Files
sundynix-pets/pets-be/internal/handler/admin.go
T
Blizzard d9d7e128ff feat: 引导页阶段按生日推断 + 8 套养护模板按临床共识重配
## 引导页三个逻辑漏洞

1. **阶段和生日脱节**:用户填了 3 个月的生日,却能手选「成年期」,
   而阶段决定疫苗针次、驱虫周期和每天要做的事,选错整套计划都是偏的。
   现在按生日自动推断(猫狗同:12 月龄成年、7 岁进老年),仍可手改,
   但改成矛盾值时会弹框说明代价;服务端 NormalizeStage 兜底。

2. **`goals` 是假选择**:6 个勾选框收上来只存进 pets.goals,一处没用过。
   现在真正生效:体重提醒、月度报告按勾选生成,勾了「想控制养宠开销」
   会多一条记账任务。疫苗和驱虫不给关——狂犬是法定要求,漏驱虫真会出事。

3. **「刚到家 0-30 天」和年龄阶段互斥**:挤在一个四选一里,刚接回家的
   成年猫只能二选一。现在拆开:stage 只存年龄阶段,新增 pets.arrived_at,
   到家不满 30 天时把安置期那套任务和计划叠加上去(同标题去重)。

顺带:生日/到家日期不能选未来(前后端各拦一道)、体重范围校验。

## 疫苗提醒改为按月龄算

原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
现在 dueDaysFor 按真实月龄推:<2 月龄等到 8 周龄、2-5 月龄间隔 21 天、
5-12 月龄两周内补齐、成年走年度加强。标题也跟着改写,否则用户照着
「年度疫苗加强」去打错针。

年度疫苗不再编一个假的到期日(原来写 30 天后),改用频率描述。

## 8 套模板按临床共识重配

依据 WSAVA 疫苗指南、ESCCAP/CAPC 驱虫建议、AAHA 生命阶段指南整理:
猫三联 3 针间隔 3-4 周末针不早于 16 周龄、犬联苗 2-3 针 + 3 月龄狂犬、
体内驱虫 6 月龄前每月一次之后每 3 个月、幼崽 3-6 月龄每天 3-4 餐、
成年犬每天 2 次共 30-60 分钟遛狗、老年建议半年一次体检。
每套从 11 条扩到 10-14 条,描述写成能直接照做的具体动作。

后台新增「恢复默认」:启动 seed 改为只补缺失、不覆盖已有(管理员可能
改过),内置模板更新后必须从后台显式推一次。

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

410 lines
11 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
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
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,
"task_sheets": []string{"", "weight", "poop", "food", "symptom", "vaccine", "medicine"},
"plan_sheets": []string{"", "weight", "vaccine", "medicine", "report", "taskDetail"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminGetCareTemplate GET /api/admin/care-templates?species=&stage=
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
data, err := h.svc.GetCareTemplate(c.Query("species"), c.Query("stage"))
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, data)
}
type careTemplateReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate PUT /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
}
if err := h.svc.SaveCareTemplate(req.Species, req.Stage, req.Items); err != nil {
response.FailErr(c, err)
return
}
data, _ := h.svc.GetCareTemplate(req.Species, req.Stage)
response.OK(c, data)
}
// AdminResetCareTemplate POST /api/admin/care-templates/reset
// 把某组合恢复成内置默认。启动时的 seed 只补缺失、不覆盖已有,
// 想把新版内置模板推到线上必须走这个显式动作。
func (h *Handler) AdminResetCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.ResetCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
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})
}