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` }),
// 任务