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:
Blizzard
2026-07-06 08:48:15 +08:00
parent 609f7d06cf
commit 2f5dd6eefc
83 changed files with 2253 additions and 355 deletions
+17 -8
View File
@@ -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
+1 -1
View File
@@ -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 != "" {
+3 -3
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+3 -2
View File
@@ -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
}
+278
View File
@@ -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 日"),
}
}
}
+58 -15
View File
@@ -1,12 +1,49 @@
package service
import (
"encoding/json"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 ImagesURL 数组)。
// 机器人帖 ImageFileIDs 为空,保留其 Imagesemoji)不变。
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
}
+247
View File
@@ -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)
}
}()
}
+88
View File
@@ -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
}
+14 -8
View File
@@ -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)
+63 -35
View File
@@ -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
}
+75 -25
View File
@@ -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 {
+2 -2
View File
@@ -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
+35 -10
View File
@@ -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
}
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+1 -1
View File
@@ -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) {
+3 -3
View File
@@ -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
}