Files
sundynix-pets/pets-be/internal/service/admin.go
T
Blizzard 9adafb1354 feat(auth): access token 缩到 2 小时 + refresh token 机制
后台登录框原来预填了 sundynix/sundynix,等于把凭证写在页面上,去掉。

令牌改为 access(2h) + refresh 两段式:
- 新表 sundynix_refresh_tokens,只存 sha256,明文只在签发时返回一次
- 有效期:小程序 30 天、后台 7 天
- 新接口 /api/auth/refresh|logout、/api/admin/refresh|logout

安全约定:
- 每次续期都轮换刷新令牌,旧的立即作废
- 作废后 60 秒内再到达算并发重试放行,超过则判定泄露、吊销该账号全部会话
- 主动退出与被连坐吊销的令牌不吃宽限期,否则「吊销全部」形同虚设
- 禁用用户时一并吊销刷新令牌,最多 2 小时彻底失去访问

两端请求层都做了单飞续期:并发请求同时 401 只发一次 refresh,
否则刷新令牌会被并发轮换掉互相打架。小程序续期失败回退 wx.login,
登录/续期请求标 noAuthRetry,避免登录失败(同样返回 40100)触发自我套娃。

顺带修掉 vite 代理仍指向 8080 的遗留(端口早已改 9090)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 09:38:19 +08:00

178 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"golang.org/x/crypto/bcrypt"
"github.com/sundynix/pets-be/internal/model"
)
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
if username != s.cfg.Admin.Username {
return nil, ErrNotFound
}
var admin model.Admin
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
return nil, ErrNotFound
}
if admin.Disabled {
return nil, ErrNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// GetAdmin 取管理员
func (s *Service) GetAdmin(id string) (*model.Admin, error) {
var admin model.Admin
if err := s.db.First(&admin, id).Error; err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// AdminStats 后台统计
type AdminStats struct {
Users int64 `json:"users"`
Pets int64 `json:"pets"`
Posts int64 `json:"posts"`
Records int64 `json:"records"`
}
// Stats Dashboard 计数
func (s *Service) Stats() (*AdminStats, error) {
var st AdminStats
s.db.Model(&model.User{}).Count(&st.Users)
s.db.Model(&model.Pet{}).Count(&st.Pets)
s.db.Model(&model.Post{}).Count(&st.Posts)
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
return &st, nil
}
// ListUsers 用户分页(keyword 匹配昵称)
func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]model.User, int64, error) {
q := s.db.Model(&model.User{})
if keyword != "" {
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
}
switch userType {
case "bot":
q = q.Where("is_bot = ?", true)
case "real":
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
}
var total int64
q.Count(&total)
var users []model.User
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
return users, total, err
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id string, disabled bool) error {
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
return err
}
if disabled {
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
s.RevokeAllRefreshTokens("user", id)
}
return nil
}
// ListPetsAdmin 宠物分页
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
var total int64
s.db.Model(&model.Pet{}).Count(&total)
var pets []model.Pet
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
return pets, total, err
}
// ListPostsAdmin 帖子分页(status 可选过滤)
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var posts []model.Post
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
return posts, total, err
}
// SetPostStatus 审核帖子(published/hidden/deleted
func (s *Service) SetPostStatus(id string, status string) error {
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListCommentsAdmin 评论分页
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
var total int64
s.db.Model(&model.Comment{}).Count(&total)
var comments []model.Comment
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
return comments, total, err
}
// DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id string) error {
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
}
// ListArticlesAdmin 文章分页(含未发布)
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
var total int64
s.db.Model(&model.Article{}).Count(&total)
var articles []model.Article
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
return articles, total, err
}
// SaveArticle 新增或更新文章(ID 为 0 则新增)
func (s *Service) SaveArticle(a *model.Article) error {
if a.ID == "" {
return s.db.Create(a).Error
}
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
"icon": a.Icon,
"title": a.Title,
"description": a.Description,
"content": a.Content,
"category": a.Category,
"related_sheet_type": a.RelatedSheetType,
"published": a.Published,
}).Error
}
// DeleteArticle 删除文章
func (s *Service) DeleteArticle(id string) error {
res := s.db.Delete(&model.Article{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListProAdmin 会员分页
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
var total int64
s.db.Model(&model.ProMembership{}).Count(&total)
var list []model.ProMembership
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}