feat: 养护模板/社区运营/体重趋势/计划日历 + 雪花ID重构 + 文件表与图片上传
后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页
小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整
后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,8 +6,11 @@ import (
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// AdminLogin 校验管理员账号密码
|
||||
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
|
||||
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
|
||||
if username != s.cfg.Admin.Username {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
var admin model.Admin
|
||||
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
@@ -22,7 +25,7 @@ func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
|
||||
}
|
||||
|
||||
// GetAdmin 取管理员
|
||||
func (s *Service) GetAdmin(id uint) (*model.Admin, error) {
|
||||
func (s *Service) GetAdmin(id string) (*model.Admin, error) {
|
||||
var admin model.Admin
|
||||
if err := s.db.First(&admin, id).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
@@ -49,11 +52,17 @@ func (s *Service) Stats() (*AdminStats, error) {
|
||||
}
|
||||
|
||||
// ListUsers 用户分页(keyword 匹配昵称)
|
||||
func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, int64, error) {
|
||||
func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]model.User, int64, error) {
|
||||
q := s.db.Model(&model.User{})
|
||||
if keyword != "" {
|
||||
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
switch userType {
|
||||
case "bot":
|
||||
q = q.Where("is_bot = ?", true)
|
||||
case "real":
|
||||
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var users []model.User
|
||||
@@ -62,7 +71,7 @@ func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, in
|
||||
}
|
||||
|
||||
// SetUserDisabled 启用/禁用用户
|
||||
func (s *Service) SetUserDisabled(id uint, disabled bool) error {
|
||||
func (s *Service) SetUserDisabled(id string, disabled bool) error {
|
||||
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
|
||||
}
|
||||
|
||||
@@ -89,7 +98,7 @@ func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post
|
||||
}
|
||||
|
||||
// SetPostStatus 审核帖子(published/hidden/deleted)
|
||||
func (s *Service) SetPostStatus(id uint, status string) error {
|
||||
func (s *Service) SetPostStatus(id string, status string) error {
|
||||
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -110,7 +119,7 @@ func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64,
|
||||
}
|
||||
|
||||
// DeleteCommentAdmin 删除评论
|
||||
func (s *Service) DeleteCommentAdmin(id uint) error {
|
||||
func (s *Service) DeleteCommentAdmin(id string) error {
|
||||
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
||||
}
|
||||
|
||||
@@ -125,7 +134,7 @@ func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64,
|
||||
|
||||
// SaveArticle 新增或更新文章(ID 为 0 则新增)
|
||||
func (s *Service) SaveArticle(a *model.Article) error {
|
||||
if a.ID == 0 {
|
||||
if a.ID == "" {
|
||||
return s.db.Create(a).Error
|
||||
}
|
||||
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
|
||||
@@ -140,7 +149,7 @@ func (s *Service) SaveArticle(a *model.Article) error {
|
||||
}
|
||||
|
||||
// DeleteArticle 删除文章
|
||||
func (s *Service) DeleteArticle(id uint) error {
|
||||
func (s *Service) DeleteArticle(id string) error {
|
||||
res := s.db.Delete(&model.Article{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
||||
func (s *Service) AIChat(userID uint, petID *uint, session, text string) (string, error) {
|
||||
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
|
||||
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
|
||||
if s.ai != nil && s.ai.Enabled() {
|
||||
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
||||
|
||||
@@ -17,7 +17,7 @@ const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新
|
||||
4. 语气亲切、简洁,用中文,避免长篇大论。`
|
||||
|
||||
// petBrief 组装宠物档案 + 近期记录的上下文文本
|
||||
func (s *Service) petBrief(petID uint) string {
|
||||
func (s *Service) petBrief(petID string) string {
|
||||
var pet model.Pet
|
||||
if err := s.db.First(&pet, petID).Error; err != nil {
|
||||
return ""
|
||||
@@ -41,7 +41,7 @@ func (s *Service) petBrief(petID uint) string {
|
||||
}
|
||||
|
||||
// llmChat 真实模型聊天回复
|
||||
func (s *Service) llmChat(petID *uint, text string) (string, error) {
|
||||
func (s *Service) llmChat(petID *string, text string) (string, error) {
|
||||
system := aiSafetyPrompt
|
||||
if petID != nil {
|
||||
if brief := s.petBrief(*petID); brief != "" {
|
||||
@@ -67,7 +67,7 @@ type SymptomResult struct {
|
||||
}
|
||||
|
||||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
||||
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
|
||||
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ func (s *Service) ListArticles() ([]model.Article, error) {
|
||||
}
|
||||
|
||||
// GetArticle 文章详情
|
||||
func (s *Service) GetArticle(id uint) (*model.Article, error) {
|
||||
func (s *Service) GetArticle(id string) (*model.Article, error) {
|
||||
var a model.Article
|
||||
if err := s.db.First(&a, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
|
||||
@@ -102,7 +102,7 @@ func (s *Service) WechatLogin(code string) (*model.User, error) {
|
||||
}
|
||||
|
||||
// GetUser 取用户
|
||||
func (s *Service) GetUser(userID uint) (*model.User, error) {
|
||||
func (s *Service) GetUser(userID string) (*model.User, error) {
|
||||
var u model.User
|
||||
if err := s.db.First(&u, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -110,11 +110,12 @@ func (s *Service) GetUser(userID uint) (*model.User, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
u.AvatarURL = s.fileURL(u.AvatarFileID)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户资料(昵称/头像/手机号)
|
||||
func (s *Service) UpdateUser(userID uint, fields map[string]any) (*model.User, error) {
|
||||
func (s *Service) UpdateUser(userID string, fields map[string]any) (*model.User, error) {
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// CareSpecies / CareStages 支持的物种与阶段(阶段字符串与 onboarding 一致)
|
||||
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
|
||||
var CareStages = []string{"刚到家 0-30 天", "幼年期", "成年期", "老年期"}
|
||||
|
||||
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
|
||||
func SpeciesOf(petType string) string {
|
||||
if strings.Contains(petType, "狗") {
|
||||
return model.SpeciesDog
|
||||
}
|
||||
return model.SpeciesCat
|
||||
}
|
||||
|
||||
func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog }
|
||||
func validStage(s string) bool {
|
||||
for _, x := range CareStages {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// careTemplateItems 取某组合的模板明细:DB 优先,无则回退内置默认
|
||||
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
|
||||
var tpl model.CareTemplate
|
||||
err := s.db.
|
||||
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
|
||||
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
|
||||
if err == nil && len(tpl.Items) > 0 {
|
||||
return tpl.Items
|
||||
}
|
||||
return builtinCareItems(species, stage)
|
||||
}
|
||||
|
||||
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
|
||||
func (s *Service) GetCareTemplate(species, stage string) (map[string]any, error) {
|
||||
if !validSpecies(species) || !validStage(stage) {
|
||||
return nil, errors.New("invalid species or stage")
|
||||
}
|
||||
return map[string]any{
|
||||
"species": species,
|
||||
"stage": stage,
|
||||
"items": s.careTemplateItems(species, stage),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SaveCareTemplate 后台保存:整表替换该组合的明细
|
||||
func (s *Service) SaveCareTemplate(species, stage string, items []model.CareTemplateItem) error {
|
||||
if !validSpecies(species) || !validStage(stage) {
|
||||
return errors.New("invalid species or stage")
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var tpl model.CareTemplate
|
||||
err := tx.Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
tpl = model.CareTemplate{Species: species, Stage: stage}
|
||||
if err := tx.Create(&tpl).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("template_id = ?", tpl.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
clean := make([]model.CareTemplateItem, 0, len(items))
|
||||
for i := range items {
|
||||
it := items[i]
|
||||
if strings.TrimSpace(it.Title) == "" {
|
||||
continue
|
||||
}
|
||||
it.ID = ""
|
||||
it.TemplateID = tpl.ID
|
||||
it.Sort = i
|
||||
clean = append(clean, it)
|
||||
}
|
||||
if len(clean) > 0 {
|
||||
if err := tx.Create(&clean).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。
|
||||
// AI 未开启或失败时回退内置默认,保证总有可用草稿。
|
||||
func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
|
||||
if !validSpecies(species) || !validStage(stage) {
|
||||
return nil, errors.New("invalid species or stage")
|
||||
}
|
||||
if !s.ai.Enabled() {
|
||||
return builtinCareItems(species, stage), nil
|
||||
}
|
||||
spName := "猫"
|
||||
if species == model.SpeciesDog {
|
||||
spName = "狗"
|
||||
}
|
||||
system := aiSafetyPrompt + `
|
||||
|
||||
你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。
|
||||
只输出 JSON,结构如下(不要多余文字):
|
||||
{
|
||||
"tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}],
|
||||
"plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}],
|
||||
"reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}]
|
||||
}
|
||||
要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。`
|
||||
user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage)
|
||||
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return builtinCareItems(species, stage), nil
|
||||
}
|
||||
items, ok := parseCareItems(out)
|
||||
if !ok || len(items) == 0 {
|
||||
return builtinCareItems(species, stage), nil
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// parseCareItems 解析 AI 返回的分组 JSON → 扁平明细
|
||||
func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
|
||||
var raw struct {
|
||||
Tasks []struct {
|
||||
Title, Description, Priority, SheetType string
|
||||
} `json:"tasks"`
|
||||
Plan []struct {
|
||||
Day int
|
||||
DayLabel, Title, Description, SheetType string
|
||||
} `json:"plan"`
|
||||
Reminders []struct {
|
||||
ReminderType, Title, Frequency string
|
||||
OffsetDays int
|
||||
} `json:"reminders"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var items []model.CareTemplateItem
|
||||
for _, t := range raw.Tasks {
|
||||
if t.Title == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType))
|
||||
}
|
||||
for _, p := range raw.Plan {
|
||||
if p.Title == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType))
|
||||
}
|
||||
for _, r := range raw.Reminders {
|
||||
if r.Title == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency))
|
||||
}
|
||||
return items, len(items) > 0
|
||||
}
|
||||
|
||||
// SeedCareTemplates 启动幂等 seed:库中无任何模板时写入 8 套内置默认
|
||||
func (s *Service) SeedCareTemplates() error {
|
||||
var count int64
|
||||
if err := s.db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
for _, sp := range CareSpecies {
|
||||
for _, stage := range CareStages {
|
||||
tpl := model.CareTemplate{Species: sp, Stage: stage, Items: builtinCareItems(sp, stage)}
|
||||
if err := s.db.Create(&tpl).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 明细构造小工具 ----
|
||||
|
||||
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindTask, Title: title, Description: desc, Priority: priority, SheetType: sheet}
|
||||
}
|
||||
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
|
||||
}
|
||||
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
|
||||
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
|
||||
}
|
||||
|
||||
// builtinCareItems 内置默认模板(8 套),既作开箱数据也作兜底
|
||||
func builtinCareItems(species, stage string) []model.CareTemplateItem {
|
||||
isDog := species == model.SpeciesDog
|
||||
dewormAdult := 90
|
||||
if isDog {
|
||||
dewormAdult = 60 // 狗驱虫间隔通常比猫短
|
||||
}
|
||||
|
||||
switch stage {
|
||||
case "刚到家 0-30 天":
|
||||
return []model.CareTemplateItem{
|
||||
careTask("记录一次体重", "到家后每周记录,建立基线", "重要", "weight"),
|
||||
careTask("观察饮水与排便", "换环境应激常见软便,连续观察几天", "", "poop"),
|
||||
careTask("布置安静休息角", "固定饮食、厕所与休息区,减少应激", "", ""),
|
||||
carePlan(0, "今天", "安顿与环境适应", "固定饮食、厕所、休息区位置", "taskDetail"),
|
||||
carePlan(3, "第3天", "观察排便与食欲", "记录应激缓解情况", "poop"),
|
||||
carePlan(7, "第7天", "第一次体重记录", "作为后续趋势基线", "weight"),
|
||||
carePlan(14, "第14天", "预约首次体检/疫苗", "确认医院与时间", "vaccine"),
|
||||
careReminder("vaccine", "首次疫苗接种", 14, ""),
|
||||
careReminder("deworm", "首次体内外驱虫", 7, ""),
|
||||
careReminder("weight", "体重记录", 0, "每周一次"),
|
||||
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||
}
|
||||
case "幼年期":
|
||||
vaccineTitle := "第 2 针疫苗"
|
||||
if isDog {
|
||||
vaccineTitle = "下一针联苗"
|
||||
}
|
||||
return []model.CareTemplateItem{
|
||||
careTask("记录一次体重", "幼年期每周至少记录 2 次", "重要", "weight"),
|
||||
careTask("观察饮水与排便", "换粮、应激都可能影响排便状态", "", "poop"),
|
||||
careTask("检查疫苗预约", "按免疫程序别漏针", "重要", "vaccine"),
|
||||
carePlan(0, "今天", "观察排便状态", "记录颜色、形态、次数", "taskDetail"),
|
||||
carePlan(1, "明天", "检查疫苗预约", "确认下一针医院与时间", "vaccine"),
|
||||
carePlan(7, "第7天", "体重趋势检查", "每周称重,观察稳定增长", "weight"),
|
||||
carePlan(14, "第14天", "复盘饮食与便便", "换粮记录与异常一起看", "taskDetail"),
|
||||
careReminder("vaccine", vaccineTitle, 18, ""),
|
||||
careReminder("deworm", "体内外驱虫", 12, ""),
|
||||
careReminder("weight", "体重记录", 0, "每周二、周五"),
|
||||
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||
}
|
||||
case "老年期":
|
||||
return []model.CareTemplateItem{
|
||||
careTask("记录一次体重", "老年期体重波动要重视", "重要", "weight"),
|
||||
careTask("观察精神与食欲", "明显变差要尽快就医", "重要", "symptom"),
|
||||
careTask("关注饮水量", "多饮多尿可能是慢病信号", "", ""),
|
||||
carePlan(0, "今天", "老年健康自查", "精神、食欲、饮水一起看", "taskDetail"),
|
||||
carePlan(7, "第7天", "体重与饮水记录", "留意慢病早期信号", "weight"),
|
||||
carePlan(14, "第14天", "用药与体检回顾", "慢病管理与用药记录", "medicine"),
|
||||
carePlan(30, "第30天", "生成月度报告", "整理给医生看的记录", "report"),
|
||||
careReminder("vaccine", "年度疫苗加强", 30, ""),
|
||||
careReminder("deworm", "体内外驱虫", dewormAdult, ""),
|
||||
careReminder("weight", "体重记录", 0, "每两周一次"),
|
||||
careReminder("monthlyReport", "月度报告 / 建议体检", 0, "每月 1 日"),
|
||||
}
|
||||
default: // 成年期
|
||||
return []model.CareTemplateItem{
|
||||
careTask("记录一次体重", "成年期每月称重一次即可", "", "weight"),
|
||||
careTask("观察饮水与排便", "发现软便可连续观察几天", "", "poop"),
|
||||
careTask("梳毛与口腔检查", "关注牙结石与皮肤状态", "", ""),
|
||||
carePlan(0, "今天", "全面健康自查", "体重、精神、食欲、被毛过一遍", "taskDetail"),
|
||||
carePlan(7, "第7天", "体重趋势检查", "对比上月,观察是否稳定", "weight"),
|
||||
carePlan(14, "第14天", "驱虫记录复盘", "确认体内外驱虫是否到期", "medicine"),
|
||||
carePlan(30, "第30天", "生成月度报告", "回顾本月记录,导出成长卡片", "report"),
|
||||
careReminder("vaccine", "年度疫苗加强", 30, ""),
|
||||
careReminder("deworm", "体内外驱虫", dewormAdult, ""),
|
||||
careReminder("weight", "体重记录", 0, "每月一次"),
|
||||
careReminder("monthlyReport", "月度报告", 0, "每月 1 日"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,49 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 Images(URL 数组)。
|
||||
// 机器人帖 ImageFileIDs 为空,保留其 Images(emoji)不变。
|
||||
func (s *Service) attachPostImages(posts []model.Post) {
|
||||
perPost := make([][]string, len(posts))
|
||||
all := []string{}
|
||||
for i := range posts {
|
||||
if len(posts[i].ImageFileIDs) == 0 {
|
||||
continue
|
||||
}
|
||||
var ids []string
|
||||
if json.Unmarshal(posts[i].ImageFileIDs, &ids) == nil && len(ids) > 0 {
|
||||
perPost[i] = ids
|
||||
all = append(all, ids...)
|
||||
}
|
||||
}
|
||||
if len(all) == 0 {
|
||||
return
|
||||
}
|
||||
urlMap := s.fileURLs(all)
|
||||
for i := range posts {
|
||||
if len(perPost[i]) == 0 {
|
||||
continue
|
||||
}
|
||||
urls := make([]string, 0, len(perPost[i]))
|
||||
for _, id := range perPost[i] {
|
||||
if u := urlMap[id]; u != "" {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
}
|
||||
if b, err := json.Marshal(urls); err == nil {
|
||||
posts[i].Images = datatypes.JSON(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
|
||||
func tabTag(tab string) string {
|
||||
switch tab {
|
||||
@@ -35,11 +72,12 @@ func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64,
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
s.attachPostImages(posts)
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// GetPost 帖子详情
|
||||
func (s *Service) GetPost(postID uint) (*model.Post, error) {
|
||||
func (s *Service) GetPost(postID string) (*model.Post, error) {
|
||||
var p model.Post
|
||||
if err := s.db.First(&p, postID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -47,20 +85,23 @@ func (s *Service) GetPost(postID uint) (*model.Post, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
one := []model.Post{p}
|
||||
s.attachPostImages(one)
|
||||
return &one[0], nil
|
||||
}
|
||||
|
||||
// PostInput 发帖入参
|
||||
type PostInput struct {
|
||||
PetID *uint
|
||||
Identity string
|
||||
Content string
|
||||
Tags datatypes.JSON
|
||||
Images datatypes.JSON
|
||||
PetID *string
|
||||
Identity string
|
||||
Content string
|
||||
Tags datatypes.JSON
|
||||
Images datatypes.JSON
|
||||
ImageFileIDs datatypes.JSON
|
||||
}
|
||||
|
||||
// CreatePost 发帖
|
||||
func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
|
||||
func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
|
||||
authorName := "匿名宠友"
|
||||
authorEmoji := "🐾"
|
||||
switch in.Identity {
|
||||
@@ -78,16 +119,18 @@ func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
|
||||
p := model.Post{
|
||||
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
||||
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
||||
Status: model.PostPublished,
|
||||
ImageFileIDs: in.ImageFileIDs, Status: model.PostPublished,
|
||||
}
|
||||
if err := s.db.Create(&p).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
one := []model.Post{p}
|
||||
s.attachPostImages(one)
|
||||
return &one[0], nil
|
||||
}
|
||||
|
||||
// LikePost 点赞(幂等:已赞则不重复计数)
|
||||
func (s *Service) LikePost(userID, postID uint) (int, error) {
|
||||
func (s *Service) LikePost(userID, postID string) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -110,7 +153,7 @@ func (s *Service) LikePost(userID, postID uint) (int, error) {
|
||||
}
|
||||
|
||||
// UnlikePost 取消点赞
|
||||
func (s *Service) UnlikePost(userID, postID uint) (int, error) {
|
||||
func (s *Service) UnlikePost(userID, postID string) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
@@ -131,7 +174,7 @@ func (s *Service) UnlikePost(userID, postID uint) (int, error) {
|
||||
return s.postLikeCount(postID)
|
||||
}
|
||||
|
||||
func (s *Service) postLikeCount(postID uint) (int, error) {
|
||||
func (s *Service) postLikeCount(postID string) (int, error) {
|
||||
var p model.Post
|
||||
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
|
||||
return 0, err
|
||||
@@ -140,7 +183,7 @@ func (s *Service) postLikeCount(postID uint) (int, error) {
|
||||
}
|
||||
|
||||
// ListComments 评论分页
|
||||
func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
|
||||
func (s *Service) ListComments(postID string, offset, limit int) ([]model.Comment, int64, error) {
|
||||
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
@@ -154,7 +197,7 @@ func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment,
|
||||
}
|
||||
|
||||
// CreateComment 评论
|
||||
func (s *Service) CreateComment(userID, postID uint, content string) (*model.Comment, error) {
|
||||
func (s *Service) CreateComment(userID, postID string, content string) (*model.Comment, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
const botTickMinutes = 15
|
||||
|
||||
// 话题种子,增加生成多样性
|
||||
var botTopics = []string{
|
||||
"新手第一次给猫换粮的经历和注意点",
|
||||
"幼犬疫苗期间的日常照护",
|
||||
"猫咪软便观察和调理的经验",
|
||||
"遛狗时防止捡食的小技巧",
|
||||
"换季掉毛严重怎么护理",
|
||||
"刚到家的猫如何度过应激期",
|
||||
"给狗狗刷牙、清口腔的经验",
|
||||
"记录体重时发现的小变化",
|
||||
"驱虫到期提醒和实操",
|
||||
"多猫家庭的相处磨合",
|
||||
"老年猫饮水和肾脏健康关注点",
|
||||
"幼猫社会化和玩耍引导",
|
||||
}
|
||||
|
||||
// 默认虚拟宠友账号池(昵称 + emoji 头像)
|
||||
var botSeeds = []struct{ Name, Emoji string }{
|
||||
{"团子的日常", "🐱"}, {"布丁妈妈", "🐶"}, {"三花小报告", "🐱"},
|
||||
{"柯基屁股蛋", "🐶"}, {"橘座驾到", "🐱"}, {"奶牛猫饲养员", "🐮"},
|
||||
{"金毛暖暖", "🦮"}, {"布偶少爷", "😺"}, {"英短小灰灰", "🐱"},
|
||||
{"泰迪不乖", "🐩"}, {"狸花猫巡逻队", "🐈"}, {"雪纳瑞老张", "🐶"},
|
||||
{"银渐层日记", "😸"}, {"哈士奇拆家现场", "🐺"}, {"暹罗小王子", "🐱"},
|
||||
{"边牧上学记", "🐕"}, {"美短起司", "🧀"}, {"柴犬麻薯", "🐕"},
|
||||
{"无毛猫斯芬克斯", "🐱"}, {"比熊棉花糖", "🐶"}, {"缅因大橘", "🦁"},
|
||||
{"腊肠一米五", "🌭"}, {"加菲脸圆圆", "😻"}, {"萨摩耶微笑", "🐻❄️"},
|
||||
{"蓝猫布加迪", "🐱"}, {"法斗打呼噜", "🐶"}, {"折耳软软", "🐱"},
|
||||
{"阿拉斯加大脸", "🐺"},
|
||||
}
|
||||
|
||||
// 兜底文案(AI 未开启/失败时用)
|
||||
var fallbackPosts = []string{
|
||||
"换粮第3天,软便基本好了,7天过渡法真的有用,分享给同样在换粮的姐妹~",
|
||||
"幼猫社会化真的重要,每天陪玩20分钟,现在一点都不怕生人了🐱",
|
||||
"提醒大家驱虫别忘记,我差点漏了,手机日历设个提醒最保险。",
|
||||
"金毛换季掉毛太夸张了,每天梳一次+补点卵磷脂,情况好多了。",
|
||||
}
|
||||
|
||||
// getBotConfig 取单例配置(不存在返回内置默认,不写库)
|
||||
func (s *Service) getBotConfig() model.CommunityBotConfig {
|
||||
var c model.CommunityBotConfig
|
||||
if err := s.db.First(&c, "1").Error; err != nil {
|
||||
return model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GetCommunityBotConfig 后台读取配置
|
||||
func (s *Service) GetCommunityBotConfig() model.CommunityBotConfig {
|
||||
return s.getBotConfig()
|
||||
}
|
||||
|
||||
// SaveCommunityBotConfig 后台保存配置(固定 id=1 upsert)
|
||||
func (s *Service) SaveCommunityBotConfig(in model.CommunityBotConfig) (model.CommunityBotConfig, error) {
|
||||
if in.DailyCount < 0 {
|
||||
in.DailyCount = 0
|
||||
}
|
||||
if in.StartHour < 0 || in.StartHour > 23 {
|
||||
in.StartHour = 9
|
||||
}
|
||||
if in.EndHour < 1 || in.EndHour > 24 {
|
||||
in.EndHour = 21
|
||||
}
|
||||
if in.EndHour <= in.StartHour {
|
||||
in.EndHour = in.StartHour + 1
|
||||
}
|
||||
in.ID = "1"
|
||||
if err := s.db.Save(&in).Error; err != nil {
|
||||
return in, err
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// SeedCommunityBot 启动幂等 seed:虚拟账号池(按昵称补齐新增账号)+ 配置单例
|
||||
func (s *Service) SeedCommunityBot() error {
|
||||
// 迁移前的旧用户 is_bot 为 NULL,回填为 false 便于筛选
|
||||
if err := s.db.Model(&model.User{}).Where("is_bot IS NULL").Update("is_bot", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, b := range botSeeds {
|
||||
var n int64
|
||||
if err := s.db.Model(&model.User{}).Where("nickname = ? AND is_bot = ?", b.Name, true).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
u := model.User{Nickname: b.Name, Avatar: b.Emoji, IsBot: true, Onboarded: true}
|
||||
if err := s.db.Create(&u).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var cfgCount int64
|
||||
if err := s.db.Model(&model.CommunityBotConfig{}).Count(&cfgCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if cfgCount == 0 {
|
||||
c := model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
|
||||
c.ID = "1"
|
||||
if err := s.db.Create(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// countAIPostsToday 今天已生成的 AI 帖数
|
||||
func (s *Service) countAIPostsToday() int64 {
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
var n int64
|
||||
s.db.Model(&model.Post{}).Where("is_ai = ? AND created_at >= ?", true, start).Count(&n)
|
||||
return n
|
||||
}
|
||||
|
||||
// GenerateCommunityPosts 生成 n 条 AI 帖并入库,返回实际生成数
|
||||
func (s *Service) GenerateCommunityPosts(n int) (int, error) {
|
||||
if n <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var bots []model.User
|
||||
if err := s.db.Where("is_bot = ?", true).Find(&bots).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(bots) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
made := 0
|
||||
for i := 0; i < n; i++ {
|
||||
bot := bots[rand.Intn(len(bots))]
|
||||
content, tag := s.genOnePostContent()
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
tagsJSON, _ := json.Marshal([]string{tag})
|
||||
imagesJSON, _ := json.Marshal(pickPhotos())
|
||||
p := model.Post{
|
||||
UserID: bot.ID, AuthorName: bot.Nickname, AuthorEmoji: bot.Avatar,
|
||||
Identity: "petName", Content: content, Tags: datatypes.JSON(tagsJSON),
|
||||
Images: datatypes.JSON(imagesJSON),
|
||||
LikeCount: rand.Intn(40), Status: model.PostPublished, IsAI: true,
|
||||
}
|
||||
if err := s.db.Create(&p).Error; err != nil {
|
||||
return made, err
|
||||
}
|
||||
made++
|
||||
}
|
||||
return made, nil
|
||||
}
|
||||
|
||||
// 配图 emoji 池(App 用 emoji 当照片渲染),当作帖子的「图」
|
||||
var photoPool = []string{"🐱", "🐶", "🐈", "🐕", "🍚", "🦴", "🧶", "🪀", "🏠", "🌿", "🛁", "💊", "🐾", "🍗", "🥩", "🧸", "☀️", "🛏️"}
|
||||
|
||||
// pickPhotos ~65% 概率随机贴 1-3 张配图,其余纯文字(更自然)
|
||||
func pickPhotos() []string {
|
||||
if rand.Float64() > 0.65 {
|
||||
return []string{}
|
||||
}
|
||||
n := 1 + rand.Intn(3)
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, photoPool[rand.Intn(len(photoPool))])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genOnePostContent 生成一条文案 + 标签;AI 不可用/失败回退内置文案
|
||||
func (s *Service) genOnePostContent() (string, string) {
|
||||
tags := []string{"晒宠", "求助", "经验", "避坑"}
|
||||
tag := tags[rand.Intn(len(tags))]
|
||||
if !s.ai.Enabled() {
|
||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
||||
}
|
||||
topic := botTopics[rand.Intn(len(botTopics))]
|
||||
system := aiSafetyPrompt + `
|
||||
|
||||
你在为宠物社区「宠友圈」生成一条真实、口语化的养宠动态,像普通铲屎官发的朋友圈。
|
||||
要求:40-120 字;真实有细节,可带 1-2 个 emoji;涉及异常要顺带提醒何时就医;不要标题、不要话题标签、不要营销口吻。
|
||||
只输出 JSON:{"content":"","tag":"晒宠|求助|经验|避坑"}`
|
||||
user := fmt.Sprintf("主题:%s。请生成一条。", topic)
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
||||
}
|
||||
var r struct {
|
||||
Content string `json:"content"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &r); err != nil || r.Content == "" {
|
||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
||||
}
|
||||
if r.Tag != "" {
|
||||
tag = r.Tag
|
||||
}
|
||||
return r.Content, tag
|
||||
}
|
||||
|
||||
// RunCommunityBotTick 定时器每次调用:白天窗口内按剩余名额概率投放,天然打散在白天
|
||||
func (s *Service) RunCommunityBotTick() {
|
||||
cfg := s.getBotConfig()
|
||||
if !cfg.Enabled || cfg.DailyCount <= 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
h := now.Hour()
|
||||
if h < cfg.StartHour || h >= cfg.EndHour {
|
||||
return
|
||||
}
|
||||
today := s.countAIPostsToday()
|
||||
if today >= int64(cfg.DailyCount) {
|
||||
return
|
||||
}
|
||||
remaining := cfg.DailyCount - int(today)
|
||||
minsLeft := (cfg.EndHour-h)*60 - now.Minute()
|
||||
ticksLeft := minsLeft / botTickMinutes
|
||||
if ticksLeft < 1 {
|
||||
ticksLeft = 1
|
||||
}
|
||||
if rand.Float64() <= float64(remaining)/float64(ticksLeft) {
|
||||
_, _ = s.GenerateCommunityPosts(1)
|
||||
}
|
||||
}
|
||||
|
||||
// StartCommunityBot 启动后台定时投放协程
|
||||
func (s *Service) StartCommunityBot() {
|
||||
go func() {
|
||||
for {
|
||||
s.RunCommunityBotTick()
|
||||
time.Sleep(botTickMinutes * time.Minute)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// 允许的图片扩展名(服务端兜底校验,前端已限制仅图片)
|
||||
var imageExts = map[string]bool{
|
||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
|
||||
".webp": true, ".bmp": true, ".heic": true, ".heif": true,
|
||||
}
|
||||
|
||||
// UploadFile 上传文件并按 MD5 去重:内容相同直接返回已存记录,不重复占用 MinIO 空间。
|
||||
func (s *Service) UploadFile(reader io.Reader, filename, contentType string) (*model.File, error) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := md5.Sum(data)
|
||||
md5hex := hex.EncodeToString(sum[:])
|
||||
|
||||
// 已有相同内容 → 直接复用
|
||||
var existing model.File
|
||||
if err := s.db.Where("md5 = ?", md5hex).First(&existing).Error; err == nil {
|
||||
return &existing, nil
|
||||
}
|
||||
|
||||
ext := strings.ToLower(path.Ext(filename))
|
||||
if !imageExts[ext] && !strings.HasPrefix(contentType, "image/") {
|
||||
return nil, errors.New("只允许上传图片文件")
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
objectName := "files/" + md5hex + ext
|
||||
url, err := s.storage.Upload(objectName, bytes.NewReader(data), int64(len(data)), contentType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f := model.File{
|
||||
MD5: md5hex, ObjectName: objectName, URL: url,
|
||||
Size: int64(len(data)), ContentType: contentType, Ext: ext,
|
||||
}
|
||||
if err := s.db.Create(&f).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// fileURL 按 file_id 取 URL(空 id 或不存在返回空串)
|
||||
func (s *Service) fileURL(id string) string {
|
||||
if id == "" {
|
||||
return ""
|
||||
}
|
||||
var f model.File
|
||||
if err := s.db.Select("url").First(&f, "id = ?", id).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return f.URL
|
||||
}
|
||||
|
||||
// fileURLs 批量按 file_id 取 URL
|
||||
func (s *Service) fileURLs(ids []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
clean := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id != "" {
|
||||
clean = append(clean, id)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return out
|
||||
}
|
||||
var files []model.File
|
||||
s.db.Where("id IN ?", clean).Find(&files)
|
||||
for _, f := range files {
|
||||
out[f.ID] = f.URL
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type HomeInsight struct {
|
||||
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"` // 当天有任务/记录/提醒
|
||||
}
|
||||
@@ -36,7 +37,7 @@ type HomeSummary struct {
|
||||
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
|
||||
|
||||
// GetHomeSummary 计算首页汇总
|
||||
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
|
||||
func (s *Service) GetHomeSummary(userID, petID string) (*HomeSummary, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -54,7 +55,7 @@ func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) userNickname(userID uint) string {
|
||||
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
|
||||
@@ -76,7 +77,7 @@ func greeting(t time.Time, name string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
|
||||
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())
|
||||
|
||||
@@ -112,7 +113,7 @@ func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
|
||||
}
|
||||
|
||||
// streakDays 从今天往前,连续有健康记录的天数
|
||||
func (s *Service) streakDays(petID uint, now time.Time) int {
|
||||
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++ {
|
||||
@@ -129,7 +130,7 @@ func (s *Service) streakDays(petID uint, now time.Time) int {
|
||||
return streak
|
||||
}
|
||||
|
||||
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
|
||||
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
|
||||
@@ -143,6 +144,7 @@ func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
|
||||
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")],
|
||||
}
|
||||
@@ -151,7 +153,7 @@ func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
|
||||
}
|
||||
|
||||
// datesWithActivity 区间内有任务/记录/提醒的日期集合
|
||||
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
|
||||
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)
|
||||
@@ -170,10 +172,14 @@ func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string
|
||||
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 uint, now time.Time) int {
|
||||
func (s *Service) todayCompletionPct(petID string, 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
|
||||
@@ -214,7 +220,7 @@ type UserSummary struct {
|
||||
}
|
||||
|
||||
// GetUserSummary 汇总当前用户名下计数
|
||||
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
|
||||
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)
|
||||
|
||||
@@ -20,9 +20,10 @@ type PetInput struct {
|
||||
Weight string
|
||||
Stage string
|
||||
Age string
|
||||
Color string
|
||||
Breed string
|
||||
Goals datatypes.JSON
|
||||
Color string
|
||||
Breed string
|
||||
AvatarFileID string
|
||||
Goals datatypes.JSON
|
||||
}
|
||||
|
||||
func normalizeWeight(w string) string {
|
||||
@@ -37,19 +38,31 @@ func normalizeWeight(w string) string {
|
||||
}
|
||||
|
||||
// ListPets 用户的全部宠物
|
||||
func (s *Service) ListPets(userID uint) ([]model.Pet, error) {
|
||||
func (s *Service) ListPets(userID string) ([]model.Pet, error) {
|
||||
var pets []model.Pet
|
||||
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
|
||||
ids := make([]string, 0, len(pets))
|
||||
for i := range pets {
|
||||
ids = append(ids, pets[i].AvatarFileID)
|
||||
}
|
||||
urls := s.fileURLs(ids)
|
||||
for i := range pets {
|
||||
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
|
||||
}
|
||||
return pets, err
|
||||
}
|
||||
|
||||
// GetPet 取单只宠物(校验归属)
|
||||
func (s *Service) GetPet(userID, petID uint) (*model.Pet, error) {
|
||||
return s.ownedPet(userID, petID)
|
||||
func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
|
||||
p, err := s.ownedPet(userID, petID)
|
||||
if err == nil && p != nil {
|
||||
p.AvatarURL = s.fileURL(p.AvatarFileID)
|
||||
}
|
||||
return p, err
|
||||
}
|
||||
|
||||
// CreatePet 新增宠物并生成默认数据
|
||||
func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
|
||||
func (s *Service) CreatePet(userID string, in PetInput) (*model.Pet, error) {
|
||||
pet := model.Pet{
|
||||
UserID: userID,
|
||||
Name: in.Name,
|
||||
@@ -62,6 +75,7 @@ func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
|
||||
Age: in.Age,
|
||||
Color: in.Color,
|
||||
Breed: in.Breed,
|
||||
AvatarFileID: in.AvatarFileID,
|
||||
HealthStatus: "正常",
|
||||
Goals: in.Goals,
|
||||
}
|
||||
@@ -77,7 +91,7 @@ func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
|
||||
}
|
||||
|
||||
// UpdatePet 更新宠物字段
|
||||
func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.Pet, error) {
|
||||
func (s *Service) UpdatePet(userID, petID string, fields map[string]any) (*model.Pet, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,13 +105,13 @@ func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.P
|
||||
}
|
||||
|
||||
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
|
||||
func (s *Service) DeletePet(userID, petID uint) error {
|
||||
func (s *Service) DeletePet(userID, petID string) error {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先删计划明细(按 plan_id),再删计划
|
||||
var planIDs []uint
|
||||
var planIDs []string
|
||||
tx.Model(&model.Plan{}).Where("pet_id = ?", petID).Pluck("id", &planIDs)
|
||||
if len(planIDs) > 0 {
|
||||
if err := tx.Where("plan_id IN ?", planIDs).Delete(&model.PlanTask{}).Error; err != nil {
|
||||
@@ -117,7 +131,7 @@ func (s *Service) DeletePet(userID, petID uint) error {
|
||||
}
|
||||
|
||||
// Onboarding 建首宠 + 标记用户已引导
|
||||
func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
|
||||
func (s *Service) Onboarding(userID string, in PetInput) (*model.Pet, error) {
|
||||
pet, err := s.CreatePet(userID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -128,41 +142,55 @@ func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
|
||||
return pet, nil
|
||||
}
|
||||
|
||||
// seedPetDefaults 为新宠生成默认今日任务、提醒、30 天计划
|
||||
// seedPetDefaults 为新宠按「物种 × 阶段」养护模板生成今日任务、提醒、30 天计划
|
||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
today := time.Now()
|
||||
tasks := []model.DailyTask{
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "记录一次体重", Description: "幼年期建议每周至少记录 2 次", Priority: "重要"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "观察饮水和排便", Description: "换粮、应激都可能影响排便状态", SheetType: "poop"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "检查疫苗预约", Description: "第 2 针疫苗还有 5 天", SheetType: "vaccine"},
|
||||
}
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
return err
|
||||
items := s.careTemplateItems(SpeciesOf(pet.Type), pet.Stage)
|
||||
|
||||
var tasks []model.DailyTask
|
||||
var reminders []model.Reminder
|
||||
var planTasks []model.PlanTask
|
||||
for _, it := range items {
|
||||
switch it.Kind {
|
||||
case model.CareKindTask:
|
||||
tasks = append(tasks, model.DailyTask{
|
||||
PetID: pet.ID, UserID: pet.UserID, TaskDate: today,
|
||||
Title: it.Title, Description: it.Description, Priority: it.Priority, SheetType: it.SheetType,
|
||||
})
|
||||
case model.CareKindPlan:
|
||||
planTasks = append(planTasks, model.PlanTask{
|
||||
Day: it.Day, DayLabel: it.DayLabel, Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
||||
})
|
||||
case model.CareKindReminder:
|
||||
r := model.Reminder{
|
||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
||||
Title: it.Title, Frequency: it.Frequency,
|
||||
}
|
||||
if it.OffsetDays > 0 {
|
||||
due := today.AddDate(0, 0, it.OffsetDays)
|
||||
r.NextDueDate = &due
|
||||
}
|
||||
reminders = append(reminders, r)
|
||||
}
|
||||
}
|
||||
|
||||
vaccineDue := today.AddDate(0, 0, 18)
|
||||
dewormDue := today.AddDate(0, 0, 12)
|
||||
reminders := []model.Reminder{
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderVaccine, Title: "第 2 针疫苗", NextDueDate: &vaccineDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderDeworm, Title: "体内外驱虫", NextDueDate: &dewormDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderWeight, Title: "体重记录", Frequency: "每周二、周五"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderMonthlyReport, Title: "月度报告", Frequency: "每月 1 日"},
|
||||
if len(tasks) > 0 {
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Create(&reminders).Error; err != nil {
|
||||
return err
|
||||
if len(reminders) > 0 {
|
||||
if err := tx.Create(&reminders).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
start := today
|
||||
end := today.AddDate(0, 0, 30)
|
||||
plan := model.Plan{
|
||||
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 40, Status: "active",
|
||||
Tasks: []model.PlanTask{
|
||||
{Day: 0, DayLabel: "今天", Title: "观察排便状态", Description: "记录颜色、形态、次数,发现软便可连续观察。", SheetType: "taskDetail"},
|
||||
{Day: 1, DayLabel: "明天", Title: "检查疫苗预约", Description: "距离下一针还有 5 天,提前确认医院和时间。", SheetType: "vaccine"},
|
||||
{Day: 7, DayLabel: "第 7 天", Title: "体重趋势检查", Description: "幼年期每周称重,观察是否稳定增长。", SheetType: "weight"},
|
||||
{Day: 14, DayLabel: "第 14 天", Title: "复盘饮食与便便", Description: "如果近期换粮,建议把换粮过程和异常记录合并查看。", SheetType: "taskDetail"},
|
||||
},
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
|
||||
Tasks: planTasks,
|
||||
}
|
||||
return tx.Create(&plan).Error
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// TogglePlanTask 切换计划明细完成状态(校验归属)
|
||||
func (s *Service) TogglePlanTask(userID, taskID uint) error {
|
||||
func (s *Service) TogglePlanTask(userID, taskID string) error {
|
||||
var pt model.PlanTask
|
||||
if err := s.db.First(&pt, taskID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
@@ -25,7 +25,7 @@ func (s *Service) TogglePlanTask(userID, taskID uint) error {
|
||||
}
|
||||
|
||||
// GetPlan 取宠物的 30 天计划(含明细)
|
||||
func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
|
||||
func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -51,9 +51,34 @@ func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
|
||||
} else {
|
||||
plan.CompletionPct = 0
|
||||
}
|
||||
// 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐
|
||||
if plan.StartDate != nil {
|
||||
for i := range plan.Tasks {
|
||||
plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day)
|
||||
func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time {
|
||||
var plan model.Plan
|
||||
if err := s.db.Preload("Tasks").
|
||||
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
|
||||
Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil {
|
||||
return nil
|
||||
}
|
||||
var out []time.Time
|
||||
for _, t := range plan.Tasks {
|
||||
d := plan.StartDate.AddDate(0, 0, t.Day)
|
||||
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||||
if !dd.Before(start) && dd.Before(end) {
|
||||
out = append(out, dd)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CalendarResult 日历视图
|
||||
type CalendarResult struct {
|
||||
Year int `json:"year"`
|
||||
@@ -63,7 +88,7 @@ type CalendarResult struct {
|
||||
}
|
||||
|
||||
// Calendar 某月有任务/提醒的日期
|
||||
func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult, error) {
|
||||
func (s *Service) Calendar(userID, petID string, year, month int) (*CalendarResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -71,29 +96,15 @@ func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult
|
||||
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
daySet := map[int]struct{}{}
|
||||
|
||||
var taskDates []time.Time
|
||||
s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Pluck("task_date", &taskDates)
|
||||
for _, t := range taskDates {
|
||||
daySet[t.Day()] = struct{}{}
|
||||
}
|
||||
|
||||
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 {
|
||||
daySet[r.NextDueDate.Day()] = struct{}{}
|
||||
// 与首页周历同源:任务/记录/提醒/计划路线图节点
|
||||
set := s.datesWithActivity(petID, start, end)
|
||||
days := make([]int, 0, len(set))
|
||||
for k := range set {
|
||||
if t, err := time.Parse("2006-01-02", k); err == nil {
|
||||
days = append(days, t.Day())
|
||||
}
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daySet))
|
||||
for d := range daySet {
|
||||
days = append(days, d)
|
||||
}
|
||||
|
||||
today := 0
|
||||
now := time.Now()
|
||||
if now.Year() == year && int(now.Month()) == month {
|
||||
@@ -102,8 +113,47 @@ func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult
|
||||
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
|
||||
}
|
||||
|
||||
// DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒
|
||||
func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Now().Location())
|
||||
if err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
next := day.AddDate(0, 0, 1)
|
||||
|
||||
nodes := []model.PlanTask{}
|
||||
var plan model.Plan
|
||||
if e := s.db.Preload("Tasks").
|
||||
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
|
||||
Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil {
|
||||
for _, t := range plan.Tasks {
|
||||
d := plan.StartDate.AddDate(0, 0, t.Day)
|
||||
if d.Year() == day.Year() && d.YearDay() == day.YearDay() {
|
||||
t.Date = d.Format("2006-01-02")
|
||||
nodes = append(nodes, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tasks []model.DailyTask
|
||||
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks)
|
||||
|
||||
var reminders []model.Reminder
|
||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders)
|
||||
|
||||
return map[string]any{
|
||||
"date": dateStr,
|
||||
"plan_nodes": nodes,
|
||||
"tasks": tasks,
|
||||
"reminders": reminders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
|
||||
func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, error) {
|
||||
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,7 +190,7 @@ func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, e
|
||||
}
|
||||
|
||||
// ApplyAIPlan 确认并应用 AI 计划
|
||||
func (s *Service) ApplyAIPlan(userID, planID uint) (*model.Plan, error) {
|
||||
func (s *Service) ApplyAIPlan(userID, planID string) (*model.Plan, error) {
|
||||
var plan model.Plan
|
||||
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
|
||||
@@ -27,7 +27,7 @@ type ProInfo struct {
|
||||
}
|
||||
|
||||
// GetPro 取会员信息
|
||||
func (s *Service) GetPro(userID uint) (*ProInfo, error) {
|
||||
func (s *Service) GetPro(userID string) (*ProInfo, error) {
|
||||
var m model.ProMembership
|
||||
err := s.db.Where("user_id = ?", userID).First(&m).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -40,7 +40,7 @@ func (s *Service) GetPro(userID uint) (*ProInfo, error) {
|
||||
}
|
||||
|
||||
// ActivatePro 开通年费会员
|
||||
func (s *Service) ActivatePro(userID uint) (*ProInfo, error) {
|
||||
func (s *Service) ActivatePro(userID string) (*ProInfo, error) {
|
||||
now := time.Now()
|
||||
end := now.AddDate(1, 0, 0)
|
||||
var m model.ProMembership
|
||||
|
||||
@@ -19,26 +19,46 @@ type RecordInput struct {
|
||||
NumValue float64
|
||||
Category string
|
||||
ImageURL string
|
||||
ImageFileID string
|
||||
Extra datatypes.JSON
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
// ListRecords 列出宠物的健康记录(可按 type 过滤)
|
||||
func (s *Service) ListRecords(userID, petID uint, recordType string) ([]model.HealthRecord, error) {
|
||||
func (s *Service) ListRecords(userID, petID string, recordType string, offset, limit int) ([]model.HealthRecord, int64, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
return nil, 0, err
|
||||
}
|
||||
q := s.db.Where("pet_id = ?", petID)
|
||||
q := s.db.Model(&model.HealthRecord{}).Where("pet_id = ?", petID)
|
||||
if recordType != "" {
|
||||
q = q.Where("type = ?", recordType)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
err := q.Order("occurred_at desc, id desc").Find(&records).Error
|
||||
return records, err
|
||||
err := q.Order("occurred_at desc, id desc").Offset(offset).Limit(limit).Find(&records).Error
|
||||
// 用 file_id 回填图片 URL
|
||||
ids := make([]string, 0)
|
||||
for i := range records {
|
||||
if records[i].ImageFileID != "" && records[i].ImageURL == "" {
|
||||
ids = append(ids, records[i].ImageFileID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
urls := s.fileURLs(ids)
|
||||
for i := range records {
|
||||
if records[i].ImageURL == "" {
|
||||
records[i].ImageURL = urls[records[i].ImageFileID]
|
||||
}
|
||||
}
|
||||
}
|
||||
return records, total, err
|
||||
}
|
||||
|
||||
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
|
||||
func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.HealthRecord, error) {
|
||||
func (s *Service) CreateRecord(userID, petID string, in RecordInput) (*model.HealthRecord, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -50,7 +70,11 @@ func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.Healt
|
||||
rec := model.HealthRecord{
|
||||
PetID: petID, UserID: userID, Type: in.Type, Icon: in.Icon,
|
||||
Title: in.Title, Description: in.Description, NumValue: in.NumValue,
|
||||
Category: in.Category, ImageURL: in.ImageURL, Extra: in.Extra, OccurredAt: occurred,
|
||||
Category: in.Category, ImageURL: in.ImageURL, ImageFileID: in.ImageFileID, Extra: in.Extra, OccurredAt: occurred,
|
||||
}
|
||||
// 传了 file_id 未传 url 时,用 file 表的 url 回填 ImageURL 便于直接展示
|
||||
if in.ImageFileID != "" && rec.ImageURL == "" {
|
||||
rec.ImageURL = s.fileURL(in.ImageFileID)
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
@@ -70,7 +94,7 @@ func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.Healt
|
||||
}
|
||||
|
||||
// DeleteRecord 删除记录
|
||||
func (s *Service) DeleteRecord(userID, recordID uint) error {
|
||||
func (s *Service) DeleteRecord(userID, recordID string) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", recordID, userID).Delete(&model.HealthRecord{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
@@ -85,10 +109,11 @@ func (s *Service) DeleteRecord(userID, recordID uint) error {
|
||||
type WeightPoint struct {
|
||||
Value float64 `json:"value"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
Note string `json:"note"` // 记录时填的备注
|
||||
}
|
||||
|
||||
// WeightTrend 最近 N 次体重(升序)
|
||||
func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, error) {
|
||||
func (s *Service) WeightTrend(userID, petID string, limit int) ([]WeightPoint, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,7 +127,7 @@ func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, err
|
||||
}
|
||||
points := make([]WeightPoint, 0, len(records))
|
||||
for i := len(records) - 1; i >= 0; i-- {
|
||||
points = append(points, WeightPoint{Value: records[i].NumValue, OccurredAt: records[i].OccurredAt})
|
||||
points = append(points, WeightPoint{Value: records[i].NumValue, OccurredAt: records[i].OccurredAt, Note: records[i].Description})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type ReminderInput struct {
|
||||
}
|
||||
|
||||
// ListReminders 宠物的提醒列表
|
||||
func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
|
||||
func (s *Service) ListReminders(userID, petID string) ([]model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
|
||||
}
|
||||
|
||||
// CreateReminder 新增提醒
|
||||
func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.Reminder, error) {
|
||||
func (s *Service) CreateReminder(userID, petID string, in ReminderInput) (*model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.R
|
||||
}
|
||||
|
||||
// UpdateReminder 更新提醒
|
||||
func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any) (*model.Reminder, error) {
|
||||
func (s *Service) UpdateReminder(userID, reminderID string, fields map[string]any) (*model.Reminder, error) {
|
||||
var r model.Reminder
|
||||
if err := s.db.Where("id = ? AND user_id = ?", reminderID, userID).First(&r).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
@@ -52,7 +52,7 @@ func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any)
|
||||
}
|
||||
|
||||
// DeleteReminder 删除提醒
|
||||
func (s *Service) DeleteReminder(userID, reminderID uint) error {
|
||||
func (s *Service) DeleteReminder(userID, reminderID string) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
|
||||
@@ -18,7 +18,7 @@ type WeeklyReport struct {
|
||||
}
|
||||
|
||||
// GetWeeklyReport 计算最近 7 天周报
|
||||
func (s *Service) GetWeeklyReport(userID, petID uint) (*WeeklyReport, error) {
|
||||
func (s *Service) GetWeeklyReport(userID, petID string) (*WeeklyReport, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,7 +76,7 @@ type Bill struct {
|
||||
}
|
||||
|
||||
// GetBill 账单(period=month 取本月)
|
||||
func (s *Service) GetBill(userID, petID uint, period string) (*Bill, error) {
|
||||
func (s *Service) GetBill(userID, petID string, period string) (*Bill, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -129,7 +129,7 @@ type HealthSummary struct {
|
||||
}
|
||||
|
||||
// GetHealthSummary 健康摘要聚合
|
||||
func (s *Service) GetHealthSummary(userID, petID uint) (*HealthSummary, error) {
|
||||
func (s *Service) GetHealthSummary(userID, petID string) (*HealthSummary, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,7 +168,7 @@ type Poster struct {
|
||||
}
|
||||
|
||||
// GetPoster 生成海报聚合数据
|
||||
func (s *Service) GetPoster(userID, petID uint) (*Poster, error) {
|
||||
func (s *Service) GetPoster(userID, petID string) (*Poster, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -27,7 +27,7 @@ func New(db *gorm.DB, st *storage.Storage, cfg *config.Config, engine *ai.Engine
|
||||
}
|
||||
|
||||
// ownedPet 校验宠物归属当前用户并返回
|
||||
func (s *Service) ownedPet(userID, petID uint) (*model.Pet, error) {
|
||||
func (s *Service) ownedPet(userID, petID string) (*model.Pet, error) {
|
||||
var pet model.Pet
|
||||
err := s.db.Where("id = ? AND user_id = ?", petID, userID).First(&pet).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// ListTasks 列出宠物任务(date 为空则取今天)
|
||||
func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyTask, error) {
|
||||
func (s *Service) ListTasks(userID, petID string, date *time.Time) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyT
|
||||
}
|
||||
|
||||
// ToggleTask 切换任务完成状态
|
||||
func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
|
||||
func (s *Service) ToggleTask(userID, taskID string) (*model.DailyTask, error) {
|
||||
var task model.DailyTask
|
||||
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&task).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
@@ -47,7 +47,7 @@ func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
|
||||
}
|
||||
|
||||
// CompleteAllTasks 完成宠物今日全部任务
|
||||
func (s *Service) CompleteAllTasks(userID, petID uint) ([]model.DailyTask, error) {
|
||||
func (s *Service) CompleteAllTasks(userID, petID string) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user