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 }