Files
sundynix-pets/pets-be/internal/service/home.go
T
Blizzard 609f7d06cf init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导),
  服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar
- pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页,
  微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台
- 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:36:55 +08:00

230 lines
7.5 KiB
Go

package service
import (
"fmt"
"math"
"time"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// HomeInsight 首页洞察条一项:Bold 为大号加粗值,Text 为说明
type HomeInsight struct {
Bold string `json:"bold"`
Text string `json:"text"`
}
// WeekDay 首页日期条一格
type WeekDay struct {
Weekday string `json:"weekday"` // 一二三四五六日
Day int `json:"day"`
Active bool `json:"active"` // 是否今天
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
}
// HomeSummary 首页所需的全部动态数据
type HomeSummary struct {
Greeting string `json:"greeting"`
Insights []HomeInsight `json:"insights"`
Week []WeekDay `json:"week"`
Advice string `json:"advice"`
HealthPct int `json:"health_pct"`
HealthStatus string `json:"health_status"`
}
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
// GetHomeSummary 计算首页汇总
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
now := time.Now()
res := &HomeSummary{
Greeting: greeting(now, s.userNickname(userID)),
HealthStatus: pet.HealthStatus,
Insights: s.homeInsights(petID, now),
Week: s.homeWeek(petID, now),
Advice: s.dailyAdvice(pet),
HealthPct: s.todayCompletionPct(petID, now),
}
return res, nil
}
func (s *Service) userNickname(userID uint) string {
var u model.User
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
return u.Nickname
}
return "铲屎官"
}
func greeting(t time.Time, name string) string {
h := t.Hour()
switch {
case h < 11:
return "早上好," + name
case h < 14:
return "中午好," + name
case h < 18:
return "下午好," + name
default:
return "晚上好," + name
}
}
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
insights := make([]HomeInsight, 0, 3)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// 1) 最近一条未到期的提醒
var rem model.Reminder
if err := s.db.Where("pet_id = ? AND next_due_date >= ?", petID, today).
Order("next_due_date asc").First(&rem).Error; err == nil && rem.NextDueDate != nil {
days := int(math.Ceil(rem.NextDueDate.Sub(today).Hours() / 24))
bold := fmt.Sprintf("%d天后", days)
if days <= 0 {
bold = "今天"
}
insights = append(insights, HomeInsight{Bold: bold, Text: rem.Title})
}
// 2) 本周体重变化
weekAgo := today.AddDate(0, 0, -7)
var latest, earliest model.HealthRecord
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at desc").First(&latest).Error == nil {
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at asc").First(&earliest).Error == nil {
d := latest.NumValue - earliest.NumValue
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%+.1fkg", d), Text: "本周体重"})
}
}
// 3) 连续记录天数
if n := s.streakDays(petID, now); n > 0 {
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%d天", n), Text: "连续记录"})
}
return insights
}
// streakDays 从今天往前,连续有健康记录的天数
func (s *Service) streakDays(petID uint, now time.Time) int {
streak := 0
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := 0; i < 60; i++ {
start := day.AddDate(0, 0, -i)
end := start.AddDate(0, 0, 1)
var cnt int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Count(&cnt)
if cnt == 0 {
break
}
streak++
}
return streak
}
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
// 本周一为起点
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
monday := today.AddDate(0, 0, -offset)
dotSet := s.datesWithActivity(petID, monday, monday.AddDate(0, 0, 7))
week := make([]WeekDay, 7)
for i := 0; i < 7; i++ {
d := monday.AddDate(0, 0, i)
week[i] = WeekDay{
Weekday: weekdayCN[int(d.Weekday())],
Day: d.Day(),
Active: d.Equal(today),
HasDot: dotSet[d.Format("2006-01-02")],
}
}
return week
}
// datesWithActivity 区间内有任务/记录/提醒的日期集合
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
set := map[string]bool{}
var ts []time.Time
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
for _, t := range ts {
set[t.Format("2006-01-02")] = true
}
var rs []time.Time
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Pluck("occurred_at", &rs)
for _, t := range rs {
set[t.Format("2006-01-02")] = true
}
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
for _, r := range reminders {
if r.NextDueDate != nil {
set[r.NextDueDate.Format("2006-01-02")] = true
}
}
return set
}
func (s *Service) todayCompletionPct(petID uint, now time.Time) int {
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 1)
var total, done int64
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Count(&total)
if total == 0 {
return 0
}
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ? AND done = ?", petID, start, end, true).Count(&done)
return int(math.Round(float64(done) / float64(total) * 100))
}
// dailyAdvice 今日建议:每宠每天缓存一条;启用模型则真调,否则规则化
func (s *Service) dailyAdvice(pet *model.Pet) string {
day := time.Now().Format("2006-01-02")
var da model.DailyAdvice
if err := s.db.Where("pet_id = ? AND day = ?", pet.ID, day).First(&da).Error; err == nil {
return da.Text
}
text := fmt.Sprintf("%s处于%s,建议今天重点观察体重、饮食和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。", pet.Name, pet.Stage)
if s.ai != nil && s.ai.Enabled() {
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
"\n请用2-3句话给出今天的养宠重点提醒,亲切简洁,直接给建议不要寒暄。"
if r, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: "今天的养宠建议"}}, ai.Options{Temperature: -1}); err == nil && r != "" {
text = r
}
}
_ = s.db.Create(&model.DailyAdvice{PetID: pet.ID, Day: day, Text: text}).Error
return text
}
// UserSummary 我的页所需计数
type UserSummary struct {
Pets int64 `json:"pets"`
Records int64 `json:"records"`
Reminders int64 `json:"reminders"`
ProStatus string `json:"pro_status"`
}
// GetUserSummary 汇总当前用户名下计数
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
var sum UserSummary
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
sum.ProStatus = model.ProNone
var m model.ProMembership
if err := s.db.Where("user_id = ?", userID).First(&m).Error; err == nil {
sum.ProStatus = m.Status
}
return &sum, nil
}