7159930d9c
## 新用户昵称 微信登录拿不到昵称(要用户主动授权),原来一律叫「微信用户」, 社区里一屏全是同名,后台也没法区分。改成随机生成「铲屎官12138」 这种形式:10 个前缀 × 5 位数字,落库前查重,撞了重试。 新增 users.nickname_auto 标记这个昵称是系统生成的。原来判断该不该 用微信授权昵称覆盖,靠的是「昵称是不是等于『微信用户』」这个字符串 比较,改成随机昵称后这招就失效了。有标记位才能安全覆盖,同时保证 用户自己改过的昵称不会被冲掉(改昵称时会把标记置 false)。 ## 用户管理 加「宠物档案」列,显示每个用户建了几个档。用一次 GROUP BY 查完 当页所有人,不是每行一次 count——那会是 N+1。 ## 帖子审核 加发布时间列。今天的显示「今天 14:48」,昨天的显示「昨天」, 更早的显示日期——审核时关心的是「多久以前发的」而不是完整时间戳。 ## 文章 新增 16 篇,连同原有 4 篇共 20 篇,每篇 400-570 字: 猫 5 篇(新猫到家第一周 / 猫砂盆 / 公猫尿闭 / 掉毛毛球 / 老年猫信号) 狗 5 篇(社会化关键期 / 定点排便 / 遛狗时长 / 刷牙 / 中暑防护) 通用 6 篇(体重趋势 / 驱虫周期 / 绝育 / 急诊清单 / 就医准备 / 养宠预算) 沿用和 AI 助手一样的安全护栏:不做诊断、不给药物剂量,凡是可能 延误病情的都写清「什么时候必须去医院」。犬用驱虫药对猫的致死风险 这类真实事故也写进去了。 seedArticles 从「库空才插」改成「按标题补缺失」,以后往内置清单加 文章,老库启动也能拿到。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
210 lines
5.9 KiB
Go
210 lines
5.9 KiB
Go
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
|
||
if err == nil {
|
||
s.fillPetCounts(users)
|
||
}
|
||
return users, total, err
|
||
}
|
||
|
||
// fillPetCounts 一次 GROUP BY 查出这一页用户各建了几个宠物档,
|
||
// 避免每行一次 count 把列表打成 N+1 查询
|
||
func (s *Service) fillPetCounts(users []model.User) {
|
||
if len(users) == 0 {
|
||
return
|
||
}
|
||
ids := make([]string, 0, len(users))
|
||
for i := range users {
|
||
ids = append(ids, users[i].ID)
|
||
}
|
||
var rows []struct {
|
||
UserID string
|
||
N int
|
||
}
|
||
if err := s.db.Model(&model.Pet{}).
|
||
Select("user_id, count(*) as n").
|
||
Where("user_id IN ?", ids).
|
||
Group("user_id").Scan(&rows).Error; err != nil {
|
||
return
|
||
}
|
||
m := make(map[string]int, len(rows))
|
||
for _, r := range rows {
|
||
m[r.UserID] = r.N
|
||
}
|
||
for i := range users {
|
||
users[i].PetCount = m[users[i].ID]
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|