Files
sundynix-pets/pets-be/internal/handler/admin.go
T
Blizzard afdbf94450 feat(admin): 养护方案页重写 —— 多套、重复规则、"弹层"改成真实的记录事项
## 你问的那个「弹层」是什么
sheet_type = 用户在小程序点这条计划时,直接跳到哪个记录表单。
「体重记录」这条关联 weight,点了就是体重表单,不用自己去记录页找。

叫「弹层」是因为那时候记录表单是底部弹出的浮层。三件事让这个名字烂掉了:
  1. 记录表单上一批已经搬成 pages/addrecord 独立页面了
  2. 可选值写死 6 个,而记录事项现在有 24 种且后台可配 —— 新加的一个都选不到
  3. 里头的 report / taskDetail 早就不存在,选了等于点了没反应

现在改成「关联记录事项」,下拉读 record_types 表。后台加一种事项,
这里立刻能选到。

## 这一页原来和后端已经对不上了
它的结构(今日任务 / 30 天计划 / 提醒 三段、第 N 天偏移)就是上一个提交在后端
推翻掉的那套。你在上面配的东西一部分已经不会生效——所以先改它,不等前端。

  三段 → 两段(计划事项 / 到期提醒)
  「第 N 天 + 标签」 → 重复规则:每天 / 每周(勾星期几)/ 每月(几号)/ 一次性
  一个 (物种,阶段) 一套 → 多套,列表页展示,每套有名字和摘要

## 后端跟着改成按 id 存取
原来是「按 species+stage 覆盖」:GET/PUT /admin/care-templates?species=&stage=。
那个模型下「让用户选方案」无从选起。改成
  GET    /admin/care-templates       列全部
  GET    /admin/care-templates/:id
  POST   /admin/care-templates       新增或更新
  DELETE /admin/care-templates/:id

## 唯一索引是真拦路石
从模型 tag 里去掉 uniqueIndex:idx_species_stage 之后,新增第二套仍然报
Duplicate entry —— **AutoMigrate 会加索引但不会删已有的**。写了个一次性程序
DropIndex 才通。这个坑值得记:改索引不能只改 tag。

## 校验放服务端
一条配错(「每周」没勾星期几)会让套用时静默少排几条,用户看不出是配错了。
所以保存时就拦,并且报出是哪一条:「梳毛」选了每周,但没勾星期几。

## 列表里报「展开后大概多少条」
只报明细条数会骗人:4 条「每天」套一整月就是 120 多条。
列表和小程序端的方案卡都按重复方式分开报数。

## 验证(预生产库实跑 service 层)
  8 套现有方案迁移后都有名字
  没名字 / 每周没勾星期几 / 每月几号填 0 → 三条校验都拦住并指名道姓
  (猫,成年期) 成功配出第二套「成年猫省心版」,小程序端能看到 2 套可选
  tsc --noEmit 通过;npm run build 后重启,/admin/ 资源哈希与新构建一致

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 11:59:52 +08:00

532 lines
14 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 handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
type adminLoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
// AdminLogin POST /api/admin/login
func (h *Handler) AdminLogin(c *gin.Context) {
var req adminLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
admin, err := h.svc.AdminLogin(req.Username, req.Password)
if err != nil {
response.Fail(c, 40100, "账号或密码错误")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"admin": admin,
}, nil
}
// AdminRefresh POST /api/admin/refresh
func (h *Handler) AdminRefresh(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
admin, err := h.svc.GetAdmin(adminID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if admin.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
func (h *Handler) AdminLogout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
response.OK(c, gin.H{"ok": true})
}
// AdminMe GET /api/admin/me
func (h *Handler) AdminMe(c *gin.Context) {
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, admin)
}
// AdminStats GET /api/admin/stats
func (h *Handler) AdminStats(c *gin.Context) {
stats, err := h.svc.Stats()
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, stats)
}
type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
Status string `form:"status"`
Type string `form:"type"`
}
// AdminListUsers GET /api/admin/users
func (h *Handler) AdminListUsers(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
users, total, err := h.svc.ListUsers(req.Keyword, req.Type, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(users, total, req.PageQuery))
}
type disableReq struct {
Disabled bool `json:"disabled"`
}
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
var req disableReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetUserDisabled(idParam(c, "id"), req.Disabled); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPets GET /api/admin/pets
func (h *Handler) AdminListPets(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(pets, total, req.PageQuery))
}
// AdminListPosts GET /api/admin/posts
func (h *Handler) AdminListPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
type postStatusReq struct {
Status string `json:"status"`
}
// AdminSetPostStatus PUT /api/admin/posts/:id/status
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetPostStatus(idParam(c, "id"), req.Status); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListComments GET /api/admin/comments
func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListArticles GET /api/admin/articles
func (h *Handler) AdminListArticles(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(articles, total, req.PageQuery))
}
type articleReq struct {
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Category string `json:"category"`
RelatedSheetType string `json:"related_sheet_type"`
Published bool `json:"published"`
}
// AdminSaveArticle POST /api/admin/articlesid>0 为更新)
func (h *Handler) AdminSaveArticle(c *gin.Context) {
var req articleReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
article := &model.Article{
Icon: req.Icon, Title: req.Title, Description: req.Description, Content: req.Content,
Category: req.Category, RelatedSheetType: req.RelatedSheetType, Published: req.Published,
}
article.ID = req.ID
if err := h.svc.SaveArticle(article); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, article)
}
// AdminDeleteArticle DELETE /api/admin/articles/:id
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
if err := h.svc.DeleteArticle(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPro GET /api/admin/memberships
func (h *Handler) AdminListPro(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
// ── 品种 ──
type breedReq struct {
ID string `json:"id"`
Species string `json:"species"`
Name string `json:"name"`
Initial string `json:"initial"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
}
// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回
func (h *Handler) AdminListBreeds(c *gin.Context) {
response.OK(c, h.svc.AdminListBreeds(c.Query("species")))
}
// AdminSaveBreed POST /api/admin/breeds
func (h *Handler) AdminSaveBreed(c *gin.Context) {
var req breedReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
b := &model.Breed{Species: req.Species, Name: req.Name,
Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled}
b.ID = req.ID
if err := h.svc.SaveBreed(b); err != nil {
respondErr(c, err)
return
}
response.OK(c, b)
}
// AdminDeleteBreed DELETE /api/admin/breeds/:id
func (h *Handler) AdminDeleteBreed(c *gin.Context) {
if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// ── 记录类型 ──
type recordTypeReq struct {
ID string `json:"id"`
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Group string `json:"group"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Form string `json:"form"`
Fields string `json:"fields_raw"`
}
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
func (h *Handler) AdminListRecordTypes(c *gin.Context) {
response.OK(c, h.svc.AdminListRecordTypes())
}
// AdminSaveRecordType POST /api/admin/record-types 新增或更新
func (h *Handler) AdminSaveRecordType(c *gin.Context) {
var req recordTypeReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
t := &model.RecordType{
Code: req.Code, Label: req.Label, Icon: req.Icon,
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
Fields: req.Fields,
}
t.ID = req.ID
if err := h.svc.SaveRecordType(t); err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// AdminDeleteRecordType DELETE /api/admin/record-types/:id
// locked 的和已有记录的删不了,service 层硬拦
func (h *Handler) AdminDeleteRecordType(c *gin.Context) {
if err := h.svc.DeleteRecordType(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
response.OK(c, gin.H{
"species": []gin.H{
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
// 「关联记录事项」的可选值读 record_types 表,不再写死。
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
// 而后台新加的 24 种事项一个都选不到
"record_types": h.svc.AdminListRecordTypes(),
"repeats": []string{"once", "daily", "weekly", "monthly"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
response.OK(c, h.svc.AdminListTemplates())
}
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tpl)
}
type careTemplateReq struct {
ID string `json:"id"`
Species string `json:"species"`
Stage string `json:"stage"`
Name string `json:"name"`
Summary string `json:"summary"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
tpl := &model.CareTemplate{
Species: req.Species, Stage: req.Stage, Name: req.Name,
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
}
tpl.ID = req.ID
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
respondErr(c, err)
return
}
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, saved)
}
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
}
// AdminGenerateCareTemplate POST /api/admin/care-templates/generate
// AI 生成草稿(不落库),未开启 AI 时回退内置默认
func (h *Handler) AdminGenerateCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.GenerateCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
// AdminGetCommunityBot GET /api/admin/community-bot
func (h *Handler) AdminGetCommunityBot(c *gin.Context) {
response.OK(c, h.svc.GetCommunityBotConfig())
}
// AdminSaveCommunityBot PUT /api/admin/community-bot
func (h *Handler) AdminSaveCommunityBot(c *gin.Context) {
var req model.CommunityBotConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
saved, err := h.svc.SaveCommunityBotConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, saved)
}
type communityGenReq struct {
Count int `json:"count"`
}
// AdminGenerateCommunityPosts POST /api/admin/community-bot/generate
func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
var req communityGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Count <= 0 {
req.Count = 1
}
if req.Count > 30 {
req.Count = 30
}
made, err := h.svc.GenerateCommunityPosts(req.Count)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"made": made})
}
// AdminGetAIQuota GET /api/admin/ai-quota
func (h *Handler) AdminGetAIQuota(c *gin.Context) {
response.OK(c, h.svc.GetAIQuotaConfig())
}
// AdminSaveAIQuota PUT /api/admin/ai-quota
func (h *Handler) AdminSaveAIQuota(c *gin.Context) {
var req model.AIQuotaConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
cfg, err := h.svc.SaveAIQuotaConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, cfg)
}