Files
Blizzard 75765b3ee8 feat: 健康洞察——把散落的记录变成能看懂的结论
之前用户记几十条数据只换回一条折线,数据没有回到他身上,所以不记也不亏。
新增 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 <noreply@anthropic.com>
2026-07-29 15:03:36 +08:00

288 lines
10 KiB
Go
Raw Permalink 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 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
}