60ee9d4bbc
## 为什么 9 种记录类型全是低频医疗和记账(疫苗一年一次、驱虫一季一次、体检更少), 用户今天打开 App 大概率没什么可记,时间轴是空的。竞品 35 种里有 26 种是 高频日常(洗澡/剪指甲/换猫砂/喝水/洗食盆),那才是每天打开的理由。 ## 一个比预想好得多的发现 本来担心加类型会踩到分析逻辑。把那 9 个常量的引用全捞了一遍,结论是不会: weight record.go:83 回写宠物体重;home/insight/report 的体重趋势与基准 food + poop insight.go:130 食便相关性 symptom insight.go:160 症状聚类;report 高风险计数 cost report.go:99 消费统计 vaccine report.go:137 疫苗完成度 deworm/medicine/photo 没有任何特殊分支 这些分支全都是「按某个具体常量过滤」,没有一处是 switch 全枚举、也没有 default 兜底。CreateRecord 更是完全没有 type 白名单校验。所以新增类型 不可能碰到任何现存逻辑——一行分析代码都不用改。 于是分工是: record_types 表 负责「展示什么、能记什么」 那 9 个常量 负责「哪几种参与分析」 两者各管一段,常量一个都没删。 ## 表设计 code / label / icon / group_key / sort / enabled / form / locked。 group_key 而不是 group:group 是 MySQL 保留字,GORM 会加反引号所以能跑, 但手写 SQL 排查时很容易踩。 form 区分 full(弹层里有专用 wx:elif 分支)和 simple(时间+备注+照片)。 15 种新类型全是 simple——下一步在弹层加一个通用分支吃掉它们,不然 28 个 分支再加 15 个会把 bottom-sheet 写到 1800 行。 locked 给那 9 种:有代码分支依赖、且被 care_template_items.sheet_type 引用。 ## 两条硬拦在 service 层的规则 1. locked 的删不掉 2. 已经有记录的删不掉(删了历史记录会变孤儿) 想让它从记录页消失应该用 enabled=false,历史记录仍能正常显示。 都在 service 层拦,不是靠前端隐藏按钮。改 code 同样受 2 的约束—— code 是落库值,改了历史记录会对不上任何类型。 ## bootstrap 的取舍 文章和养护模板当初按你的要求从代码里挪走了,这次 24 种类型仍然放在 seed 里, 因为它不是内容而是结构:表空着记录页一个可记事项都没有,App 直接不能用。 和管理员账号一样,只在表**完全为空**时铺一次,之后一切以数据库为准。 表非空就完全不碰,否则后台删掉的类型会每次重启自己长回来。 ListRecordTypes 在表为空时返回空数组 + 打 warn,不硬编码兜底一份—— 那等于把「可配置」又变回代码常量,下次改配置的人会发现改了没用。 ## 图标 新增 15 个(iconfont.wxss 40 → 55),全部取自本地那份 tabler-icons-3.46.0 真源,不是凭记忆画路径。已解码抽检确认路径非空。 ## 验证(生产库实跑) 建表 + 24 条 bootstrap 一次到位,分组 daily6/health7/care6/clean5 locked 9 个、simple 表单 15 个 规则 1 删 weight → 「有统计和分析逻辑依赖它,不能删」 规则 2 新增 nose 成功 → 重复 code 报错 → 删除成功 规则 3 造 1 条 tmptest 记录 → 删类型被拦;删掉记录后再删 → 成功;残留 0 规则 4 停用「美容」→ 小程序端洗护组只剩 5 项;恢复后回到 6 项 规则 3 一开始没真验到:库里唯一的记录类型是 weight,locked 先拦住了, 「已有记录」那个分支根本没走到。补了个造真记录的测试才算验过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
99 lines
2.9 KiB
Go
99 lines
2.9 KiB
Go
package handler
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"gorm.io/datatypes"
|
|
|
|
"github.com/sundynix/pets-be/internal/middleware"
|
|
"github.com/sundynix/pets-be/internal/service"
|
|
"github.com/sundynix/pets-be/pkg/response"
|
|
)
|
|
|
|
type recordReq struct {
|
|
Type string `json:"type"`
|
|
Icon string `json:"icon"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
NumValue float64 `json:"num_value"`
|
|
Category string `json:"category"`
|
|
ImageURL string `json:"image_url"`
|
|
ImageFileID string `json:"image_file_id"`
|
|
Extra datatypes.JSON `json:"extra"`
|
|
OccurredAt string `json:"occurred_at"`
|
|
}
|
|
|
|
// ListRecords GET /api/pets/:id/records?type=
|
|
func (h *Handler) ListRecords(c *gin.Context) {
|
|
var pq response.PageQuery
|
|
_ = c.ShouldBindQuery(&pq)
|
|
pq.Normalize()
|
|
records, total, err := h.svc.ListRecords(middleware.UserID(c), idParam(c, "id"), c.Query("type"), pq.Offset(), pq.Limit())
|
|
if err != nil {
|
|
respondErr(c, err)
|
|
return
|
|
}
|
|
response.OK(c, response.NewPage(records, total, pq))
|
|
}
|
|
|
|
// CreateRecord POST /api/pets/:id/records
|
|
func (h *Handler) CreateRecord(c *gin.Context) {
|
|
var req recordReq
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
response.FailParams(c, err.Error())
|
|
return
|
|
}
|
|
in := service.RecordInput{
|
|
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
|
|
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, ImageFileID: req.ImageFileID, Extra: req.Extra,
|
|
}
|
|
if req.OccurredAt != "" {
|
|
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
|
|
in.OccurredAt = &t
|
|
}
|
|
}
|
|
rec, err := h.svc.CreateRecord(middleware.UserID(c), idParam(c, "id"), in)
|
|
if err != nil {
|
|
respondErr(c, err)
|
|
return
|
|
}
|
|
response.OK(c, rec)
|
|
}
|
|
|
|
// DeleteRecord DELETE /api/records/:id
|
|
func (h *Handler) DeleteRecord(c *gin.Context) {
|
|
if err := h.svc.DeleteRecord(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
respondErr(c, err)
|
|
return
|
|
}
|
|
response.OK(c, gin.H{"deleted": true})
|
|
}
|
|
|
|
// WeightTrend GET /api/pets/:id/records/weight-trend
|
|
func (h *Handler) WeightTrend(c *gin.Context) {
|
|
points, err := h.svc.WeightTrend(middleware.UserID(c), idParam(c, "id"), 7)
|
|
if err != nil {
|
|
respondErr(c, err)
|
|
return
|
|
}
|
|
response.OK(c, points)
|
|
}
|
|
|
|
// PetInsights GET /api/pets/:id/insights
|
|
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
|
|
func (h *Handler) PetInsights(c *gin.Context) {
|
|
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
|
|
if err != nil {
|
|
respondErr(c, err)
|
|
return
|
|
}
|
|
response.OK(c, list)
|
|
}
|
|
|
|
// RecordTypes GET /api/record-types 记录页可记事项,按分组归好。
|
|
// 类型从表里来而不是前端写死,后台加一种不用发版
|
|
func (h *Handler) RecordTypes(c *gin.Context) {
|
|
response.OK(c, h.svc.ListRecordTypes())
|
|
}
|