From 75765b3ee8721dc866685b5eb2f6dd90f131f56c Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 29 Jul 2026 15:03:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=81=A5=E5=BA=B7=E6=B4=9E=E5=AF=9F?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=8A=8A=E6=95=A3=E8=90=BD=E7=9A=84=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=8F=98=E6=88=90=E8=83=BD=E7=9C=8B=E6=87=82=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E8=AE=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前用户记几十条数据只换回一条折线,数据没有回到他身上,所以不记也不亏。 新增 GET /api/pets/:id/insights,只看最近 90 天(更早的对「现在怎么样」 没参考价值,还会让关联分析找出一堆巧合),产出最多 6 条按严重度排序的结论: 1. 体重趋势 —— 单次数字没意义,跨度 ≥14 天的连续同向变化才算。 跌 10% 报警建议血检,涨 15% 提醒控制,跌 5% 持续观察 2. 换粮 → 软便的时间关联 —— 找异常排便前 72 小时内的饮食记录。 这是用户最看不出来的一类:两条记录隔两三天、分属不同类型,翻时间轴翻不出来 3. 异常扎堆 —— 两周内 ≥2 次异常。偶发一次和反复出现完全不是一回事 4. 逾期提醒 —— 设了不看等于没设 5. 同龄体重对比 —— 同物种、月龄差 2 个月内其它宠物的中位数。 样本 <8 只直接不显示:拿 3 只算出来的「中位数」比不给还糟 6. 记录习惯 —— 数据不足时negative空手而归,给一句「再记几条就能看出规律」 每条都带 evidence(支撑它的具体数据)和 action(点击直接跳到对应记录入口), 结论不能只是断言。文案沿用 AI 助手同一套护栏:不做诊断,异常一律写清 什么时候必须就医。 前端落在记录页体重趋势卡上方,三档配色区分严重度。 实测(造 4 条体重下滑 + 换粮 + 软便 + 两次异常): 🔴 体重掉了 13%,建议就医检查(39 天内 4.50→3.90kg) 🔴 两周内记了 2 次异常 🟠 这次「软便」之前 2 天有过饮食变化(7月20日换粮 → 7月22日软便) Co-Authored-By: Claude Opus 5 --- pets-be/internal/handler/record.go | 11 ++ pets-be/internal/router/router.go | 1 + pets-be/internal/service/insight.go | 287 ++++++++++++++++++++++++++++ pets-fe/pages/record/record.js | 21 ++ pets-fe/pages/record/record.wxml | 12 ++ pets-fe/pages/record/record.wxss | 15 ++ pets-fe/utils/api.js | 1 + 7 files changed, 348 insertions(+) create mode 100644 pets-be/internal/service/insight.go diff --git a/pets-be/internal/handler/record.go b/pets-be/internal/handler/record.go index deab119..d1b9703 100644 --- a/pets-be/internal/handler/record.go +++ b/pets-be/internal/handler/record.go @@ -79,3 +79,14 @@ func (h *Handler) WeightTrend(c *gin.Context) { } 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) +} diff --git a/pets-be/internal/router/router.go b/pets-be/internal/router/router.go index 8418d06..b110ac5 100644 --- a/pets-be/internal/router/router.go +++ b/pets-be/internal/router/router.go @@ -59,6 +59,7 @@ func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manage g.GET("/pets/:id/records", h.ListRecords) g.POST("/pets/:id/records", h.CreateRecord) g.GET("/pets/:id/records/weight-trend", h.WeightTrend) + g.GET("/pets/:id/insights", h.PetInsights) g.DELETE("/records/:id", h.DeleteRecord) g.GET("/pets/:id/tasks", h.ListTasks) diff --git a/pets-be/internal/service/insight.go b/pets-be/internal/service/insight.go new file mode 100644 index 0000000..4f3426d --- /dev/null +++ b/pets-be/internal/service/insight.go @@ -0,0 +1,287 @@ +package service + +import ( + "fmt" + "sort" + "time" + + "github.com/sundynix/pets-be/internal/model" +) + +// Insight 一条健康洞察。 +// 这是「记录」这件事的兑现方式:用户记了几十条数据,之前只换回一条折线, +// 数据没有回到他身上。洞察要说出他自己看不出来的东西。 +type Insight struct { + Kind string `json:"kind"` // trend | correlation | overdue | benchmark | habit + Level string `json:"level"` // info | warn | alert + Title string `json:"title"` // 一句话结论 + Detail string `json:"detail"` // 依据 + 建议 + Action string `json:"action"` // 关联的记录弹层类型,前端据此给按钮 + Evidence string `json:"evidence"` // 支撑这条结论的具体数据 +} + +const ( + insightInfo = "info" + insightWarn = "warn" + insightAlert = "alert" +) + +// PetInsights 汇总一只宠物当前值得说的事。按严重度排序,最多 6 条。 +func (s *Service) PetInsights(userID, petID string) ([]Insight, error) { + pet, err := s.ownedPet(userID, petID) + if err != nil { + return nil, err + } + + // 只看最近 90 天。更早的数据对「现在怎么样」没有参考价值, + // 还会让关联分析找出一堆巧合。 + since := time.Now().AddDate(0, 0, -90) + var records []model.HealthRecord + if err := s.db.Where("pet_id = ? AND occurred_at >= ?", petID, since). + Order("occurred_at asc").Find(&records).Error; err != nil { + return nil, err + } + + var out []Insight + out = appendIf(out, s.weightTrendInsight(records)) + out = appendIf(out, s.foodPoopCorrelation(records)) + out = appendIf(out, s.symptomClusterInsight(records)) + out = appendIf(out, s.overdueReminderInsight(petID)) + out = appendIf(out, s.weightBenchmark(pet, records)) + out = appendIf(out, s.recordHabitInsight(records)) + + // alert > warn > info,同级保持原顺序 + rank := map[string]int{insightAlert: 0, insightWarn: 1, insightInfo: 2} + sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Level] < rank[out[j].Level] }) + if len(out) > 6 { + out = out[:6] + } + return out, nil +} + +func appendIf(list []Insight, in *Insight) []Insight { + if in != nil { + list = append(list, *in) + } + return list +} + +func byType(records []model.HealthRecord, t string) []model.HealthRecord { + var out []model.HealthRecord + for _, r := range records { + if r.Type == t { + out = append(out, r) + } + } + return out +} + +// weightTrendInsight 体重趋势。单次数字没意义,连续同向变化才有。 +func (s *Service) weightTrendInsight(records []model.HealthRecord) *Insight { + w := byType(records, model.RecordWeight) + if len(w) < 3 { + return nil + } + first, last := w[0], w[len(w)-1] + if first.NumValue <= 0 || last.NumValue <= 0 { + return nil + } + days := int(last.OccurredAt.Sub(first.OccurredAt).Hours() / 24) + if days < 14 { + return nil // 时间跨度太短,波动多半是称重误差 + } + pct := (last.NumValue - first.NumValue) / first.NumValue * 100 + ev := fmt.Sprintf("%d 天内 %.2fkg → %.2fkg(%d 次记录)", days, first.NumValue, last.NumValue, len(w)) + + switch { + case pct <= -10: + return &Insight{ + Kind: "trend", Level: insightAlert, Action: "symptom", + Title: fmt.Sprintf("体重掉了 %.0f%%,建议就医检查", -pct), + Detail: "短期内明显掉秤,常见于甲亢、慢性肾病、糖尿病、寄生虫和口腔问题。即使精神食欲看起来正常,也建议做一次血检。", + Evidence: ev, + } + case pct >= 15: + return &Insight{ + Kind: "trend", Level: insightWarn, Action: "food", + Title: fmt.Sprintf("体重涨了 %.0f%%,注意控制", pct), + Detail: "超重是绝大多数慢性病的起点。摸得到肋骨但看不见明显轮廓才是标准体型,先从减少 10-20% 的喂食量开始。", + Evidence: ev, + } + case pct <= -5: + return &Insight{ + Kind: "trend", Level: insightWarn, Action: "weight", + Title: fmt.Sprintf("体重在缓慢下降(%.0f%%)", -pct), + Detail: "还不到紧急程度,但值得继续盯。保持每周称重,如果一个月内继续下降就去做次体检。", + Evidence: ev, + } + } + return &Insight{ + Kind: "trend", Level: insightInfo, Action: "weight", + Title: "体重保持稳定", + Detail: "这段时间的波动在正常范围内,继续保持现在的喂食量和运动量。", + Evidence: ev, + } +} + +// foodPoopCorrelation 换粮/饮食变化 → 排便异常的时间关联。 +// 这是用户自己最看不出来的一类:两条记录隔了两三天,翻时间轴翻不出规律。 +func (s *Service) foodPoopCorrelation(records []model.HealthRecord) *Insight { + foods := byType(records, model.RecordFood) + poops := byType(records, model.RecordPoop) + if len(foods) == 0 || len(poops) == 0 { + return nil + } + for _, p := range poops { + if p.Category == "" || p.Category == "正常" { + continue + } + // 往前找 3 天内的饮食记录 + for i := len(foods) - 1; i >= 0; i-- { + f := foods[i] + gap := p.OccurredAt.Sub(f.OccurredAt) + if gap < 0 || gap > 72*time.Hour { + continue + } + return &Insight{ + Kind: "correlation", Level: insightWarn, Action: "food", + Title: fmt.Sprintf("这次「%s」之前 %d 天有过饮食变化", p.Category, int(gap.Hours()/24)), + Detail: "换粮或加新食物引起的软便很常见。回到原来的粮,之后按 7 天过渡:每天替换约 1/7,软便就放慢速度。持续超过 3 天或带血要就医。", + Evidence: fmt.Sprintf("%s 记录「%s」→ %s 出现「%s」", + f.OccurredAt.Format("1月2日"), f.Title, p.OccurredAt.Format("1月2日"), p.Category), + } + } + } + return nil +} + +// symptomClusterInsight 异常记录扎堆。单次异常不说明什么,短期内反复才是信号。 +func (s *Service) symptomClusterInsight(records []model.HealthRecord) *Insight { + sym := byType(records, model.RecordSymptom) + if len(sym) < 2 { + return nil + } + recent := 0 + cut := time.Now().AddDate(0, 0, -14) + var last time.Time + for _, r := range sym { + if r.OccurredAt.After(cut) { + recent++ + last = r.OccurredAt + } + } + if recent < 2 { + return nil + } + return &Insight{ + Kind: "habit", Level: insightAlert, Action: "symptom", + Title: fmt.Sprintf("两周内记了 %d 次异常", recent), + Detail: "反复出现的异常和偶发一次完全不是一回事。把这些记录导出成就医摘要带去医院,比口头描述有用得多。", + Evidence: fmt.Sprintf("最近一次:%s", last.Format("1月2日")), + } +} + +// overdueReminderInsight 已经过期的提醒。设了不看等于没设。 +func (s *Service) overdueReminderInsight(petID string) *Insight { + var rems []model.Reminder + if err := s.db.Where("pet_id = ? AND next_due_date IS NOT NULL AND next_due_date < ?", + petID, time.Now()).Order("next_due_date asc").Find(&rems).Error; err != nil { + return nil + } + if len(rems) == 0 { + return nil + } + r := rems[0] + days := int(time.Since(*r.NextDueDate).Hours() / 24) + action := "vaccine" + if r.Type == model.ReminderDeworm { + action = "medicine" + } + return &Insight{ + Kind: "overdue", Level: insightAlert, Action: action, + Title: fmt.Sprintf("「%s」已经过期 %d 天", r.Title, days), + Detail: "疫苗和驱虫拖久了保护力会断档,尤其驱虫,遛弯和接触其它动物的风险是持续的。补上之后记得把下次日期也设好。", + Evidence: fmt.Sprintf("原定 %s", r.NextDueDate.Format("2006年1月2日")), + } +} + +// weightBenchmark 同龄同物种对比。 +// 这是单机数据做不到、只有平台侧能给的价值。样本不足就不给结论—— +// 拿 3 只猫算出来的「中位数」比不给还糟。 +func (s *Service) weightBenchmark(pet *model.Pet, records []model.HealthRecord) *Insight { + w := byType(records, model.RecordWeight) + if len(w) == 0 || pet.Birthday == nil || pet.Birthday.IsZero() { + return nil + } + mine := w[len(w)-1].NumValue + if mine <= 0 { + return nil + } + months := monthsSince(*pet.Birthday) + + // 同物种、月龄相差 2 个月以内的其它宠物,取各自最近一次体重 + lo := time.Now().AddDate(0, -(months + 2), 0) + hi := time.Now().AddDate(0, -maxInt(months-2, 0), 0) + var peers []float64 + rows, err := s.db.Raw(` + SELECT r.num_value FROM sundynix_health_records r + JOIN ( + SELECT pet_id, MAX(occurred_at) AS t FROM sundynix_health_records + WHERE type = ? GROUP BY pet_id + ) latest ON latest.pet_id = r.pet_id AND latest.occurred_at = latest.t + JOIN sundynix_pets p ON p.id = r.pet_id + WHERE r.type = ? AND r.num_value > 0 AND p.id <> ? + AND p.type = ? AND p.birthday BETWEEN ? AND ?`, + model.RecordWeight, model.RecordWeight, pet.ID, pet.Type, lo, hi).Rows() + if err != nil { + return nil + } + defer rows.Close() + for rows.Next() { + var v float64 + if rows.Scan(&v) == nil && v > 0 { + peers = append(peers, v) + } + } + // 样本太少给不出可信的参考值,宁可不显示 + if len(peers) < 8 { + return nil + } + sort.Float64s(peers) + med := peers[len(peers)/2] + diff := (mine - med) / med * 100 + + level, title := insightInfo, "体重和同龄伙伴差不多" + if diff >= 20 { + level, title = insightWarn, fmt.Sprintf("比同龄伙伴重 %.0f%%", diff) + } else if diff <= -20 { + level, title = insightWarn, fmt.Sprintf("比同龄伙伴轻 %.0f%%", -diff) + } + return &Insight{ + Kind: "benchmark", Level: level, Action: "weight", + Title: title, + Detail: "这只是同月龄的横向参考,品种和体型差异很大,不能当成标准。真正要看的还是它自己的体重曲线是否平稳。", + Evidence: fmt.Sprintf("%d 月龄 · 它 %.2fkg,%d 只同龄%s的中位数 %.2fkg", + months, mine, len(peers), pet.Type, med), + } +} + +// recordHabitInsight 记录习惯。没数据就没洞察,这条负责把用户拉回来记。 +func (s *Service) recordHabitInsight(records []model.HealthRecord) *Insight { + if len(records) >= 5 { + return nil + } + return &Insight{ + Kind: "habit", Level: insightInfo, Action: "weight", + Title: "再记几条,这里就能看出规律了", + Detail: "体重、便便、饮食三类各记几次之后,能自动帮你发现「换粮第几天开始软便」这种自己翻记录看不出来的关联。", + Evidence: fmt.Sprintf("目前共 %d 条记录", len(records)), + } +} + +func maxInt(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pets-fe/pages/record/record.js b/pets-fe/pages/record/record.js index 7a7beef..f1472fa 100644 --- a/pets-fe/pages/record/record.js +++ b/pets-fe/pages/record/record.js @@ -93,6 +93,12 @@ function buildChart(points) { return { svg: 'data:image/svg+xml,' + encodeURIComponent(svg), dots }; } +// 洞察等级 → 图标。alert 用警告号,其余按类别给 +const INS_ICON = { alert: 'warn', warn: 'warn', info: 'trend' }; +function mapInsight(i) { + return { ...i, icon: i.level === 'info' ? (INS_ICON.info) : INS_ICON.alert }; +} + const REC_PAGE = 6; Page({ @@ -105,6 +111,7 @@ Page({ recFilters: REC_FILTERS, recFilterIdx: 0, recordItems: RECORD_ITEMS, + insights: [], trendSvg: '', trendStats: null, trendDots: [], @@ -165,6 +172,19 @@ Page({ }, }); }, + // 健康洞察:把散落的记录变成结论。点一条直接跳到对应的记录入口 + loadInsights() { + const id = store.currentPetId(); + if (!id) return this.setData({ insights: [] }); + api + .petInsights(id) + .then((list) => this.setData({ insights: (list || []).map(mapInsight) })) + .catch(() => this.setData({ insights: [] })); + }, + onTapInsight(e) { + const it = this.data.insights[e.currentTarget.dataset.index]; + if (it && it.action) this.openSheetType(it.action); + }, loadRecords() { const id = store.currentPetId(); if (!id) return; @@ -176,6 +196,7 @@ Page({ }) .catch((e) => toastErr(e)); this.loadTrend(); + this.loadInsights(); }, loadMoreRecords() { const id = store.currentPetId(); diff --git a/pets-fe/pages/record/record.wxml b/pets-fe/pages/record/record.wxml index 64203d9..87d6d5f 100644 --- a/pets-fe/pages/record/record.wxml +++ b/pets-fe/pages/record/record.wxml @@ -20,6 +20,18 @@ + + 健康洞察来自你的记录 + + + + {{item.title}} + + {{item.detail}} + {{item.evidence}} + + + 体重趋势记录 最新 {{trendStats.latest}}kg · 较首次 {{trendStats.delta >= 0 ? '+' : ''}}{{trendStats.delta}}kg · 共 {{trendStats.count}} 次 diff --git a/pets-fe/pages/record/record.wxss b/pets-fe/pages/record/record.wxss index 33a0db1..25c7996 100644 --- a/pets-fe/pages/record/record.wxss +++ b/pets-fe/pages/record/record.wxss @@ -42,3 +42,18 @@ margin-top:var(--sp-3);padding:var(--sp-3);text-align:center;color:var(--primary-dark); background:var(--surface-2);border-radius:var(--r-md);font-size:var(--fs-sm);font-weight:var(--fw-b); } + +/* 健康洞察 */ +.ins{border-radius:var(--r-md);padding:var(--sp-4);margin-bottom:var(--sp-2)} +.ins:last-child{margin-bottom:0} +.ins-info{background:var(--surface-2)} +.ins-warn{background:var(--primary-soft)} +.ins-alert{background:var(--red-soft)} +.ins-head{display:flex;align-items:flex-start;gap:var(--sp-2)} +.ins-ic{flex:none;margin-top:2rpx} +.ins-info .ins-ic{color:var(--green-ink)} +.ins-warn .ins-ic{color:var(--primary-ink)} +.ins-alert .ins-ic{color:var(--red-ink)} +.ins-title{flex:1;font-size:var(--fs-md);font-weight:var(--fw-b);line-height:1.4} +.ins-detail{color:var(--text-2);font-size:var(--fs-sm);line-height:1.6;margin-top:var(--sp-2)} +.ins-ev{color:var(--muted);font-size:var(--fs-cap);margin-top:var(--sp-2)} diff --git a/pets-fe/utils/api.js b/pets-fe/utils/api.js index c4aa5ab..ba0f016 100644 --- a/pets-fe/utils/api.js +++ b/pets-fe/utils/api.js @@ -66,6 +66,7 @@ const api = { }, createRecord: (id, body) => request({ url: `/api/pets/${id}/records`, method: 'POST', data: body }), deleteRecord: (recordId) => request({ url: `/api/records/${recordId}`, method: 'DELETE' }), + petInsights: (id) => request({ url: `/api/pets/${id}/insights` }), weightTrend: (id) => request({ url: `/api/pets/${id}/records/weight-trend` }), // 任务