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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-WALewLI5.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-ZqmdrDyd.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
package admin
import (
"embed"
"io/fs"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
//go:embed all:dist
var distFS embed.FS
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback
func Register(r *gin.Engine) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
panic(err)
}
fileServer := http.FileServer(http.FS(sub))
handler := func(c *gin.Context) {
// 去掉前缀 /admin,交给内嵌文件服务器
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
reqPath = strings.TrimPrefix(reqPath, "/")
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
if reqPath != "" {
if f, err := sub.Open(reqPath); err == nil {
_ = f.Close()
c.Request.URL.Path = "/" + reqPath
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
}
// SPA fallback
data, err := fs.ReadFile(sub, "index.html")
if err != nil {
c.String(http.StatusInternalServerError, "admin dist missing")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") })
r.GET("/admin/*filepath", handler)
}
+73
View File
@@ -0,0 +1,73 @@
// Package ai 提供 provider 中立的大模型调用能力。
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
package ai
import (
"github.com/sundynix/pets-be/internal/config"
)
// Message 一条对话消息
type Message struct {
Role string // system / user / assistant
Content string
}
// Options 单次调用参数
type Options struct {
JSON bool // 要求返回 JSON(结构化输出)
MaxTokens int // 0 用配置默认
Temperature float64 // <0 用配置默认
}
// Provider 大模型供应商接口
type Provider interface {
Name() string
// Complete 传入 system 提示与多轮消息,返回助手回复文本
Complete(system string, messages []Message, opts Options) (string, error)
}
// Engine 对外统一入口,持有当前 provider 与开关
type Engine struct {
provider Provider
cfg config.AIConfig
}
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
func New(cfg config.AIConfig) *Engine {
e := &Engine{cfg: cfg}
if !cfg.Enabled || cfg.APIKey == "" {
return e
}
switch cfg.Provider {
case "", "openai":
e.provider = newOpenAICompatProvider(cfg)
default:
// 未知 provider 视为未启用,回退规则化
}
return e
}
// Enabled 是否可用真实模型
func (e *Engine) Enabled() bool {
return e.provider != nil
}
// ProviderName 当前供应商名(诊断用)
func (e *Engine) ProviderName() string {
if e.provider == nil {
return "disabled"
}
return e.provider.Name()
}
// Complete 代理到当前 provider(调用前请先判断 Enabled
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
if opts.MaxTokens == 0 {
opts.MaxTokens = e.cfg.MaxTokens
}
if opts.Temperature < 0 {
opts.Temperature = e.cfg.Temperature
}
return e.provider.Complete(system, messages, opts)
}
+122
View File
@@ -0,0 +1,122 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/sundynix/pets-be/internal/config"
)
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
type openaiCompatProvider struct {
baseURL string
apiKey string
model string
client *http.Client
}
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
timeout := time.Duration(cfg.TimeoutSec) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return &openaiCompatProvider{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
client: &http.Client{Timeout: timeout},
}
}
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ResponseFormat *respFormat `json:"response_format,omitempty"`
}
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type respFormat struct {
Type string `json:"type"`
}
type chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
msgs := make([]chatMsg, 0, len(messages)+1)
if system != "" {
msgs = append(msgs, chatMsg{Role: "system", Content: system})
}
for _, m := range messages {
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
}
body := chatReq{
Model: p.model,
Messages: msgs,
Temperature: opts.Temperature,
MaxTokens: opts.MaxTokens,
}
if opts.JSON {
body.ResponseFormat = &respFormat{Type: "json_object"}
}
raw, _ := json.Marshal(body)
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var out chatResp
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
}
if out.Error != nil {
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
}
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+107
View File
@@ -0,0 +1,107 @@
package config
import (
"fmt"
"strings"
"github.com/spf13/viper"
)
// Config 全局配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
MySQL MySQLConfig `mapstructure:"mysql"`
MinIO MinIOConfig `mapstructure:"minio"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Auth AuthConfig `mapstructure:"auth"`
Admin AdminConfig `mapstructure:"admin"`
AI AIConfig `mapstructure:"ai"`
}
// AIConfig 大模型配置(provider 中立,openai 兼容 base_url
type AIConfig struct {
Enabled bool `mapstructure:"enabled"`
Provider string `mapstructure:"provider"` // openai(兼容) / mock
BaseURL string `mapstructure:"base_url"` // 如 https://api.deepseek.com
APIKey string `mapstructure:"api_key"` // 用 PETS_AI_API_KEY 覆盖
Model string `mapstructure:"model"` // 如 deepseek-chat
Temperature float64 `mapstructure:"temperature"`
MaxTokens int `mapstructure:"max_tokens"`
TimeoutSec int `mapstructure:"timeout_sec"`
}
type ServerConfig struct {
Port int `mapstructure:"port"`
Mode string `mapstructure:"mode"`
}
type MySQLConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
Charset string `mapstructure:"charset"`
}
// DSN 返回 GORM MySQL 连接串
func (m MySQLConfig) DSN() string {
return fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
m.User, m.Password, m.Host, m.Port, m.Database, m.Charset,
)
}
type MinIOConfig struct {
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
PublicBaseURL string `mapstructure:"public_base_url"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
}
type AuthConfig struct {
DevLogin bool `mapstructure:"dev_login"`
}
type AdminConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
}
// Load 载入配置:configs/config.yaml + 环境变量覆盖(PETS_ 前缀,点转下划线)
// 例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password
func Load() (*Config, error) {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.AddConfigPath("../../configs")
v.AddConfigPath(".")
v.SetEnvPrefix("PETS")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
return &cfg, nil
}
+48
View File
@@ -0,0 +1,48 @@
package database
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
)
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
func New(cfg *config.Config) (*gorm.DB, error) {
logLevel := logger.Info
if cfg.Server.Mode == "release" {
logLevel = logger.Warn
}
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "sundynix_",
SingularTable: false,
},
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return nil, fmt.Errorf("gorm open: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
}
// Migrate 自动建表
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(model.AllModels()...)
}
+53
View File
@@ -0,0 +1,53 @@
package database
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
)
// Seed 初始化种子数据:管理员账号 + 新手文章
func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
return seedArticles(db)
}
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
var count int64
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.Create(&model.Admin{
Username: cfg.Admin.Username,
PasswordHash: string(hash),
Role: "admin",
}).Error
}
func seedArticles(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Article{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
articles := []model.Article{
{Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?", Description: "适合搜索流量和社群传播,引导生成提醒。", Category: "vaccine", RelatedSheetType: "vaccine", Published: true},
{Icon: "💩", Title: "猫咪软便要不要去医院?", Description: "接异常记录和 AI 观察建议。", Category: "symptom", RelatedSheetType: "symptom", Published: true},
{Icon: "🍽️", Title: "7 天换粮计划怎么做?", Description: "接计划模板,适合一次性付费。", Category: "food", RelatedSheetType: "applyPlan", Published: true},
{Icon: "💰", Title: "一个月养猫大概花多少钱?", Description: "接养宠账本和年度账单。", Category: "cost", RelatedSheetType: "cost", Published: true},
}
return db.Create(&articles).Error
}
+223
View File
@@ -0,0 +1,223 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
type adminLoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
// AdminLogin POST /api/admin/login
func (h *Handler) AdminLogin(c *gin.Context) {
var req adminLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
admin, err := h.svc.AdminLogin(req.Username, req.Password)
if err != nil {
response.Fail(c, 40100, "账号或密码错误")
return
}
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "admin": admin})
}
// AdminMe GET /api/admin/me
func (h *Handler) AdminMe(c *gin.Context) {
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, admin)
}
// AdminStats GET /api/admin/stats
func (h *Handler) AdminStats(c *gin.Context) {
stats, err := h.svc.Stats()
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, stats)
}
type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
Status string `form:"status"`
}
// AdminListUsers GET /api/admin/users
func (h *Handler) AdminListUsers(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
users, total, err := h.svc.ListUsers(req.Keyword, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(users, total, req.PageQuery))
}
type disableReq struct {
Disabled bool `json:"disabled"`
}
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
var req disableReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetUserDisabled(uintParam(c, "id"), req.Disabled); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPets GET /api/admin/pets
func (h *Handler) AdminListPets(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(pets, total, req.PageQuery))
}
// AdminListPosts GET /api/admin/posts
func (h *Handler) AdminListPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
type postStatusReq struct {
Status string `json:"status"`
}
// AdminSetPostStatus PUT /api/admin/posts/:id/status
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetPostStatus(uintParam(c, "id"), req.Status); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListComments GET /api/admin/comments
func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(uintParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListArticles GET /api/admin/articles
func (h *Handler) AdminListArticles(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(articles, total, req.PageQuery))
}
type articleReq struct {
ID uint `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Category string `json:"category"`
RelatedSheetType string `json:"related_sheet_type"`
Published bool `json:"published"`
}
// AdminSaveArticle POST /api/admin/articlesid>0 为更新)
func (h *Handler) AdminSaveArticle(c *gin.Context) {
var req articleReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
article := &model.Article{
Icon: req.Icon, Title: req.Title, Description: req.Description, Content: req.Content,
Category: req.Category, RelatedSheetType: req.RelatedSheetType, Published: req.Published,
}
article.ID = req.ID
if err := h.svc.SaveArticle(article); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, article)
}
// AdminDeleteArticle DELETE /api/admin/articles/:id
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
if err := h.svc.DeleteArticle(uintParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPro GET /api/admin/memberships
func (h *Handler) AdminListPro(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
+120
View File
@@ -0,0 +1,120 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
type loginReq struct {
Nickname string `json:"nickname"`
}
// Login 开发态 Mock 登录
func (h *Handler) Login(c *gin.Context) {
if !h.cfg.Auth.DevLogin {
response.Fail(c, 40300, "开发登录未开启,请使用微信登录")
return
}
var req loginReq
_ = c.ShouldBindJSON(&req)
user, err := h.svc.MockLogin(req.Nickname)
if err != nil {
response.FailErr(c, err)
return
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
}
type wechatLoginReq struct {
Code string `json:"code"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// WechatLogin 微信小程序登录:前端 wx.login 拿 code 传入
func (h *Handler) WechatLogin(c *gin.Context) {
var req wechatLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Code == "" {
response.FailParams(c, "缺少 code")
return
}
user, err := h.svc.WechatLogin(req.Code)
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
// 可选:首次登录写入用户授权的昵称/头像
if fields := map[string]any{}; true {
if req.Nickname != "" && user.Nickname == "微信用户" {
fields["nickname"] = req.Nickname
}
if req.Avatar != "" && user.Avatar == "" {
fields["avatar"] = req.Avatar
}
if len(fields) > 0 {
if u, e := h.svc.UpdateUser(user.ID, fields); e == nil {
user = u
}
}
}
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"token": token, "user": user})
}
// Profile 当前用户资料
func (h *Handler) Profile(c *gin.Context) {
user, err := h.svc.GetUser(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, user)
}
type updateProfileReq struct {
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
Phone *string `json:"phone"`
}
// UpdateProfile 更新资料
func (h *Handler) UpdateProfile(c *gin.Context) {
var req updateProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Nickname != nil {
fields["nickname"] = *req.Nickname
}
if req.Avatar != nil {
fields["avatar"] = *req.Avatar
}
if req.Phone != nil {
fields["phone"] = *req.Phone
}
user, err := h.svc.UpdateUser(middleware.UserID(c), fields)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, user)
}
+130
View File
@@ -0,0 +1,130 @@
package handler
import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type postListReq struct {
response.PageQuery
Tab string `form:"tab"`
}
// ListPosts GET /api/posts?tab=&page=&page_size=
func (h *Handler) ListPosts(c *gin.Context) {
var req postListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPosts(req.Tab, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// GetPost GET /api/posts/:id
func (h *Handler) GetPost(c *gin.Context) {
post, err := h.svc.GetPost(uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, post)
}
type postReq struct {
PetID *uint `json:"pet_id"`
Identity string `json:"identity"`
Content string `json:"content"`
Tags []string `json:"tags"`
Images []string `json:"images"`
}
// CreatePost POST /api/posts
func (h *Handler) CreatePost(c *gin.Context) {
var req postReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.PostInput{PetID: req.PetID, Identity: req.Identity, Content: req.Content}
if b, err := jsonMarshal(req.Tags); err == nil {
in.Tags = datatypes.JSON(b)
}
if b, err := jsonMarshal(req.Images); err == nil {
in.Images = datatypes.JSON(b)
}
post, err := h.svc.CreatePost(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, post)
}
// LikePost POST /api/posts/:id/like
func (h *Handler) LikePost(c *gin.Context) {
count, err := h.svc.LikePost(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"like_count": count})
}
// UnlikePost DELETE /api/posts/:id/like
func (h *Handler) UnlikePost(c *gin.Context) {
count, err := h.svc.UnlikePost(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"like_count": count})
}
type commentListReq struct {
response.PageQuery
}
// ListComments GET /api/posts/:id/comments
func (h *Handler) ListComments(c *gin.Context) {
var req commentListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListComments(uintParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
type commentReq struct {
Content string `json:"content"`
}
// CreateComment POST /api/posts/:id/comments
func (h *Handler) CreateComment(c *gin.Context) {
var req commentReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Content == "" {
response.FailParams(c, "评论内容不能为空")
return
}
comment, err := h.svc.CreateComment(middleware.UserID(c), uintParam(c, "id"), req.Content)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, comment)
}
+18
View File
@@ -0,0 +1,18 @@
package handler
import (
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/service"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
)
// Handler 所有 HTTP 处理器聚合,方法按领域分散在各文件
type Handler struct {
svc *service.Service
jwt *appjwt.Manager
cfg *config.Config
}
func New(svc *service.Service, jm *appjwt.Manager, cfg *config.Config) *Handler {
return &Handler{svc: svc, jwt: jm, cfg: cfg}
}
+33
View File
@@ -0,0 +1,33 @@
package handler
import (
"encoding/json"
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/errcode"
"github.com/sundynix/pets-be/pkg/response"
)
// uintParam 解析路径参数为 uint
func uintParam(c *gin.Context, key string) uint {
v, _ := strconv.ParseUint(c.Param(key), 10, 64)
return uint(v)
}
// jsonMarshal 便捷序列化
func jsonMarshal(v any) ([]byte, error) {
return json.Marshal(v)
}
// respondErr 统一错误响应:区分 not found 与内部错误
func respondErr(c *gin.Context, err error) {
if errors.Is(err, service.ErrNotFound) {
response.Fail(c, errcode.ErrNotFound, "")
return
}
response.FailErr(c, err)
}
+117
View File
@@ -0,0 +1,117 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
// ListArticles GET /api/articles
func (h *Handler) ListArticles(c *gin.Context) {
articles, err := h.svc.ListArticles()
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, articles)
}
// GetArticle GET /api/articles/:id
func (h *Handler) GetArticle(c *gin.Context) {
article, err := h.svc.GetArticle(uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, article)
}
// GetPro GET /api/pro
func (h *Handler) GetPro(c *gin.Context) {
info, err := h.svc.GetPro(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, info)
}
// ActivatePro POST /api/pro/activate
func (h *Handler) ActivatePro(c *gin.Context) {
info, err := h.svc.ActivatePro(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, info)
}
type aiChatReq struct {
PetID *uint `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
}
// AIChat POST /api/ai/chat
func (h *Handler) AIChat(c *gin.Context) {
var req aiChatReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Text == "" {
response.FailParams(c, "问题不能为空")
return
}
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"reply": reply})
}
// HomeSummary GET /api/pets/:id/home-summary
func (h *Handler) HomeSummary(c *gin.Context) {
res, err := h.svc.GetHomeSummary(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
// UserSummary GET /api/user/summary
func (h *Handler) UserSummary(c *gin.Context) {
res, err := h.svc.GetUserSummary(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, res)
}
type assessSymptomReq struct {
Symptoms []string `json:"symptoms"`
Duration string `json:"duration"`
Spirit string `json:"spirit"`
}
// AssessSymptom POST /api/pets/:id/ai/assess-symptom
func (h *Handler) AssessSymptom(c *gin.Context) {
var req assessSymptomReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
res, err := h.svc.AssessSymptom(middleware.UserID(c), uintParam(c, "id"), service.SymptomInput{
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
})
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
+143
View File
@@ -0,0 +1,143 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type petReq struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
Goals []string `json:"goals"`
}
func (r petReq) toInput() service.PetInput {
in := service.PetInput{
Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender,
Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed,
}
if r.Birthday != "" {
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
in.Birthday = &t
}
}
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
}
}
return in
}
// ListPets GET /api/pets
func (h *Handler) ListPets(c *gin.Context) {
pets, err := h.svc.ListPets(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pets)
}
// CreatePet POST /api/pets
func (h *Handler) CreatePet(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
// GetPet GET /api/pets/:id
func (h *Handler) GetPet(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, pet)
}
// UpdatePet PUT /api/pets/:id
func (h *Handler) UpdatePet(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Name != "" {
fields["name"] = req.Name
}
if req.Emoji != "" {
fields["emoji"] = req.Emoji
}
if req.Weight != "" {
fields["weight"] = req.Weight
}
if req.Stage != "" {
fields["stage"] = req.Stage
}
if req.Gender != "" {
fields["gender"] = req.Gender
}
if req.Age != "" {
fields["age"] = req.Age
}
if req.Color != "" {
fields["color"] = req.Color
}
if req.Breed != "" {
fields["breed"] = req.Breed
}
pet, err := h.svc.UpdatePet(middleware.UserID(c), uintParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, pet)
}
// DeletePet DELETE /api/pets/:id
func (h *Handler) DeletePet(c *gin.Context) {
if err := h.svc.DeletePet(middleware.UserID(c), uintParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// Onboarding POST /api/onboarding
func (h *Handler) Onboarding(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
+111
View File
@@ -0,0 +1,111 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/pkg/response"
)
// ListTasks GET /api/pets/:id/tasks?date=YYYY-MM-DD
func (h *Handler) ListTasks(c *gin.Context) {
var date *time.Time
if q := c.Query("date"); q != "" {
if t, err := time.Parse("2006-01-02", q); err == nil {
date = &t
}
}
tasks, err := h.svc.ListTasks(middleware.UserID(c), uintParam(c, "id"), date)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tasks)
}
// ToggleTask POST /api/tasks/:id/toggle
func (h *Handler) ToggleTask(c *gin.Context) {
task, err := h.svc.ToggleTask(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, task)
}
// CompleteAllTasks POST /api/pets/:id/tasks/complete-all
func (h *Handler) CompleteAllTasks(c *gin.Context) {
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tasks)
}
// GetPlan GET /api/pets/:id/plan
func (h *Handler) GetPlan(c *gin.Context) {
plan, err := h.svc.GetPlan(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
func (h *Handler) PlanCalendar(c *gin.Context) {
now := time.Now()
year, month := now.Year(), int(now.Month())
if q := c.Query("month"); q != "" {
if t, err := time.Parse("2006-01", q); err == nil {
year, month = t.Year(), int(t.Month())
}
}
res, err := h.svc.Calendar(middleware.UserID(c), uintParam(c, "id"), year, month)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
type aiPlanReq struct {
Input string `json:"input"`
}
// CreateAIPlan POST /api/pets/:id/ai-plan
func (h *Handler) CreateAIPlan(c *gin.Context) {
var req aiPlanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), uintParam(c, "id"), req.Input)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// ApplyAIPlan POST /api/ai-plan/:id/apply
func (h *Handler) ApplyAIPlan(c *gin.Context) {
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// TogglePlanTask POST /api/plan-tasks/:id/toggle
func (h *Handler) TogglePlanTask(c *gin.Context) {
if err := h.svc.TogglePlanTask(middleware.UserID(c), uintParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type recordReq struct {
Type string `json:"type"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
NumValue float64 `json:"num_value"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
Extra datatypes.JSON `json:"extra"`
OccurredAt string `json:"occurred_at"`
}
// ListRecords GET /api/pets/:id/records?type=
func (h *Handler) ListRecords(c *gin.Context) {
records, err := h.svc.ListRecords(middleware.UserID(c), uintParam(c, "id"), c.Query("type"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, records)
}
// CreateRecord POST /api/pets/:id/records
func (h *Handler) CreateRecord(c *gin.Context) {
var req recordReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.RecordInput{
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, Extra: req.Extra,
}
if req.OccurredAt != "" {
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
in.OccurredAt = &t
}
}
rec, err := h.svc.CreateRecord(middleware.UserID(c), uintParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, rec)
}
// DeleteRecord DELETE /api/records/:id
func (h *Handler) DeleteRecord(c *gin.Context) {
if err := h.svc.DeleteRecord(middleware.UserID(c), uintParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// WeightTrend GET /api/pets/:id/records/weight-trend
func (h *Handler) WeightTrend(c *gin.Context) {
points, err := h.svc.WeightTrend(middleware.UserID(c), uintParam(c, "id"), 7)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, points)
}
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type reminderReq struct {
Type string `json:"type"`
Title string `json:"title"`
NextDueDate string `json:"next_due_date"` // YYYY-MM-DD
Frequency string `json:"frequency"`
}
// ListReminders GET /api/pets/:id/reminders
func (h *Handler) ListReminders(c *gin.Context) {
reminders, err := h.svc.ListReminders(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, reminders)
}
// CreateReminder POST /api/pets/:id/reminders
func (h *Handler) CreateReminder(c *gin.Context) {
var req reminderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
in.NextDueDate = &t
}
}
r, err := h.svc.CreateReminder(middleware.UserID(c), uintParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, r)
}
// UpdateReminder PUT /api/reminders/:id
func (h *Handler) UpdateReminder(c *gin.Context) {
var req reminderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Title != "" {
fields["title"] = req.Title
}
if req.Frequency != "" {
fields["frequency"] = req.Frequency
}
if req.NextDueDate != "" {
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
fields["next_due_date"] = t
}
}
r, err := h.svc.UpdateReminder(middleware.UserID(c), uintParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, r)
}
// DeleteReminder DELETE /api/reminders/:id
func (h *Handler) DeleteReminder(c *gin.Context) {
if err := h.svc.DeleteReminder(middleware.UserID(c), uintParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
+49
View File
@@ -0,0 +1,49 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/pkg/response"
)
// WeeklyReport GET /api/pets/:id/report/weekly
func (h *Handler) WeeklyReport(c *gin.Context) {
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, rep)
}
// Bill GET /api/pets/:id/bill?period=month
func (h *Handler) Bill(c *gin.Context) {
period := c.DefaultQuery("period", "month")
bill, err := h.svc.GetBill(middleware.UserID(c), uintParam(c, "id"), period)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, bill)
}
// HealthSummary GET /api/pets/:id/health-summary
func (h *Handler) HealthSummary(c *gin.Context) {
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, sum)
}
// Poster GET /api/pets/:id/poster
func (h *Handler) Poster(c *gin.Context) {
poster, err := h.svc.GetPoster(middleware.UserID(c), uintParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, poster)
}
+42
View File
@@ -0,0 +1,42 @@
package handler
import (
"fmt"
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/pkg/response"
)
// Upload POST /api/upload multipart/form-data,字段名 file
func (h *Handler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
response.FailParams(c, "缺少上传文件 file")
return
}
src, err := fileHeader.Open()
if err != nil {
response.FailErr(c, err)
return
}
defer src.Close()
ext := strings.ToLower(path.Ext(fileHeader.Filename))
objectName := fmt.Sprintf("uploads/%d/%d%s", middleware.UserID(c), time.Now().UnixNano(), ext)
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
url, err := h.svc.Upload(objectName, src, fileHeader.Size, contentType)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"url": url})
}
+77
View File
@@ -0,0 +1,77 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/pkg/errcode"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
// gin.Context 中存放身份的 key
const (
CtxUserID = "userID"
CtxAdminID = "adminID"
CtxName = "name"
)
func bearer(c *gin.Context) string {
h := c.GetHeader("Authorization")
if h == "" {
return ""
}
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return h
}
// AuthUser 小程序用户鉴权
func AuthUser(jm *appjwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := jm.Parse(bearer(c))
if err != nil || claims.Kind != appjwt.KindUser {
response.Abort(c, errcode.ErrUnauthized, "")
return
}
c.Set(CtxUserID, claims.ID)
c.Set(CtxName, claims.Name)
c.Next()
}
}
// AuthAdmin 后台管理员鉴权
func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := jm.Parse(bearer(c))
if err != nil || claims.Kind != appjwt.KindAdmin {
response.Abort(c, errcode.ErrUnauthized, "")
return
}
c.Set(CtxAdminID, claims.ID)
c.Set(CtxName, claims.Name)
c.Next()
}
}
// UserID 从上下文取当前用户 ID
func UserID(c *gin.Context) uint {
if v, ok := c.Get(CtxUserID); ok {
if id, ok := v.(uint); ok {
return id
}
}
return 0
}
// AdminID 从上下文取当前管理员 ID
func AdminID(c *gin.Context) uint {
if v, ok := c.Get(CtxAdminID); ok {
if id, ok := v.(uint); ok {
return id
}
}
return 0
}
+28
View File
@@ -0,0 +1,28 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS 开发期允许跨域(小程序无跨域限制,主要给后台 SPA dev server 用)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
} else {
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+10
View File
@@ -0,0 +1,10 @@
package model
// Admin 后台管理员
type Admin struct {
Base
Username string `gorm:"size:64;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:128" json:"-"`
Role string `gorm:"size:32;default:admin" json:"role"`
Disabled bool `json:"disabled"`
}
+11
View File
@@ -0,0 +1,11 @@
package model
// AIMessage AI 聊天消息
type AIMessage struct {
Base
UserID uint `gorm:"index" json:"user_id"`
PetID *uint `json:"pet_id"`
Session string `gorm:"size:64;index" json:"session"`
Role string `gorm:"size:8" json:"role"` // user / ai
Text string `gorm:"type:text" json:"text"`
}
+13
View File
@@ -0,0 +1,13 @@
package model
// Article 新手知识文章
type Article struct {
Base
Icon string `gorm:"size:16" json:"icon"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
Content string `gorm:"type:text" json:"content"`
Category string `gorm:"size:32" json:"category"`
RelatedSheetType string `gorm:"size:32" json:"related_sheet_type"`
Published bool `gorm:"default:true" json:"published"`
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "time"
// Base 所有模型的公共字段
type Base struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
func AllModels() []any {
return []any{
&User{},
&Pet{},
&HealthRecord{},
&DailyTask{},
&Plan{},
&PlanTask{},
&Reminder{},
&Post{},
&Comment{},
&PostLike{},
&Article{},
&AIMessage{},
&ProMembership{},
&Admin{},
&DailyAdvice{},
}
}
+11
View File
@@ -0,0 +1,11 @@
package model
// Comment 帖子评论
type Comment struct {
Base
PostID uint `gorm:"index" json:"post_id"`
UserID uint `gorm:"index" json:"user_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Status string `gorm:"size:16;default:published" json:"status"`
}
+9
View File
@@ -0,0 +1,9 @@
package model
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
type DailyAdvice struct {
Base
PetID uint `gorm:"uniqueIndex:idx_pet_day" json:"pet_id"`
Day string `gorm:"size:10;uniqueIndex:idx_pet_day" json:"day"` // YYYY-MM-DD
Text string `gorm:"type:text" json:"text"`
}
+17
View File
@@ -0,0 +1,17 @@
package model
import "time"
// DailyTask 今日任务
type DailyTask struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
TaskDate time.Time `gorm:"index" json:"task_date"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
Priority string `gorm:"size:16" json:"priority"` // "" / 重要
SheetType string `gorm:"size:32" json:"sheet_type"` // 点击打开的弹层类型
Done bool `json:"done"`
CompletedAt *time.Time `json:"completed_at"`
}
+37
View File
@@ -0,0 +1,37 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// 健康记录类型
const (
RecordWeight = "weight"
RecordPoop = "poop"
RecordFood = "food"
RecordSymptom = "symptom"
RecordMedicine = "medicine"
RecordVaccine = "vaccine"
RecordDeworm = "deworm"
RecordCost = "cost"
RecordPhoto = "photo"
)
// HealthRecord 统一健康/时间轴记录表,type 区分 9 类。
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
type HealthRecord struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
Type string `gorm:"size:16;index" json:"type"`
Icon string `gorm:"size:16" json:"icon"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
NumValue float64 `gorm:"type:decimal(10,2)" json:"num_value"`
Category string `gorm:"size:32" json:"category"`
ImageURL string `gorm:"size:512" json:"image_url"`
Extra datatypes.JSON `json:"extra"`
OccurredAt time.Time `gorm:"index" json:"occurred_at"`
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// Pet 宠物,属于 User,支持多宠
type Pet struct {
Base
UserID uint `gorm:"index" json:"user_id"`
Name string `gorm:"size:64" json:"name"`
Emoji string `gorm:"size:16" json:"emoji"`
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 刚到家 0-30 天 / 幼年期 / 成年期 / 老年期
Age string `gorm:"size:32" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
Goals datatypes.JSON `json:"goals"` // onboarding 目标多选
}
+41
View File
@@ -0,0 +1,41 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// 计划类型
const (
PlanThirtyDay = "thirty_day"
PlanAI = "ai"
)
// Plan 养宠计划(30 天路线图 / AI 计划)
type Plan struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
Kind string `gorm:"size:16;index" json:"kind"` // thirty_day / ai
Stage string `gorm:"size:32" json:"stage"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
UserInput string `gorm:"size:512" json:"user_input"`
Extracted datatypes.JSON `json:"extracted"` // AI 提取信息
CompletionPct int `json:"completion_pct"`
Status string `gorm:"size:16;default:active" json:"status"` // active / completed / archived
Tasks []PlanTask `gorm:"foreignKey:PlanID" json:"tasks"`
}
// PlanTask 计划明细项
type PlanTask struct {
Base
PlanID uint `gorm:"index" json:"plan_id"`
Day int `json:"day"`
DayLabel string `gorm:"size:32" json:"day_label"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
SheetType string `gorm:"size:32" json:"sheet_type"`
Done bool `json:"done"`
}
+26
View File
@@ -0,0 +1,26 @@
package model
import "gorm.io/datatypes"
// 帖子状态
const (
PostPublished = "published"
PostHidden = "hidden"
PostDeleted = "deleted"
)
// Post 社区帖子
type Post struct {
Base
UserID uint `gorm:"index" json:"user_id"`
PetID *uint `json:"pet_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
AuthorEmoji string `gorm:"size:16" json:"author_emoji"`
Identity string `gorm:"size:16" json:"identity"` // petName / anonymous / official
Content string `gorm:"type:text" json:"content"`
Tags datatypes.JSON `json:"tags"`
Images datatypes.JSON `json:"images"`
LikeCount int `json:"like_count"`
CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"`
}
+8
View File
@@ -0,0 +1,8 @@
package model
// PostLike 帖子点赞,(post_id, user_id) 唯一
type PostLike struct {
Base
PostID uint `gorm:"uniqueIndex:idx_post_user" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_post_user" json:"user_id"`
}
+21
View File
@@ -0,0 +1,21 @@
package model
import "time"
// Pro 会员状态
const (
ProActive = "active"
ProExpired = "expired"
ProNone = "none"
)
// ProMembership 会员,1:1 于 User
type ProMembership struct {
Base
UserID uint `gorm:"uniqueIndex" json:"user_id"`
Status string `gorm:"size:16;default:none" json:"status"`
PlanType string `gorm:"size:16" json:"plan_type"` // yearly / monthly
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}
+22
View File
@@ -0,0 +1,22 @@
package model
import "time"
// 提醒类型
const (
ReminderVaccine = "vaccine"
ReminderDeworm = "deworm"
ReminderWeight = "weight"
ReminderMonthlyReport = "monthlyReport"
)
// Reminder 提醒
type Reminder struct {
Base
PetID uint `gorm:"index" json:"pet_id"`
UserID uint `gorm:"index" json:"user_id"`
Type string `gorm:"size:24;index" json:"type"`
Title string `gorm:"size:128" json:"title"`
NextDueDate *time.Time `json:"next_due_date"`
Frequency string `gorm:"size:64" json:"frequency"`
}
+12
View File
@@ -0,0 +1,12 @@
package model
// User 小程序用户(铲屎官)
type User struct {
Base
OpenID string `gorm:"size:64;index" json:"openid"`
Nickname string `gorm:"size:64" json:"nickname"`
Avatar string `gorm:"size:512" json:"avatar"`
Phone string `gorm:"size:32" json:"phone"`
Onboarded bool `json:"onboarded"`
Disabled bool `json:"disabled"`
}
+128
View File
@@ -0,0 +1,128 @@
package router
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/admin"
"github.com/sundynix/pets-be/internal/handler"
"github.com/sundynix/pets-be/internal/middleware"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
// New 组装 gin 引擎与所有路由
func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
gin.SetMode(mode)
r := gin.New()
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
// 健康检查
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
// 根路径跳转后台
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
api := r.Group("/api")
registerAuth(api, h)
registerUserAPI(api, h, jm)
registerAdminAPI(api, h, jm)
// 内嵌后台 SPA
admin.Register(r)
return r
}
func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
auth := api.Group("/auth")
auth.POST("/login", h.Login)
auth.POST("/wechat", h.WechatLogin)
}
// 小程序用户接口(需 user JWT)
func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
g := api.Group("")
g.Use(middleware.AuthUser(jm))
g.GET("/user/profile", h.Profile)
g.PUT("/user/profile", h.UpdateProfile)
g.GET("/user/summary", h.UserSummary)
g.GET("/pets", h.ListPets)
g.POST("/pets", h.CreatePet)
g.POST("/onboarding", h.Onboarding)
g.GET("/pets/:id", h.GetPet)
g.PUT("/pets/:id", h.UpdatePet)
g.DELETE("/pets/:id", h.DeletePet)
g.GET("/pets/:id/records", h.ListRecords)
g.POST("/pets/:id/records", h.CreateRecord)
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
g.DELETE("/records/:id", h.DeleteRecord)
g.GET("/pets/:id/tasks", h.ListTasks)
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
g.POST("/tasks/:id/toggle", h.ToggleTask)
g.GET("/pets/:id/home-summary", h.HomeSummary)
g.GET("/pets/:id/plan", h.GetPlan)
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
g.GET("/pets/:id/reminders", h.ListReminders)
g.POST("/pets/:id/reminders", h.CreateReminder)
g.PUT("/reminders/:id", h.UpdateReminder)
g.DELETE("/reminders/:id", h.DeleteReminder)
g.GET("/pets/:id/report/weekly", h.WeeklyReport)
g.GET("/pets/:id/bill", h.Bill)
g.GET("/pets/:id/health-summary", h.HealthSummary)
g.GET("/pets/:id/poster", h.Poster)
g.GET("/posts", h.ListPosts)
g.POST("/posts", h.CreatePost)
g.GET("/posts/:id", h.GetPost)
g.POST("/posts/:id/like", h.LikePost)
g.DELETE("/posts/:id/like", h.UnlikePost)
g.GET("/posts/:id/comments", h.ListComments)
g.POST("/posts/:id/comments", h.CreateComment)
g.GET("/articles", h.ListArticles)
g.GET("/articles/:id", h.GetArticle)
g.GET("/pro", h.GetPro)
g.POST("/pro/activate", h.ActivatePro)
g.POST("/ai/chat", h.AIChat)
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
g.POST("/upload", h.Upload)
}
// 后台接口(登录开放,其余需 admin JWT)
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
api.POST("/admin/login", h.AdminLogin)
g := api.Group("/admin")
g.Use(middleware.AuthAdmin(jm))
g.GET("/me", h.AdminMe)
g.GET("/stats", h.AdminStats)
g.GET("/users", h.AdminListUsers)
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
g.GET("/pets", h.AdminListPets)
g.GET("/posts", h.AdminListPosts)
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
g.GET("/comments", h.AdminListComments)
g.DELETE("/comments/:id", h.AdminDeleteComment)
g.GET("/articles", h.AdminListArticles)
g.POST("/articles", h.AdminSaveArticle)
g.DELETE("/articles/:id", h.AdminDeleteArticle)
g.GET("/memberships", h.AdminListPro)
}
+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)
}
+85
View File
@@ -0,0 +1,85 @@
package storage
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/sundynix/pets-be/internal/config"
)
// Storage MinIO 对象存储封装
type Storage struct {
client *minio.Client
bucket string
publicBaseURL string
}
// New 初始化 MinIO 客户端,并确保 bucket 存在且可公开下载
func New(cfg config.MinIOConfig) (*Storage, error) {
client, err := minio.New(cfg.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("minio new: %w", err)
}
s := &Storage{
client: client,
bucket: cfg.Bucket,
publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"),
}
if err := s.ensureBucket(); err != nil {
return nil, err
}
return s, nil
}
func (s *Storage) ensureBucket() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
return fmt.Errorf("bucket exists: %w", err)
}
if !exists {
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
return fmt.Errorf("make bucket: %w", err)
}
}
// dev:设置公开只读策略,便于直接以 URL 访问对象
policy := fmt.Sprintf(`{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"AWS": ["*"]},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::%s/*"]
}]
}`, s.bucket)
if err := s.client.SetBucketPolicy(ctx, s.bucket, policy); err != nil {
return fmt.Errorf("set bucket policy: %w", err)
}
return nil
}
// Upload 上传对象,返回可公开访问的 URL
func (s *Storage) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return "", fmt.Errorf("put object: %w", err)
}
return fmt.Sprintf("%s/%s/%s", s.publicBaseURL, s.bucket, objectName), nil
}