Files
sundynix-pets/pets-be/internal/service/report.go
T
Blizzard 7636454650 feat: 下掉用户端 AI,首页悬浮键改成快速记录
## 只砍入口,后端一行不动
10 个 AI 路由、service/ai_*.go、后台配额配置页、ai_usages / ai_messages
两张表全部保留。api.js 里那 5 个方法注释掉而不是删——想开回来解注释、
把入口接上就行,不用重写。

## 5 处露出
  pages/ai 聊天页        删页面 + 从 app.json 移除
  首页「AI 今日建议」卡    整张删(含「问问 AI 养宠助手」按钮)
  计划页「AI 计划」tab    删 tab + wxml 分支 + ?tab 深链;只剩「路线图」一项后
                        整条 seg-tabs 也藏了——单选的 tab 条是个没用的控件
  异常观察的 AI 风险评估   见下
  引导页「AI 日历」文案    改成「养护日历」

## 异常观察这一处差点做错
原链路是:symptom 只收集表单 → AI 风险评估 → 用户在 risk 页再点保存 →
buildRecord 的 case 'risk' 才真正落库。**symptom 自己从来不落库。**
拆掉中间环节如果只删 risk,结果就是「记了异常但没存下来」,而且不报错。

补了 case 'symptom' 让它自己存。连带发现第二个问题:category 存的是 AI 给的
风险等级,而 report.go:46 按 category='高' 统计周报的高风险数——不写这个字段
周报会永远是 0。改成让用户自己选严重程度(轻微/需留意/严重 → 低/中/高):
谁看着它谁最清楚,比规则化猜一个准,还顺手保住了周报。

## FAB
  首页    → 打开 24 项分组选择器(弹层新增 quickRecord 分支,
            点某一项在同一个弹层内 setType 切过去,不关不跳)
  社区    → 改成发帖(它本来就不该是记录入口)
  记录/报告/我的/计划/学习  → 直接去掉,底部留白从 pad-b-fab 换成 pad-b-plain,
            不然白留 330rpx

fab 组件原来图标写死成 ai,加了 icon 属性——按钮干什么事图标就得是什么。
顺手删了 settings.js 里一个死的 onFab(Phase A 拆页时漏的,页面上根本没有 fab)。

.tg 分组样式从 record.wxss 提到 app.wxss:记录页和快速记录弹层都在用,
页面级 wxss 跨不了页(这个项目已经栽过三次)。

## 顺手修了周报两个先前就有的 bug
验证时撞上的,和 AI 无关,但 AI 建议卡拆掉后周报权重变高了:

1. 摘要把「无高风险异常记录」写死,和它自己刚算出来的 highRisk 自相矛盾——
   记了 2 条高风险,摘要还说没有
2. next_week_focus 是一整句静态文案「第 2 针疫苗提醒、继续观察体重趋势、
   避免频繁更换食物」,不管谁的宠物多大年纪都是这句,而「第 2 针疫苗」
   对成年猫狗根本不适用。改成按真实数据拼:未来 7 天到期的提醒 +
   有高风险就提就医 + 一周没称体重就提醒补记

## 验证(预生产库实跑)
  异常观察三档   低/中/高 各存一条,category 正确落库
  周报          summary「有 2 条高风险异常记录」,不再自相矛盾
  next_week_focus  有高风险的宠物 → 「继续观察上周记录的异常,必要时就医」
                   新建幼犬(提醒都在 7 天外、没称过体重)→ 「本周还没称体重,补记一次」
  症状聚类洞察    仍然工作(insight.go 按 symptom 过滤,没受影响)
  全站 grep     无 pages/ai / onFab / riskData / onGenRisk 残留
  FAB           只剩首页(plus)和社区(edit)两处

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 09:50:33 +08:00

228 lines
7.8 KiB
Go
Raw 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"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// WeeklyReport 周报聚合
type WeeklyReport struct {
Summary string `json:"summary"`
TasksCompleted int64 `json:"tasks_completed"`
WeightGain float64 `json:"weight_gain"`
HighRiskCount int64 `json:"high_risk_count"`
HealthStatus string `json:"health_status"`
NextWeekFocus string `json:"next_week_focus"`
}
// GetWeeklyReport 计算最近 7 天周报
func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
weekAgo := time.Now().AddDate(0, 0, -7)
var tasksCompleted int64
s.db.Model(&model.DailyTask{}).
Where("pet_id = ? AND done = ? AND updated_at >= ?", petID, true, weekAgo).
Count(&tasksCompleted)
// 体重增长:最近 7 天最新 - 最早
var latest, earliest model.HealthRecord
gain := 0.0
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at desc").First(&latest).Error; err == nil {
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at asc").First(&earliest).Error; err == nil {
gain = latest.NumValue - earliest.NumValue
}
}
var highRisk int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).
Count(&highRisk)
status := "稳定成长"
risk := "无高风险异常记录"
if highRisk > 0 {
status = "需要关注"
// 原来这句写死成「无高风险异常记录」,和上面刚算出来的 highRisk 自相矛盾:
// 记了 3 条高风险,摘要还说没有
risk = fmt.Sprintf("有 %d 条高风险异常记录", highRisk)
}
return &WeeklyReport{
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg%s。",
pet.Name, tasksCompleted, gain, risk),
TasksCompleted: tasksCompleted,
WeightGain: gain,
HighRiskCount: highRisk,
HealthStatus: status,
NextWeekFocus: s.nextWeekFocus(petID, highRisk, gain),
}, nil
}
// nextWeekFocus 下周重点。原来是一句写死的「第 2 针疫苗提醒、继续观察体重趋势、
// 避免频繁更换食物」——不管谁的宠物、多大年纪、有没有异常,看到的都是这句,
// 而且「第 2 针疫苗」对成年猫狗根本不适用。改成按真实数据拼。
func (s *Service) nextWeekFocus(petID string, highRisk int64, gain float64) string {
var parts []string
// 未来 7 天到期的提醒,是最具体的「下周要做什么」
now := time.Now()
var rems []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date <= ?",
petID, now, now.AddDate(0, 0, 7)).Order("next_due_date asc").Limit(2).Find(&rems)
for _, r := range rems {
parts = append(parts, r.Title)
}
if highRisk > 0 {
parts = append(parts, "继续观察上周记录的异常,必要时就医")
}
// 一周没称过体重(gain 恰好为 0 也可能是真没变,所以查条数而不是看 gain)
var weighed int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, now.AddDate(0, 0, -7)).
Count(&weighed)
if weighed == 0 {
parts = append(parts, "本周还没称体重,补记一次")
}
if len(parts) == 0 {
return "各项都稳定,保持现在的记录节奏就好。"
}
return strings.Join(parts, "") + "。"
}
// BillCategory 账单分类项
type BillCategory struct {
Category string `json:"category"`
Amount float64 `json:"amount"`
Percent int `json:"percent"`
}
// Bill 账单聚合
type Bill struct {
Period string `json:"period"`
Total float64 `json:"total"`
MaxSingle float64 `json:"max_single"`
Categories []BillCategory `json:"categories"`
}
// GetBill 账单(period=month 取本月)
func (s *Service) GetBill(userID, petID string, period string) (*Bill, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
now := time.Now()
var start time.Time
if period == "year" {
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
} else {
period = "month"
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
}
type row struct {
Category string
Amount float64
}
var rows []row
s.db.Model(&model.HealthRecord{}).
Select("category, sum(num_value) as amount").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Group("category").Scan(&rows)
var total, maxSingle float64
s.db.Model(&model.HealthRecord{}).
Select("coalesce(sum(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&total)
s.db.Model(&model.HealthRecord{}).
Select("coalesce(max(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&maxSingle)
cats := make([]BillCategory, 0, len(rows))
for _, r := range rows {
pct := 0
if total > 0 {
pct = int(r.Amount / total * 100)
}
cats = append(cats, BillCategory{Category: r.Category, Amount: r.Amount, Percent: pct})
}
return &Bill{Period: period, Total: total, MaxSingle: maxSingle, Categories: cats}, nil
}
// HealthSummary 健康摘要
type HealthSummary struct {
VaccineProgress string `json:"vaccine_progress"`
DewormStatus string `json:"deworm_status"`
WeightTrend string `json:"weight_trend"`
AnomalyCount int64 `json:"anomaly_count"`
}
// GetHealthSummary 健康摘要聚合
func (s *Service) GetHealthSummary(userID, petID string) (*HealthSummary, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
var vaccineDone int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineDone)
dewormStatus := "暂无计划"
var dewormReminder model.Reminder
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dewormReminder).Error; err == nil && dewormReminder.NextDueDate != nil {
dewormStatus = "下次 " + dewormReminder.NextDueDate.Format("1月2日")
}
var anomalies int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordSymptom).Count(&anomalies)
return &HealthSummary{
VaccineProgress: fmt.Sprintf("%d/3,即将到期", vaccineDone),
DewormStatus: dewormStatus,
WeightTrend: "稳定增长",
AnomalyCount: anomalies,
}, nil
}
// Poster 成长海报数据
type Poster struct {
PetName string `json:"pet_name"`
PetEmoji string `json:"pet_emoji"`
Age string `json:"age"`
Weight string `json:"weight"`
Stage string `json:"stage"`
TasksCompleted int64 `json:"tasks_completed"`
WeightRecords int64 `json:"weight_records"`
VaccineRecords int64 `json:"vaccine_records"`
HighRiskCount int64 `json:"high_risk_count"`
Headline string `json:"headline"`
}
// GetPoster 生成海报聚合数据
func (s *Service) GetPoster(userID, petID string) (*Poster, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
var tasksDone, weightRecs, vaccineRecs int64
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND done = ?", petID, true).Count(&tasksDone)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs)
return &Poster{
PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
HighRiskCount: 0, Headline: "稳定成长",
}, nil
}