init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-03 15:33:31 +08:00
commit 609f7d06cf
180 changed files with 15259 additions and 0 deletions
+161
View File
@@ -0,0 +1,161 @@
package service
import (
"golang.org/x/crypto/bcrypt"
"github.com/sundynix/pets-be/internal/model"
)
// AdminLogin 校验管理员账号密码
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
var admin model.Admin
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
return nil, ErrNotFound
}
if admin.Disabled {
return nil, ErrNotFound
}
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// GetAdmin 取管理员
func (s *Service) GetAdmin(id uint) (*model.Admin, error) {
var admin model.Admin
if err := s.db.First(&admin, id).Error; err != nil {
return nil, ErrNotFound
}
return &admin, nil
}
// AdminStats 后台统计
type AdminStats struct {
Users int64 `json:"users"`
Pets int64 `json:"pets"`
Posts int64 `json:"posts"`
Records int64 `json:"records"`
}
// Stats Dashboard 计数
func (s *Service) Stats() (*AdminStats, error) {
var st AdminStats
s.db.Model(&model.User{}).Count(&st.Users)
s.db.Model(&model.Pet{}).Count(&st.Pets)
s.db.Model(&model.Post{}).Count(&st.Posts)
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
return &st, nil
}
// ListUsers 用户分页(keyword 匹配昵称)
func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, int64, error) {
q := s.db.Model(&model.User{})
if keyword != "" {
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
}
var total int64
q.Count(&total)
var users []model.User
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
return users, total, err
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id uint, disabled bool) error {
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
}
// ListPetsAdmin 宠物分页
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
var total int64
s.db.Model(&model.Pet{}).Count(&total)
var pets []model.Pet
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
return pets, total, err
}
// ListPostsAdmin 帖子分页(status 可选过滤)
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var posts []model.Post
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
return posts, total, err
}
// SetPostStatus 审核帖子(published/hidden/deleted
func (s *Service) SetPostStatus(id uint, status string) error {
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListCommentsAdmin 评论分页
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
var total int64
s.db.Model(&model.Comment{}).Count(&total)
var comments []model.Comment
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
return comments, total, err
}
// DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id uint) error {
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
}
// ListArticlesAdmin 文章分页(含未发布)
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
var total int64
s.db.Model(&model.Article{}).Count(&total)
var articles []model.Article
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
return articles, total, err
}
// SaveArticle 新增或更新文章(ID 为 0 则新增)
func (s *Service) SaveArticle(a *model.Article) error {
if a.ID == 0 {
return s.db.Create(a).Error
}
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
"icon": a.Icon,
"title": a.Title,
"description": a.Description,
"content": a.Content,
"category": a.Category,
"related_sheet_type": a.RelatedSheetType,
"published": a.Published,
}).Error
}
// DeleteArticle 删除文章
func (s *Service) DeleteArticle(id uint) error {
res := s.db.Delete(&model.Article{}, id)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListProAdmin 会员分页
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
var total int64
s.db.Model(&model.ProMembership{}).Count(&total)
var list []model.ProMembership
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}
+31
View File
@@ -0,0 +1,31 @@
package service
import (
"io"
"github.com/sundynix/pets-be/internal/model"
)
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
func (s *Service) AIChat(userID uint, petID *uint, session, text string) (string, error) {
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
if s.ai != nil && s.ai.Enabled() {
if r, err := s.llmChat(petID, text); err == nil && r != "" {
reply = r
}
}
msgs := []model.AIMessage{
{UserID: userID, PetID: petID, Session: session, Role: "user", Text: text},
{UserID: userID, PetID: petID, Session: session, Role: "ai", Text: reply},
}
if err := s.db.Create(&msgs).Error; err != nil {
return "", err
}
return reply, nil
}
// Upload 代理到对象存储
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
return s.storage.Upload(objectName, reader, size, contentType)
}
+141
View File
@@ -0,0 +1,141 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新手猫狗主人。要求:
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
4. 语气亲切、简洁,用中文,避免长篇大论。`
// petBrief 组装宠物档案 + 近期记录的上下文文本
func (s *Service) petBrief(petID uint) string {
var pet model.Pet
if err := s.db.First(&pet, petID).Error; err != nil {
return ""
}
var records []model.HealthRecord
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
var b strings.Builder
fmt.Fprintf(&b, "宠物档案:%s%s%s%s,当前体重%s)。\n",
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
if len(records) > 0 {
b.WriteString("近期记录:")
items := make([]string, 0, len(records))
for _, r := range records {
items = append(items, r.Title)
}
b.WriteString(strings.Join(items, ""))
b.WriteString("。")
}
return b.String()
}
// llmChat 真实模型聊天回复
func (s *Service) llmChat(petID *uint, text string) (string, error) {
system := aiSafetyPrompt
if petID != nil {
if brief := s.petBrief(*petID); brief != "" {
system += "\n\n" + brief
}
}
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
}
// SymptomInput 异常观察入参
type SymptomInput struct {
Symptoms []string
Duration string
Spirit string
}
// SymptomResult 风险评估结果(对应前端 risk 弹层)
type SymptomResult struct {
RiskLevel string `json:"risk_level"` // 低/中/高
Causes string `json:"causes"`
Suggestion string `json:"suggestion"`
SeekCare string `json:"seek_care"`
}
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
// 规则化兜底
fallback := &SymptomResult{
RiskLevel: "中",
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
}
if s.ai == nil || !s.ai.Enabled() {
return fallback, nil
}
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return fallback, nil // 模型故障不阻断,回退
}
var res SymptomResult
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
return fallback, nil
}
return &res, nil
}
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
type aiPlanExtracted struct {
Stage string `json:"stage"`
Risk string `json:"risk"`
Priority string `json:"priority"`
Reminder string `json:"reminder"`
Tasks []struct {
DayLabel string `json:"day_label"`
Title string `json:"title"`
Description string `json:"description"`
} `json:"tasks"`
}
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
if s.ai == nil || !s.ai.Enabled() {
return nil, false
}
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]}tasks 3-5 条。`
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return nil, false
}
var ex aiPlanExtracted
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
return nil, false
}
return &ex, true
}
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
func extractJSON(s string) string {
i := strings.IndexByte(s, '{')
j := strings.LastIndexByte(s, '}')
if i >= 0 && j > i {
return s[i : j+1]
}
return s
}
+26
View File
@@ -0,0 +1,26 @@
package service
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// ListArticles 已发布文章
func (s *Service) ListArticles() ([]model.Article, error) {
var articles []model.Article
err := s.db.Where("published = ?", true).Order("id asc").Find(&articles).Error
return articles, err
}
// GetArticle 文章详情
func (s *Service) GetArticle(id uint) (*model.Article, error) {
var a model.Article
if err := s.db.First(&a, id).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
return &a, nil
}
+122
View File
@@ -0,0 +1,122 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// MockLogin 开发态登录:按昵称找回或新建用户
func (s *Service) MockLogin(nickname string) (*model.User, error) {
if nickname == "" {
nickname = "毛孩子用户"
}
var u model.User
err := s.db.Where("nickname = ?", nickname).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
u = model.User{Nickname: nickname}
if e := s.db.Create(&u).Error; e != nil {
return nil, e
}
return &u, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// code2SessionResp 微信 jscode2session 返回
type code2SessionResp struct {
OpenID string `json:"openid"`
SessionKey string `json:"session_key"`
UnionID string `json:"unionid"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
// code2Session 调用微信换取 openid
func (s *Service) code2Session(code string) (*code2SessionResp, error) {
if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" {
return nil, errors.New("微信登录未配置 app_id / app_secret")
}
q := url.Values{}
q.Set("appid", s.cfg.WeChat.AppID)
q.Set("secret", s.cfg.WeChat.AppSecret)
q.Set("js_code", code)
q.Set("grant_type", "authorization_code")
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + q.Encode()
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out code2SessionResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.ErrCode != 0 {
return nil, fmt.Errorf("微信登录失败(%d): %s", out.ErrCode, out.ErrMsg)
}
if out.OpenID == "" {
return nil, errors.New("微信未返回 openid")
}
return &out, nil
}
// WechatLogin 微信小程序登录:code → openid → 找回/新建用户
func (s *Service) WechatLogin(code string) (*model.User, error) {
sess, err := s.code2Session(code)
if err != nil {
return nil, err
}
var u model.User
err = s.db.Where("open_id = ?", sess.OpenID).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
u = model.User{OpenID: sess.OpenID, Nickname: "微信用户"}
if e := s.db.Create(&u).Error; e != nil {
return nil, e
}
return &u, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
// GetUser 取用户
func (s *Service) GetUser(userID uint) (*model.User, error) {
var u model.User
if err := s.db.First(&u, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return &u, nil
}
// UpdateUser 更新用户资料(昵称/头像/手机号)
func (s *Service) UpdateUser(userID uint, 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
}
return s.GetUser(userID)
}
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
func tabTag(tab string) string {
switch tab {
case "新手求助":
return "求助"
case "晒宠":
return "晒宠"
case "经验":
return "经验"
default: // 推荐 / 关注
return ""
}
}
// ListPosts 帖子分页列表
func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64, error) {
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
if tag := tabTag(tab); tag != "" {
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
return nil, 0, err
}
return posts, total, nil
}
// GetPost 帖子详情
func (s *Service) GetPost(postID uint) (*model.Post, error) {
var p model.Post
if err := s.db.First(&p, postID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
return &p, nil
}
// PostInput 发帖入参
type PostInput struct {
PetID *uint
Identity string
Content string
Tags datatypes.JSON
Images datatypes.JSON
}
// CreatePost 发帖
func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
authorName := "匿名宠友"
authorEmoji := "🐾"
switch in.Identity {
case "official":
authorName = "毛孩子计划官方"
case "petName":
if in.PetID != nil {
var pet model.Pet
if err := s.db.First(&pet, *in.PetID).Error; err == nil {
authorName = pet.Name + "的铲屎官"
authorEmoji = pet.Emoji
}
}
}
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,
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
return &p, nil
}
// LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID uint) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
like := model.PostLike{PostID: postID, UserID: userID}
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).FirstOrCreate(&like)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 1 { // 新建才计数
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
}
return nil
})
if err != nil {
return 0, err
}
return s.postLikeCount(postID)
}
// UnlikePost 取消点赞
func (s *Service) UnlikePost(userID, postID uint) (int, error) {
if _, err := s.GetPost(postID); err != nil {
return 0, err
}
err := s.db.Transaction(func(tx *gorm.DB) error {
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 1 {
return tx.Model(&model.Post{}).Where("id = ? AND like_count > 0", postID).
UpdateColumn("like_count", gorm.Expr("like_count - 1")).Error
}
return nil
})
if err != nil {
return 0, err
}
return s.postLikeCount(postID)
}
func (s *Service) postLikeCount(postID uint) (int, error) {
var p model.Post
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
return 0, err
}
return p.LikeCount, nil
}
// ListComments 评论分页
func (s *Service) ListComments(postID uint, 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 {
return nil, 0, err
}
var comments []model.Comment
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
return nil, 0, err
}
return comments, total, nil
}
// CreateComment 评论
func (s *Service) CreateComment(userID, postID uint, content string) (*model.Comment, error) {
if _, err := s.GetPost(postID); err != nil {
return nil, err
}
var user model.User
name := "宠友"
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
name = user.Nickname
}
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&comment).Error; err != nil {
return err
}
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
}); err != nil {
return nil, err
}
return &comment, nil
}
+229
View File
@@ -0,0 +1,229 @@
package service
import (
"fmt"
"math"
"time"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// HomeInsight 首页洞察条一项:Bold 为大号加粗值,Text 为说明
type HomeInsight struct {
Bold string `json:"bold"`
Text string `json:"text"`
}
// WeekDay 首页日期条一格
type WeekDay struct {
Weekday string `json:"weekday"` // 一二三四五六日
Day int `json:"day"`
Active bool `json:"active"` // 是否今天
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
}
// HomeSummary 首页所需的全部动态数据
type HomeSummary struct {
Greeting string `json:"greeting"`
Insights []HomeInsight `json:"insights"`
Week []WeekDay `json:"week"`
Advice string `json:"advice"`
HealthPct int `json:"health_pct"`
HealthStatus string `json:"health_status"`
}
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
// GetHomeSummary 计算首页汇总
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
now := time.Now()
res := &HomeSummary{
Greeting: greeting(now, s.userNickname(userID)),
HealthStatus: pet.HealthStatus,
Insights: s.homeInsights(petID, now),
Week: s.homeWeek(petID, now),
Advice: s.dailyAdvice(pet),
HealthPct: s.todayCompletionPct(petID, now),
}
return res, nil
}
func (s *Service) userNickname(userID uint) string {
var u model.User
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
return u.Nickname
}
return "铲屎官"
}
func greeting(t time.Time, name string) string {
h := t.Hour()
switch {
case h < 11:
return "早上好," + name
case h < 14:
return "中午好," + name
case h < 18:
return "下午好," + name
default:
return "晚上好," + name
}
}
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
insights := make([]HomeInsight, 0, 3)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// 1) 最近一条未到期的提醒
var rem model.Reminder
if err := s.db.Where("pet_id = ? AND next_due_date >= ?", petID, today).
Order("next_due_date asc").First(&rem).Error; err == nil && rem.NextDueDate != nil {
days := int(math.Ceil(rem.NextDueDate.Sub(today).Hours() / 24))
bold := fmt.Sprintf("%d天后", days)
if days <= 0 {
bold = "今天"
}
insights = append(insights, HomeInsight{Bold: bold, Text: rem.Title})
}
// 2) 本周体重变化
weekAgo := today.AddDate(0, 0, -7)
var latest, earliest model.HealthRecord
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at desc").First(&latest).Error == nil {
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at asc").First(&earliest).Error == nil {
d := latest.NumValue - earliest.NumValue
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%+.1fkg", d), Text: "本周体重"})
}
}
// 3) 连续记录天数
if n := s.streakDays(petID, now); n > 0 {
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%d天", n), Text: "连续记录"})
}
return insights
}
// streakDays 从今天往前,连续有健康记录的天数
func (s *Service) streakDays(petID uint, now time.Time) int {
streak := 0
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := 0; i < 60; i++ {
start := day.AddDate(0, 0, -i)
end := start.AddDate(0, 0, 1)
var cnt int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Count(&cnt)
if cnt == 0 {
break
}
streak++
}
return streak
}
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
// 本周一为起点
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
monday := today.AddDate(0, 0, -offset)
dotSet := s.datesWithActivity(petID, monday, monday.AddDate(0, 0, 7))
week := make([]WeekDay, 7)
for i := 0; i < 7; i++ {
d := monday.AddDate(0, 0, i)
week[i] = WeekDay{
Weekday: weekdayCN[int(d.Weekday())],
Day: d.Day(),
Active: d.Equal(today),
HasDot: dotSet[d.Format("2006-01-02")],
}
}
return week
}
// datesWithActivity 区间内有任务/记录/提醒的日期集合
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
set := map[string]bool{}
var ts []time.Time
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
for _, t := range ts {
set[t.Format("2006-01-02")] = true
}
var rs []time.Time
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Pluck("occurred_at", &rs)
for _, t := range rs {
set[t.Format("2006-01-02")] = true
}
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
for _, r := range reminders {
if r.NextDueDate != nil {
set[r.NextDueDate.Format("2006-01-02")] = true
}
}
return set
}
func (s *Service) todayCompletionPct(petID uint, now time.Time) int {
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 1)
var total, done int64
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Count(&total)
if total == 0 {
return 0
}
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ? AND done = ?", petID, start, end, true).Count(&done)
return int(math.Round(float64(done) / float64(total) * 100))
}
// dailyAdvice 今日建议:每宠每天缓存一条;启用模型则真调,否则规则化
func (s *Service) dailyAdvice(pet *model.Pet) string {
day := time.Now().Format("2006-01-02")
var da model.DailyAdvice
if err := s.db.Where("pet_id = ? AND day = ?", pet.ID, day).First(&da).Error; err == nil {
return da.Text
}
text := fmt.Sprintf("%s处于%s,建议今天重点观察体重、饮食和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。", pet.Name, pet.Stage)
if s.ai != nil && s.ai.Enabled() {
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
"\n请用2-3句话给出今天的养宠重点提醒,亲切简洁,直接给建议不要寒暄。"
if r, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: "今天的养宠建议"}}, ai.Options{Temperature: -1}); err == nil && r != "" {
text = r
}
}
_ = s.db.Create(&model.DailyAdvice{PetID: pet.ID, Day: day, Text: text}).Error
return text
}
// UserSummary 我的页所需计数
type UserSummary struct {
Pets int64 `json:"pets"`
Records int64 `json:"records"`
Reminders int64 `json:"reminders"`
ProStatus string `json:"pro_status"`
}
// GetUserSummary 汇总当前用户名下计数
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
var sum UserSummary
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
sum.ProStatus = model.ProNone
var m model.ProMembership
if err := s.db.Where("user_id = ?", userID).First(&m).Error; err == nil {
sum.ProStatus = m.Status
}
return &sum, nil
}
+168
View File
@@ -0,0 +1,168 @@
package service
import (
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// PetInput 建/改宠物入参
type PetInput struct {
Name string
Emoji string
Type string
Gender string
Birthday *time.Time
Weight string
Stage string
Age string
Color string
Breed string
Goals datatypes.JSON
}
func normalizeWeight(w string) string {
w = strings.TrimSpace(w)
if w == "" {
return ""
}
if strings.Contains(w, "kg") {
return w
}
return w + "kg"
}
// ListPets 用户的全部宠物
func (s *Service) ListPets(userID uint) ([]model.Pet, error) {
var pets []model.Pet
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
return pets, err
}
// GetPet 取单只宠物(校验归属)
func (s *Service) GetPet(userID, petID uint) (*model.Pet, error) {
return s.ownedPet(userID, petID)
}
// CreatePet 新增宠物并生成默认数据
func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
pet := model.Pet{
UserID: userID,
Name: in.Name,
Emoji: in.Emoji,
Type: in.Type,
Gender: in.Gender,
Birthday: in.Birthday,
Weight: normalizeWeight(in.Weight),
Stage: in.Stage,
Age: in.Age,
Color: in.Color,
Breed: in.Breed,
HealthStatus: "正常",
Goals: in.Goals,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&pet).Error; err != nil {
return err
}
return s.seedPetDefaults(tx, &pet)
}); err != nil {
return nil, err
}
return &pet, nil
}
// UpdatePet 更新宠物字段
func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.Pet, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
if w, ok := fields["weight"].(string); ok {
fields["weight"] = normalizeWeight(w)
}
if err := s.db.Model(&model.Pet{}).Where("id = ?", petID).Updates(fields).Error; err != nil {
return nil, err
}
return s.ownedPet(userID, petID)
}
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
func (s *Service) DeletePet(userID, petID uint) 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
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 {
return err
}
}
for _, m := range []any{
&model.HealthRecord{}, &model.DailyTask{}, &model.Reminder{},
&model.Plan{}, &model.DailyAdvice{},
} {
if err := tx.Where("pet_id = ?", petID).Delete(m).Error; err != nil {
return err
}
}
return tx.Where("id = ? AND user_id = ?", petID, userID).Delete(&model.Pet{}).Error
})
}
// Onboarding 建首宠 + 标记用户已引导
func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
pet, err := s.CreatePet(userID, in)
if err != nil {
return nil, err
}
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("onboarded", true).Error; err != nil {
return nil, err
}
return pet, nil
}
// 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
}
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 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"},
},
}
return tx.Create(&plan).Error
}
+185
View File
@@ -0,0 +1,185 @@
package service
import (
"math"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// TogglePlanTask 切换计划明细完成状态(校验归属)
func (s *Service) TogglePlanTask(userID, taskID uint) error {
var pt model.PlanTask
if err := s.db.First(&pt, taskID).Error; err != nil {
return ErrNotFound
}
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil {
return ErrNotFound
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error
}
// GetPlan 取宠物的 30 天计划(含明细)
func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
var plan model.Plan
err := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// 真实完成度:已完成明细 / 总明细
if n := len(plan.Tasks); n > 0 {
done := 0
for _, t := range plan.Tasks {
if t.Done {
done++
}
}
plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100))
} else {
plan.CompletionPct = 0
}
return &plan, nil
}
// CalendarResult 日历视图
type CalendarResult struct {
Year int `json:"year"`
Month int `json:"month"`
TaskedDays []int `json:"tasked_days"`
Today int `json:"today"`
}
// Calendar 某月有任务/提醒的日期
func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
loc := time.Now().Location()
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{}{}
}
}
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 {
today = now.Day()
}
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
}
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 默认(规则化)提取与任务
var extracted any = extractAIInfo(input, pet.Stage)
tasks := []model.PlanTask{
{DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"},
{DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"},
{DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"},
}
// 启用模型则用真实提取覆盖
if ex, ok := s.aiPlanExtract(pet, input); ok {
extracted = map[string]string{
"stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder,
}
tasks = tasks[:0]
for _, t := range ex.Tasks {
tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description})
}
}
b, _ := jsonMarshalAny(extracted)
plan := model.Plan{
PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage,
UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks,
}
if err := s.db.Create(&plan).Error; err != nil {
return nil, err
}
return &plan, nil
}
// ApplyAIPlan 确认并应用 AI 计划
func (s *Service) ApplyAIPlan(userID, planID uint) (*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 {
return nil, ErrNotFound
}
return nil, err
}
now := time.Now()
end := now.AddDate(0, 0, 7)
if err := s.db.Model(&plan).Updates(map[string]any{
"status": "active", "start_date": now, "end_date": end,
}).Error; err != nil {
return nil, err
}
if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil {
return nil, err
}
return &plan, nil
}
// extractAIInfo 规则化提取(后续可替换为大模型)
func extractAIInfo(input, stage string) map[string]string {
risk := "暂无明显风险"
priority := "日常观察"
reminder := "常规提醒"
if strings.Contains(input, "换粮") || strings.Contains(input, "软便") {
risk = "换粮软便"
priority = "排便观察"
}
if strings.Contains(input, "疫苗") {
reminder = "第二针疫苗"
}
if stage == "" {
stage = "幼年期"
}
return map[string]string{
"stage": stage + " / 疫苗期",
"risk": risk,
"priority": priority,
"reminder": reminder,
}
}
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// ProFeatures Pro 权益列表
var ProFeatures = []string{
"365 天养宠计划",
"多宠物管理",
"月度成长报告",
"PDF 健康档案",
"年度养宠账单",
}
// ProInfo Pro 状态 + 权益
type ProInfo struct {
Status string `json:"status"`
PlanType string `json:"plan_type"`
Price float64 `json:"price"`
EndDate *time.Time `json:"end_date"`
Features []string `json:"features"`
}
// GetPro 取会员信息
func (s *Service) GetPro(userID uint) (*ProInfo, error) {
var m model.ProMembership
err := s.db.Where("user_id = ?", userID).First(&m).Error
if err == gorm.ErrRecordNotFound {
return &ProInfo{Status: model.ProNone, Features: ProFeatures}, nil
}
if err != nil {
return nil, err
}
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
}
// ActivatePro 开通年费会员
func (s *Service) ActivatePro(userID uint) (*ProInfo, error) {
now := time.Now()
end := now.AddDate(1, 0, 0)
var m model.ProMembership
err := s.db.Where("user_id = ?", userID).First(&m).Error
if err == gorm.ErrRecordNotFound {
m = model.ProMembership{UserID: userID}
} else if err != nil {
return nil, err
}
m.Status = model.ProActive
m.PlanType = "yearly"
m.Price = 29.9
m.StartDate = &now
m.EndDate = &end
if err := s.db.Save(&m).Error; err != nil {
return nil, err
}
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
}
+108
View File
@@ -0,0 +1,108 @@
package service
import (
"fmt"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// RecordInput 健康记录入参
type RecordInput struct {
Type string
Icon string
Title string
Description string
NumValue float64
Category string
ImageURL string
Extra datatypes.JSON
OccurredAt *time.Time
}
// ListRecords 列出宠物的健康记录(可按 type 过滤)
func (s *Service) ListRecords(userID, petID uint, recordType string) ([]model.HealthRecord, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
q := s.db.Where("pet_id = ?", petID)
if recordType != "" {
q = q.Where("type = ?", recordType)
}
var records []model.HealthRecord
err := q.Order("occurred_at desc, id desc").Find(&records).Error
return records, err
}
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.HealthRecord, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
occurred := time.Now()
if in.OccurredAt != nil {
occurred = *in.OccurredAt
}
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,
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&rec).Error; err != nil {
return err
}
if in.Type == model.RecordWeight && in.NumValue > 0 {
weight := fmt.Sprintf("%gkg", in.NumValue)
if err := tx.Model(&model.Pet{}).Where("id = ?", pet.ID).Update("weight", weight).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return &rec, nil
}
// DeleteRecord 删除记录
func (s *Service) DeleteRecord(userID, recordID uint) error {
res := s.db.Where("id = ? AND user_id = ?", recordID, userID).Delete(&model.HealthRecord{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// WeightPoint 体重趋势点
type WeightPoint struct {
Value float64 `json:"value"`
OccurredAt time.Time `json:"occurred_at"`
}
// WeightTrend 最近 N 次体重(升序)
func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
if limit <= 0 {
limit = 7
}
var records []model.HealthRecord
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.RecordWeight).
Order("occurred_at desc").Limit(limit).Find(&records).Error; err != nil {
return nil, 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})
}
return points, nil
}
+64
View File
@@ -0,0 +1,64 @@
package service
import (
"time"
"github.com/sundynix/pets-be/internal/model"
)
// ReminderInput 提醒入参
type ReminderInput struct {
Type string
Title string
NextDueDate *time.Time
Frequency string
}
// ListReminders 宠物的提醒列表
func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
var reminders []model.Reminder
err := s.db.Where("pet_id = ?", petID).Order("id asc").Find(&reminders).Error
return reminders, err
}
// CreateReminder 新增提醒
func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.Reminder, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
r := model.Reminder{
PetID: petID, UserID: userID, Type: in.Type, Title: in.Title,
NextDueDate: in.NextDueDate, Frequency: in.Frequency,
}
if err := s.db.Create(&r).Error; err != nil {
return nil, err
}
return &r, nil
}
// UpdateReminder 更新提醒
func (s *Service) UpdateReminder(userID, reminderID uint, 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
}
if err := s.db.Model(&r).Updates(fields).Error; err != nil {
return nil, err
}
return &r, nil
}
// DeleteReminder 删除提醒
func (s *Service) DeleteReminder(userID, reminderID uint) error {
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
+186
View File
@@ -0,0 +1,186 @@
package service
import (
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// WeeklyReport 周报聚合
type WeeklyReport struct {
Summary string `json:"summary"`
TasksCompleted int64 `json:"tasks_completed"`
WeightGain float64 `json:"weight_gain"`
HighRiskCount int64 `json:"high_risk_count"`
HealthStatus string `json:"health_status"`
NextWeekFocus string `json:"next_week_focus"`
}
// GetWeeklyReport 计算最近 7 天周报
func (s *Service) GetWeeklyReport(userID, petID uint) (*WeeklyReport, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
weekAgo := time.Now().AddDate(0, 0, -7)
var tasksCompleted int64
s.db.Model(&model.DailyTask{}).
Where("pet_id = ? AND done = ? AND updated_at >= ?", petID, true, weekAgo).
Count(&tasksCompleted)
// 体重增长:最近 7 天最新 - 最早
var latest, earliest model.HealthRecord
gain := 0.0
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at desc").First(&latest).Error; err == nil {
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
Order("occurred_at asc").First(&earliest).Error; err == nil {
gain = latest.NumValue - earliest.NumValue
}
}
var highRisk int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).
Count(&highRisk)
status := "稳定成长"
if highRisk > 0 {
status = "需要关注"
}
return &WeeklyReport{
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,无高风险异常记录。", pet.Name, tasksCompleted, gain),
TasksCompleted: tasksCompleted,
WeightGain: gain,
HighRiskCount: highRisk,
HealthStatus: status,
NextWeekFocus: "第 2 针疫苗提醒、继续观察体重趋势、避免频繁更换食物。",
}, nil
}
// BillCategory 账单分类项
type BillCategory struct {
Category string `json:"category"`
Amount float64 `json:"amount"`
Percent int `json:"percent"`
}
// Bill 账单聚合
type Bill struct {
Period string `json:"period"`
Total float64 `json:"total"`
MaxSingle float64 `json:"max_single"`
Categories []BillCategory `json:"categories"`
}
// GetBill 账单(period=month 取本月)
func (s *Service) GetBill(userID, petID uint, period string) (*Bill, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
now := time.Now()
var start time.Time
if period == "year" {
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
} else {
period = "month"
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
}
type row struct {
Category string
Amount float64
}
var rows []row
s.db.Model(&model.HealthRecord{}).
Select("category, sum(num_value) as amount").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Group("category").Scan(&rows)
var total, maxSingle float64
s.db.Model(&model.HealthRecord{}).
Select("coalesce(sum(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&total)
s.db.Model(&model.HealthRecord{}).
Select("coalesce(max(num_value),0)").
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
Scan(&maxSingle)
cats := make([]BillCategory, 0, len(rows))
for _, r := range rows {
pct := 0
if total > 0 {
pct = int(r.Amount / total * 100)
}
cats = append(cats, BillCategory{Category: r.Category, Amount: r.Amount, Percent: pct})
}
return &Bill{Period: period, Total: total, MaxSingle: maxSingle, Categories: cats}, nil
}
// HealthSummary 健康摘要
type HealthSummary struct {
VaccineProgress string `json:"vaccine_progress"`
DewormStatus string `json:"deworm_status"`
WeightTrend string `json:"weight_trend"`
AnomalyCount int64 `json:"anomaly_count"`
}
// GetHealthSummary 健康摘要聚合
func (s *Service) GetHealthSummary(userID, petID uint) (*HealthSummary, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
var vaccineDone int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineDone)
dewormStatus := "暂无计划"
var dewormReminder model.Reminder
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dewormReminder).Error; err == nil && dewormReminder.NextDueDate != nil {
dewormStatus = "下次 " + dewormReminder.NextDueDate.Format("1月2日")
}
var anomalies int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordSymptom).Count(&anomalies)
return &HealthSummary{
VaccineProgress: fmt.Sprintf("%d/3,即将到期", vaccineDone),
DewormStatus: dewormStatus,
WeightTrend: "稳定增长",
AnomalyCount: anomalies,
}, nil
}
// Poster 成长海报数据
type Poster struct {
PetName string `json:"pet_name"`
PetEmoji string `json:"pet_emoji"`
Age string `json:"age"`
Weight string `json:"weight"`
Stage string `json:"stage"`
TasksCompleted int64 `json:"tasks_completed"`
WeightRecords int64 `json:"weight_records"`
VaccineRecords int64 `json:"vaccine_records"`
HighRiskCount int64 `json:"high_risk_count"`
Headline string `json:"headline"`
}
// GetPoster 生成海报聚合数据
func (s *Service) GetPoster(userID, petID uint) (*Poster, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
var tasksDone, weightRecs, vaccineRecs int64
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND done = ?", petID, true).Count(&tasksDone)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs)
return &Poster{
PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
HighRiskCount: 0, Headline: "稳定成长",
}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package service
import (
"errors"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/storage"
)
// ErrNotFound 资源不存在(handler 据此返回 40400
var ErrNotFound = errors.New("not found")
// Service 业务逻辑聚合,方法按领域分散在各文件
type Service struct {
db *gorm.DB
storage *storage.Storage
cfg *config.Config
ai *ai.Engine
}
func New(db *gorm.DB, st *storage.Storage, cfg *config.Config, engine *ai.Engine) *Service {
return &Service{db: db, storage: st, cfg: cfg, ai: engine}
}
// ownedPet 校验宠物归属当前用户并返回
func (s *Service) ownedPet(userID, petID uint) (*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) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
return &pet, nil
}
+63
View File
@@ -0,0 +1,63 @@
package service
import (
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// ListTasks 列出宠物任务(date 为空则取今天)
func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
q := s.db.Where("pet_id = ?", petID)
if date != nil {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
q = q.Where("task_date >= ? AND task_date < ?", start, end)
}
var tasks []model.DailyTask
err := q.Order("id asc").Find(&tasks).Error
return tasks, err
}
// ToggleTask 切换任务完成状态
func (s *Service) ToggleTask(userID, taskID uint) (*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 {
return nil, ErrNotFound
}
return nil, err
}
task.Done = !task.Done
if task.Done {
now := time.Now()
task.CompletedAt = &now
} else {
task.CompletedAt = nil
}
if err := s.db.Save(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
// CompleteAllTasks 完成宠物今日全部任务
func (s *Service) CompleteAllTasks(userID, petID uint) ([]model.DailyTask, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
end := start.AddDate(0, 0, 1)
if err := s.db.Model(&model.DailyTask{}).
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
Updates(map[string]any{"done": true, "completed_at": now}).Error; err != nil {
return nil, err
}
return s.ListTasks(userID, petID, &now)
}
+8
View File
@@ -0,0 +1,8 @@
package service
import "encoding/json"
// jsonMarshalAny 便捷序列化(用于写入 datatypes.JSON 列)
func jsonMarshalAny(v any) ([]byte, error) {
return json.Marshal(v)
}