Files
sundynix-pets/pets-be/internal/service/home.go
T
Blizzard 2f2757267e fix(be): 任务完成统计与留痕改用 PlanTask(DailyTask 已空)
- TogglePlanTask:完成无 sheet_type 的任务时补一条 note 记录,
  首页/计划页两条路径都统一留痕(ToggleTask 委托它);去掉前端重复建记录
- 报告页「完成任务」原来统计空的 DailyTask 恒为 0,改成经 plan 关联
  统计 PlanTask 本周完成数;用户汇总的 tasksDone 同改
- 首页今日完成度 todayCompletionPct 同样从 DailyTask 改成今日 PlanTask

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 14:29:04 +08:00

248 lines
8.3 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"
"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"`
Date string `json:"date"` // YYYY-MM-DD,供首页点击查当日任务
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 string) (*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 string) 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 string, 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 string, 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 string, 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(),
Date: d.Format("2006-01-02"),
Active: d.Equal(today),
HasDot: dotSet[d.Format("2006-01-02")],
}
}
return week
}
// datesWithActivity 区间内有任务/记录/提醒的日期集合
func (s *Service) datesWithActivity(petID string, 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
}
}
// 30 天计划路线图节点也算「有活动」,让首页周历与计划日历一致
for _, d := range s.planNodeDates(petID, start, end) {
set[d.Format("2006-01-02")] = true
}
return set
}
func (s *Service) todayCompletionPct(petID string, now time.Time) int {
// 任务是 PlanTaskDailyTask 已空)。取宠物的 active 30 天计划,算今天那天的完成占比
var plan model.Plan
if s.db.Where("pet_id = ? AND kind = ? AND status = ?", petID, model.PlanThirtyDay, "active").
Order("id desc").First(&plan).Error != nil {
return 0
}
day := dayIndexOf(&plan, now)
if day < 0 {
return 0
}
var total, done int64
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ?", plan.ID, day).Count(&total)
if total == 0 {
return 0
}
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, 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"`
Following int64 `json:"following"` // 我关注了多少人
Followers int64 `json:"followers"` // 多少人关注我
ProStatus string `json:"pro_status"`
}
// GetUserSummary 汇总当前用户名下计数
func (s *Service) GetUserSummary(userID string) (*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)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
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
}