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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package service
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// jsonMarshalAny 便捷序列化(用于写入 datatypes.JSON 列)
|
||||
func jsonMarshalAny(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
Reference in New Issue
Block a user