feat: rebuild pet app and backend around core care flows

This commit is contained in:
2026-10-01 13:11:30 +08:00
parent 51ead8f411
commit bfcf8788a4
321 changed files with 4918 additions and 25199 deletions
+2 -9
View File
@@ -1,15 +1,8 @@
.PHONY: env-up env-down admin-install admin-build admin-dev build run tidy
# 起/停 MySQL + MinIO
env-up:
cd deployments && docker compose up -d
env-down:
cd deployments && docker compose down
.PHONY: admin-install admin-build admin-dev build run tidy
# 后台前端
admin-install:
cd web/admin && npm install
cd web/admin && npm ci
admin-build:
cd web/admin && npm run build
+26 -55
View File
@@ -1,64 +1,35 @@
# 毛孩子计划 · 后端 (pets-be)
# 肉垫计划
Gin + GORM + MySQL + MinIO 的宠物小程序后端,内嵌 React(Vite + Tailwind + shadcn/ui) 后台管理。
## 技术栈与约定
- 模块路径 `github.com/sundynix/pets-be`,标准 Go 布局(`cmd/ internal/ pkg/ configs/ deployments/ web/`)
- 所有数据库表带前缀 `sundynix_`
- 统一响应 `{code, message, data}`(`pkg/response`),列表接口统一分页参数 `page` / `page_size`(`response.PageQuery` + `PageResult`)
- MySQL `root/root` 库 `pets`;MinIO `sundynix/sundynix` 桶 `pets`
- 服务端口 `:8080`;后台在 `http://localhost:8080/admin`(seed 管理员 `sundynix/sundynix`)
## 快速开始
```bash
# 1. 起依赖(MySQL + MinIO)
make env-up # 或 cd deployments && docker compose up -d
# 2. 构建后台前端 + 后端单二进制
make build # 产物 bin/pets-be(已 embed 后台)
./bin/pets-be
# 或开发期直接运行(使用现有 internal/admin/dist)
make run
```
### 后台前端开发(热更新)
```bash
make admin-dev # vite dev server :5173,已代理 /api → :8080
# 改完执行 make admin-build 让产物落到 internal/admin/dist,再 go build 即 embed
```
核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/admin/`。
## 目录
- `cmd/server` 入口:载配置→连库→迁移→seed→建桶→路由→启动
- `internal/config` 配置(viper,支持 `PETS_` 环境变量覆盖)
- `internal/model` GORM 模型(`sundynix_` 前缀)
- `internal/database` 连接/迁移/seed
- `internal/storage` MinIO 封装(自动建桶 + 上传)
- `internal/service` 业务逻辑;`internal/handler` HTTP 处理器;`internal/router` 路由
- `internal/middleware` CORS / 用户鉴权 / 管理员鉴权
- `internal/admin` `go:embed` 内嵌后台产物 + SPA fallback
- `pkg/response` 统一响应与分页;`pkg/jwt` 令牌;`pkg/errcode` 错误码
- `web/admin` 后台前端源码
## 接口
- 小程序 `/api/*`(用户 JWT):auth / user / pets / onboarding / records / tasks / plan / reminders / report / bill / community / articles / pro / ai / upload
- 后台 `/api/admin/*`(管理员 JWT):login / stats / users / pets / posts / comments / articles / memberships
- `cmd/server`:服务入口
- `internal/config`:配置
- `internal/model`:数据库模型
- `internal/database`:PostgreSQL 连接和迁移
- `internal/storage`:MinIO 文件存储
- `internal/httpapi`:用户与管理端 API
- `web/admin`:管理端源码
- `internal/admin/dist`:嵌入的管理端构建产物
## 登录鉴权
默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。
接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。
## 本地启动
### access token + refresh token
登录接口返回 `token`(access) 与 `refresh_token` 两个令牌:
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。
2. `cd web/admin && npm ci && npm run build`。
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
4. 执行 `go run ./cmd/server`。
- **access token**:JWT,默认 **2 小时**(`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。
- **refresh token**:随机串,落库 `sundynix_refresh_tokens`(**只存 sha256**)。小程序 30 天、后台 7 天。
- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。
- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。
默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/admin/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。
安全约定:
- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。
- 主动退出、被连坐吊销的令牌不享受宽限期。
- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。
配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。
前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。
## 数据
GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例如 `sundynix_user`。主键均为 PostgreSQL `uuid`,Go 字段为 `string`。文件内容存 MinIO;`sundynix_file` 存 bucket、object_key、md5、url 和元信息。头像与约遛封面只通过 `*_file_id` 关联文件表。上传会先按 MD5 查重。
现有 MySQL 数据未自动迁移。新的 PostgreSQL 表在服务启动时自动创建。正式切换旧数据前,应单独设计数据迁移映射并备份。
## 部署
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"fmt"
"log"
"regexp"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
)
var databaseName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// dbinit creates the configured PostgreSQL database once. It does not change other databases.
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
name := cfg.Postgres.Database
if !databaseName.MatchString(name) {
log.Fatal("invalid database name")
}
maintenance := cfg.Postgres
maintenance.Database = "postgres"
db, err := gorm.Open(postgres.Open(maintenance.DSN()), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
var count int64
if err := db.Raw("SELECT count(*) FROM pg_database WHERE datname = ?", name).Scan(&count).Error; err != nil {
log.Fatal(err)
}
if count > 0 {
log.Printf("database %s already exists", name)
return
}
if err := db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", name)).Error; err != nil {
log.Fatal(err)
}
log.Printf("created database %s", name)
}
+18 -34
View File
@@ -2,56 +2,40 @@ package main
import (
"fmt"
"log"
"github.com/sundynix/pets-be/internal/ai"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/admin"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/handler"
"github.com/sundynix/pets-be/internal/router"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/internal/httpapi"
"github.com/sundynix/pets-be/internal/storage"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
log.Fatal(err)
}
db, err := database.New(cfg)
db, err := database.Open(cfg.Postgres, cfg.Server.Mode == "release")
if err != nil {
log.Fatalf("connect mysql: %v", err)
log.Fatal(err)
}
if err := database.Migrate(db); err != nil {
log.Fatalf("migrate: %v", err)
log.Fatal(err)
}
if err := database.Seed(db, cfg); err != nil {
log.Fatalf("seed: %v", err)
}
st, err := storage.New(cfg.MinIO)
if err != nil {
log.Fatalf("connect minio: %v", err)
log.Fatal(err)
}
engine := ai.New(cfg.AI)
log.Printf("AI provider: %s", engine.ProviderName())
svc := service.New(db, st, cfg, engine)
if err := svc.SeedCommunityBot(); err != nil {
log.Fatalf("seed community bot: %v", err)
api := httpapi.New(db, st, cfg)
if err := api.SeedAdmin(); err != nil {
log.Fatal(err)
}
svc.StartCommunityBot()
jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
h := handler.New(svc, jm, cfg)
r := router.New(h, jm, cfg.Server.Mode)
gin.SetMode(cfg.Server.Mode)
r := gin.New()
api.Register(r)
admin.Register(r)
addr := fmt.Sprintf(":%d", cfg.Server.Port)
log.Printf("pets-be listening on %s (admin: http://localhost%s/admin)", addr, addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server run: %v", err)
}
log.Printf("肉垫计划 listening on %s", addr)
log.Fatal(r.Run(addr))
}
+15 -25
View File
@@ -1,43 +1,33 @@
server:
port: 9090
mode: debug # debug | release
mode: release
mysql:
host: 127.0.0.1
port: 3306
postgres:
host: 192.168.100.127
port: 5432
user: root
password: root
database: pets
charset: utf8mb4
database: sundynix-pets
ssl_mode: disable
minio:
endpoint: 127.0.0.1:9000
endpoint: https://s3.sundynix.cn
access_key: sundynix
secret_key: sundynix
bucket: pets
use_ssl: false
public_base_url: http://127.0.0.1:9000 # 拼接可访问的对象 URL
bucket: sundynix-pet
use_ssl: true
jwt:
secret: change-me-to-a-long-random-string # 生产用 PETS_JWT_SECRET 覆盖
secret: "" # 用 PETS_JWT_SECRET 注入
expire_hours: 168
wechat:
app_id: your-wechat-app-id
app_secret: your-wechat-app-secret # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
app_id: ""
app_secret: ""
auth:
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录
dev_login: false
admin:
username: sundynix
password: change-me # 启动时若无同名管理员则 seed 此账号
ai:
enabled: false # false 时全部走规则化文案(不烧 token);接大模型改 true
provider: openai # openai 兼容协议,适配 DeepSeek/Qwen(兼容模式)/Gemini(兼容端点)等
base_url: https://api.deepseek.com # 换供应商只改这里 + model
api_key: your-ai-api-key # 敏感:生产用 PETS_AI_API_KEY 环境变量覆盖
model: deepseek-chat
temperature: 0.6
max_tokens: 1024
timeout_sec: 30
password: sundynix
+32
View File
@@ -0,0 +1,32 @@
server:
port: 9090
mode: debug # debug | release
postgres:
host: 192.168.100.127
port: 5432
user: root
password: root
database: sundynix-pets
ssl_mode: disable
minio:
endpoint: https://s3.sundynix.cn
access_key: sundynix
secret_key: sundynix
bucket: sundynix-pet
use_ssl: true
jwt:
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
wechat:
app_id: wx52dfc635739a9c19
app_secret: b3c78abf7bcdd5b4e3c444f9e6caad3f # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
auth:
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录
admin:
username: sundynix
password: sundynix
-45
View File
@@ -1,45 +0,0 @@
services:
mysql:
image: mysql:8.0
container_name: pets-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pets
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:latest
container_name: pets-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: sundynix
MINIO_ROOT_PASSWORD: sundynix
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 20
volumes:
mysql_data:
minio_data:
+8 -6
View File
@@ -1,20 +1,19 @@
module github.com/sundynix/pets-be
go 1.26.4
go 1.26.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.2.1
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.53.0
gorm.io/datatypes v1.2.7
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -27,11 +26,13 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -63,6 +64,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
+9 -24
View File
@@ -1,5 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -33,8 +31,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -43,10 +39,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -54,12 +46,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -83,8 +75,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
@@ -127,6 +117,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -174,15 +165,9 @@ gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,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-f13soFkd.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
<script type="module" crossorigin src="/admin/assets/index-DmUsbLpt.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BQA5NViF.css">
</head>
<body>
<div id="root"></div>
-73
View File
@@ -1,73 +0,0 @@
// 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
@@ -1,122 +0,0 @@
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]
}
+51 -101
View File
@@ -9,28 +9,14 @@ import (
"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"`
Server ServerConfig `mapstructure:"server"`
Postgres PostgresConfig `mapstructure:"postgres"`
MinIO MinIOConfig `mapstructure:"minio"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Auth AuthConfig `mapstructure:"auth"`
Admin AdminConfig `mapstructure:"admin"`
}
type ServerConfig struct {
@@ -38,47 +24,36 @@ type ServerConfig struct {
Mode string `mapstructure:"mode"`
}
type MySQLConfig struct {
type PostgresConfig 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"`
SSLMode string `mapstructure:"ssl_mode"`
}
// 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,
)
func (p PostgresConfig) DSN() string {
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
p.Host, p.Port, p.User, p.Password, p.Database, p.SSLMode)
}
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"`
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
}
// JWTConfig access token 短、refresh token 长:
// access 过期由前端拿 refresh 静默续期,refresh 每次使用都轮换。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"` // access token 有效期
RefreshExpireHours int `mapstructure:"refresh_expire_hours"` // 小程序用户刷新令牌有效期
AdminRefreshExpireHours int `mapstructure:"admin_refresh_expire_hours"` // 后台刷新令牌有效期(更短)
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
}
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
MsgToken string `mapstructure:"msg_token"`
}
type AuthConfig struct {
@@ -90,79 +65,54 @@ type AdminConfig struct {
Password string `mapstructure:"password"`
}
// setDefaults 为每个配置项登记默认值。
// 必须登记:viper 的 AutomaticEnv 只对「已知的 key」生效,未登记的 key 即使设了
// PETS_XXX 环境变量,Unmarshal 时也绑不上(容器里纯 .env 部署会静默取到空值)。
func setDefaults(v *viper.Viper) {
v.SetDefault("server.port", 9090)
v.SetDefault("server.mode", "release")
v.SetDefault("mysql.host", "127.0.0.1")
v.SetDefault("mysql.port", 3306)
v.SetDefault("mysql.user", "root")
v.SetDefault("mysql.password", "")
v.SetDefault("mysql.database", "pets")
v.SetDefault("mysql.charset", "utf8mb4")
v.SetDefault("minio.endpoint", "127.0.0.1:9000")
v.SetDefault("minio.access_key", "")
v.SetDefault("minio.secret_key", "")
v.SetDefault("minio.bucket", "pets")
v.SetDefault("minio.use_ssl", false)
v.SetDefault("minio.public_base_url", "")
v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 2)
v.SetDefault("jwt.refresh_expire_hours", 720) // 30 天
v.SetDefault("jwt.admin_refresh_expire_hours", 168)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
v.SetDefault("wechat.msg_token", "") // 用 PETS_WECHAT_MSG_TOKEN 覆盖
v.SetDefault("auth.dev_login", false)
v.SetDefault("admin.username", "sundynix")
v.SetDefault("admin.password", "")
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.base_url", "")
v.SetDefault("ai.api_key", "")
v.SetDefault("ai.model", "")
v.SetDefault("ai.temperature", 0.6)
v.SetDefault("ai.max_tokens", 1024)
v.SetDefault("ai.timeout_sec", 30)
}
// Load 载入配置:默认值 → configs/config.yaml(可选)→ 环境变量覆盖。
// 环境变量用 PETS_ 前缀、点转下划线,例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password。
// 容器部署不带 config.yaml,全部走 .env 注入的环境变量。
func Load() (*Config, error) {
v := viper.New()
setDefaults(v)
v.SetDefault("server.port", 9090)
v.SetDefault("server.mode", "debug")
v.SetDefault("postgres.host", "192.168.100.127")
v.SetDefault("postgres.port", 5432)
v.SetDefault("postgres.user", "root")
v.SetDefault("postgres.password", "")
v.SetDefault("postgres.database", "sundynix-pets")
v.SetDefault("postgres.ssl_mode", "disable")
v.SetDefault("minio.endpoint", "https://s3.sundynix.cn")
v.SetDefault("minio.access_key", "")
v.SetDefault("minio.secret_key", "")
v.SetDefault("minio.bucket", "sundynix-pet")
v.SetDefault("minio.use_ssl", true)
v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 168)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
v.SetDefault("auth.dev_login", false)
v.SetDefault("admin.username", "sundynix")
v.SetDefault("admin.password", "")
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 {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) && !os.IsNotExist(err) {
var missing viper.ConfigFileNotFoundError
if !errors.As(err, &missing) && !os.IsNotExist(err) {
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 nil, fmt.Errorf("decode config: %w", err)
}
if cfg.Postgres.Password == "" || cfg.JWT.Secret == "" {
return nil, errors.New("postgres.password and jwt.secret are required")
}
if cfg.MinIO.AccessKey == "" || cfg.MinIO.SecretKey == "" {
return nil, errors.New("minio access_key and secret_key are required")
}
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
return nil, errors.New("dev login must be disabled in release mode")
}
return &cfg, nil
}
-233
View File
@@ -1,233 +0,0 @@
package database
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
//
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
// 青年期 绝育窗口 + 换成年粮
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
// 中年期 年度血检开始有意义,体重和牙结石重点看
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
//
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
//
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
// 用户建完档只能自己从零排计划。
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
Title: title, Description: desc, SheetType: sheet}
}
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatWeekly, sheet)
it.Weekdays = weekdays
return it
}
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatMonthly, sheet)
it.MonthDay = day
return it
}
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatOnce, sheet)
it.Day = day
return it
}
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
Title: title, OffsetDays: offset, Frequency: freq}
}
func important(it model.CareTemplateItem) model.CareTemplateItem {
it.Priority = "重要"
return it
}
// TplDef 一套方案的定义(迁移程序要读它)
type tplDef struct {
Species string
Stage string
Name string
Summary string
Items []model.CareTemplateItem
}
// CareTemplateDefs 导出给一次性迁移程序用
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
func careTemplateDefs() []tplDef {
const (
J = "幼年期"
Y = "青年期"
A = "成年期"
M = "中年期"
S = "老年期"
)
return []tplDef{
// ───────────────── 猫 ─────────────────
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
// ───────────────── 狗 ─────────────────
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
}
}
// seedCareTemplates 只在方案表为空时铺一次
func seedCareTemplates(db *gorm.DB) error {
var count int64
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return insertCareTemplates(db)
}
func insertCareTemplates(db *gorm.DB) error {
for i, d := range careTemplateDefs() {
tpl := model.CareTemplate{
Species: d.Species, Stage: d.Stage, Name: d.Name,
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
}
if err := db.Create(&tpl).Error; err != nil {
return err
}
items := make([]model.CareTemplateItem, 0, len(d.Items))
for j, it := range d.Items {
it.TemplateID = tpl.ID
it.Sort = j
items = append(items, it)
}
if len(items) > 0 {
if err := db.Create(&items).Error; err != nil {
return err
}
}
}
return nil
}
+12 -21
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
@@ -13,40 +13,31 @@ import (
"github.com/sundynix/pets-be/internal/model"
)
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
func New(cfg *config.Config) (*gorm.DB, error) {
func Open(cfg config.PostgresConfig, release bool) (*gorm.DB, error) {
logLevel := logger.Info
if cfg.Server.Mode == "release" {
if release {
logLevel = logger.Warn
}
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "sundynix_",
SingularTable: false,
},
// 关联在 Go 层管,不靠库级外键。迁移期建外键时 MySQL 会为外键列自动
// 补一个索引,和字段上的 `index` tag 撞成同名索引,父子表同迁时第二次
// 建索引报 1061 duplicate。关掉外键约束创建,索引就只由 index tag 建一次
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.Default.LogMode(logLevel),
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return nil, fmt.Errorf("gorm open: %w", err)
return nil, fmt.Errorf("open postgres: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
}
// Migrate 自动建表
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(model.AllModels()...)
if err := db.AutoMigrate(model.All()...); err != nil {
return err
}
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
}
-224
View File
@@ -1,224 +0,0 @@
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 初始化种子数据:管理员账号 + 记录类型。
//
// 文章、养护模板这类**内容**数据不放代码里——正文几百字塞进 Go 源文件,既看不清
// 也没法在后台改完再同步回来。上线时直接同步数据库。
//
// 记录类型是另一码事,它是**结构**:表空着的话记录页一个可记事项都没有,
// App 直接不能用。所以和管理员账号一样,只在表完全为空时铺一次底,
// 之后一切以数据库为准,代码里这份不再参与。
func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
if err := seedRecordTypes(db); err != nil {
return err
}
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
//
// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。
// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬)
// 自动转常出错,人来定比库来猜靠谱。
func seedBreeds(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
breeds := []model.Breed{
// 猫 34 种
{Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10},
{Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20},
{Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30},
{Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40},
{Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50},
{Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60},
{Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70},
{Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80},
{Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90},
{Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100},
{Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110},
{Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120},
{Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130},
{Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140},
{Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150},
{Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160},
{Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170},
{Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180},
{Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190},
{Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200},
{Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210},
{Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220},
{Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230},
{Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240},
{Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250},
{Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260},
{Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290},
{Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300},
{Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310},
{Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320},
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
}
for i := range breeds {
breeds[i].Enabled = true
}
return db.Create(&breeds).Error
}
// seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰,
// 否则后台删掉的类型会在每次重启后自己长回来
func seedRecordTypes(db *gorm.DB) error {
var count int64
if err := db.Model(&model.RecordType{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
// locked=true 的这 9 种在 record.go / insight.go / report.go / home.go 里有真实
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
// care_template_items.sheet_type 引用,后台不许删,只能停用。
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
// 空 = 只要「时间 + 描述 + 照片」。
//
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
// weight number → num_value,CreateRecord 靠它回写宠物档案
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
// poop 状态→category,食便相关性分析排除 category='正常'
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
form := model.RecordFormSimple
if locked {
form = model.RecordFormFull
}
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
}
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, true, fields)
}
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, false, fields)
}
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
types := []model.RecordType{
// 日常
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
full("photo", "照片", "photo", d, 60, ""),
// 健康
full("symptom", "异常", "symptom", h, 10,
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
// 洗护
simple("bath", "洗澡", "bath", c, 10, ""),
simple("nail", "剪指甲", "nail", c, 20, ""),
simple("ear", "洗耳朵", "ear", c, 30, ""),
simple("tooth", "刷牙", "tooth", c, 40, ""),
simple("brush", "梳毛", "brush", c, 50, ""),
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
// 清洁
simple("litter", "换猫砂", "litter", cl, 10, ""),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
simple("clean", "消毒", "clean", cl, 50, ""),
}
return db.Create(&types).Error
}
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
}
-558
View File
@@ -1,558 +0,0 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
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
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"admin": admin,
}, nil
}
// AdminRefresh POST /api/admin/refresh
func (h *Handler) AdminRefresh(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
admin, err := h.svc.GetAdmin(adminID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if admin.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
func (h *Handler) AdminLogout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
response.OK(c, gin.H{"ok": true})
}
// 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)
}
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
func (h *Handler) AdminAnalytics(c *gin.Context) {
days, _ := strconv.Atoi(c.Query("days"))
data, err := h.svc.AdminAnalytics(days)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
Status string `form:"status"`
Type string `form:"type"`
}
// 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.Type, 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(idParam(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(idParam(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.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(idParam(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 string `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/articles(id>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(idParam(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))
}
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
// ── 品种 ──
type breedReq struct {
ID string `json:"id"`
Species string `json:"species"`
Name string `json:"name"`
Initial string `json:"initial"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
}
// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回
func (h *Handler) AdminListBreeds(c *gin.Context) {
response.OK(c, h.svc.AdminListBreeds(c.Query("species")))
}
// AdminSaveBreed POST /api/admin/breeds
func (h *Handler) AdminSaveBreed(c *gin.Context) {
var req breedReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
b := &model.Breed{Species: req.Species, Name: req.Name,
Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled}
b.ID = req.ID
if err := h.svc.SaveBreed(b); err != nil {
respondErr(c, err)
return
}
response.OK(c, b)
}
// AdminDeleteBreed DELETE /api/admin/breeds/:id
func (h *Handler) AdminDeleteBreed(c *gin.Context) {
if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// ── 记录类型 ──
type recordTypeReq struct {
ID string `json:"id"`
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Group string `json:"group"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Form string `json:"form"`
Fields string `json:"fields_raw"`
}
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
func (h *Handler) AdminListRecordTypes(c *gin.Context) {
response.OK(c, h.svc.AdminListRecordTypes())
}
// AdminSaveRecordType POST /api/admin/record-types 新增或更新
func (h *Handler) AdminSaveRecordType(c *gin.Context) {
var req recordTypeReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
t := &model.RecordType{
Code: req.Code, Label: req.Label, Icon: req.Icon,
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
Fields: req.Fields,
}
t.ID = req.ID
if err := h.svc.SaveRecordType(t); err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// AdminDeleteRecordType DELETE /api/admin/record-types/:id
// locked 的和已有记录的删不了,service 层硬拦
func (h *Handler) AdminDeleteRecordType(c *gin.Context) {
if err := h.svc.DeleteRecordType(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
response.OK(c, gin.H{
"species": []gin.H{
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
// 「关联记录事项」的可选值读 record_types 表,不再写死。
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
// 而后台新加的 24 种事项一个都选不到
"record_types": h.svc.AdminListRecordTypes(),
"repeats": []string{"once", "daily", "weekly", "monthly"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
response.OK(c, h.svc.AdminListTemplates())
}
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tpl)
}
type careTemplateReq struct {
ID string `json:"id"`
Species string `json:"species"`
Stage string `json:"stage"`
Name string `json:"name"`
Summary string `json:"summary"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
tpl := &model.CareTemplate{
Species: req.Species, Stage: req.Stage, Name: req.Name,
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
}
tpl.ID = req.ID
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
respondErr(c, err)
return
}
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, saved)
}
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
}
// AdminGenerateCareTemplate POST /api/admin/care-templates/generate
// AI 生成草稿(不落库),未开启 AI 时回退内置默认
func (h *Handler) AdminGenerateCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.GenerateCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
// AdminGetCommunityBot GET /api/admin/community-bot
func (h *Handler) AdminGetCommunityBot(c *gin.Context) {
response.OK(c, h.svc.GetCommunityBotConfig())
}
// AdminSaveCommunityBot PUT /api/admin/community-bot
func (h *Handler) AdminSaveCommunityBot(c *gin.Context) {
var req model.CommunityBotConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
saved, err := h.svc.SaveCommunityBotConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, saved)
}
type communityGenReq struct {
Count int `json:"count"`
}
// AdminGenerateCommunityPosts POST /api/admin/community-bot/generate
func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
var req communityGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Count <= 0 {
req.Count = 1
}
if req.Count > 30 {
req.Count = 30
}
made, err := h.svc.GenerateCommunityPosts(req.Count)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"made": made})
}
// AdminGetAIQuota GET /api/admin/ai-quota
func (h *Handler) AdminGetAIQuota(c *gin.Context) {
response.OK(c, h.svc.GetAIQuotaConfig())
}
// AdminSaveAIQuota PUT /api/admin/ai-quota
func (h *Handler) AdminSaveAIQuota(c *gin.Context) {
var req model.AIQuotaConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
cfg, err := h.svc.SaveAIQuotaConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, cfg)
}
-229
View File
@@ -1,229 +0,0 @@
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 loginReq struct {
Nickname string `json:"nickname"`
}
// userTokens 签发小程序用户的「access + refresh」令牌对。
// access token 只有 2 小时,前端过期后拿 refresh_token 静默续期。
func (h *Handler) userTokens(user *model.User) (gin.H, error) {
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindUser), user.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"user": user,
}, nil
}
// 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
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
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 {
// 只覆盖系统生成的昵称。用户自己改过的(NicknameAuto=false)不动
if req.Nickname != "" && user.NicknameAuto {
fields["nickname"] = req.Nickname
fields["nickname_auto"] = false
}
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
}
}
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
// RefreshToken POST /api/auth/refresh
// 用刷新令牌换新的 access token;刷新令牌本身也会轮换,旧的立即失效。
func (h *Handler) RefreshToken(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
userID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
user, err := h.svc.GetUser(userID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if user.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// Logout POST /api/auth/logout 作废当前刷新令牌
func (h *Handler) Logout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
response.OK(c, gin.H{"ok": true})
}
// 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"`
AvatarFileID *string `json:"avatar_file_id"`
Phone *string `json:"phone"`
}
// UpdateProfile 更新资料
// decorationReq 主页装扮。全部用指针:区分「没传」和「传了空串」——
// 用户清空个性签名和不改个性签名是两件事
type decorationReq struct {
Bio *string `json:"bio"`
BgFileID *string `json:"bg_file_id"`
Theme *string `json:"theme"`
ShowPets *bool `json:"show_pets"`
}
// UpdateDecoration PUT /api/user/decoration 装扮自己的主页
func (h *Handler) UpdateDecoration(c *gin.Context) {
var req decorationReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
user, err := h.svc.UpdateDecoration(middleware.UserID(c), req.Bio, req.BgFileID, req.Theme, req.ShowPets)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, user)
}
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
fields["nickname_auto"] = false // 用户自己设过就别再被微信授权覆盖
}
if req.Avatar != nil {
fields["avatar"] = *req.Avatar
}
if req.AvatarFileID != nil {
fields["avatar_file_id"] = *req.AvatarFileID
}
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)
}
// BindPhone POST /api/user/phone 用小程序 getPhoneNumber 的 code 绑定手机号
func (h *Handler) BindPhone(c *gin.Context) {
var req struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" {
response.FailParams(c, "缺少 code")
return
}
phone, err := h.svc.BindPhoneByCode(middleware.UserID(c), req.Code)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"phone": phone})
}
-221
View File
@@ -1,221 +0,0 @@
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(middleware.UserID(c), 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(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, post)
}
type postReq struct {
PetID *string `json:"pet_id"`
Identity string `json:"identity"`
Content string `json:"content"`
Tags []string `json:"tags"`
Images []string `json:"images"`
ImageFileIDs []string `json:"image_file_ids"`
}
// 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)
}
if len(req.ImageFileIDs) > 0 {
if b, err := jsonMarshal(req.ImageFileIDs); err == nil {
in.ImageFileIDs = 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), idParam(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), idParam(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(middleware.UserID(c), idParam(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"`
Images []string `json:"images"`
ParentID string `json:"parent_id"` // 回复某条评论时传它的 id
}
// 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), idParam(c, "id"), req.Content, req.Images, req.ParentID)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, comment)
}
// FollowUser POST /api/users/:id/follow
func (h *Handler) FollowUser(c *gin.Context) {
if err := h.svc.FollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"followed": true})
}
// UnfollowUser DELETE /api/users/:id/follow
func (h *Handler) UnfollowUser(c *gin.Context) {
if err := h.svc.UnfollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"followed": false})
}
// DeleteOwnPost DELETE /api/posts/:id (仅限本人)
func (h *Handler) DeleteOwnPost(c *gin.Context) {
if err := h.svc.DeleteOwnPost(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// DeleteOwnComment DELETE /api/comments/:id (仅限本人)
func (h *Handler) DeleteOwnComment(c *gin.Context) {
if err := h.svc.DeleteOwnComment(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// ListReplies GET /api/comments/:id/replies 展开某条评论的全部回复
func (h *Handler) ListReplies(c *gin.Context) {
list, err := h.svc.ListReplies(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// UserCard GET /api/users/:id/profile 某人的公开资料
func (h *Handler) UserCard(c *gin.Context) {
card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
// UserPosts GET /api/users/:id/posts 某人发布的帖子
func (h *Handler) UserPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// Relations GET /api/users/:id/relations?kind=following|followers
func (h *Handler) Relations(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
kind := c.Query("kind")
list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
-18
View File
@@ -1,18 +0,0 @@
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}
}
-39
View File
@@ -1,39 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"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"
)
// idParam 取路径参数(字符串雪花 ID)
func idParam(c *gin.Context, key string) string {
return c.Param(key)
}
// 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
}
if errors.Is(err, service.ErrForbidden) {
response.Fail(c, errcode.ErrForbidden, err.Error())
return
}
if errors.Is(err, service.ErrInvalidParam) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err)
}
-199
View File
@@ -1,199 +0,0 @@
package handler
import (
"errors"
"strconv"
"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(idParam(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 {
// 支付未接入不是服务器错误,按参数错返回,前端直接展示提示文案
if errors.Is(err, service.ErrPayNotReady) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err)
return
}
response.OK(c, info)
}
type aiChatReq struct {
PetID *string `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 {
if errors.Is(err, service.ErrAIQuotaExceeded) {
response.Fail(c, 42900, err.Error())
return
}
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), idParam(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), idParam(c, "id"), service.SymptomInput{
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
})
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
type feedbackReq struct {
Content string `json:"content"`
Contact string `json:"contact"`
}
// CreateFeedback POST /api/feedback
func (h *Handler) CreateFeedback(c *gin.Context) {
var req feedbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
f, err := h.svc.CreateFeedback(middleware.UserID(c), req.Content, req.Contact)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, f)
}
// AdminListFeedback GET /api/admin/feedback
func (h *Handler) AdminListFeedback(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListFeedbackAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
type handledReq struct {
Handled bool `json:"handled"`
}
// AdminSetFeedbackHandled PUT /api/admin/feedback/:id/handled
func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
var req handledReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetFeedbackHandled(idParam(c, "id"), req.Handled); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// ListAIMessages GET /api/ai/messages?session=&limit=
func (h *Handler) ListAIMessages(c *gin.Context) {
limit := 0
if v := c.Query("limit"); v != "" {
limit, _ = strconv.Atoi(v)
}
msgs, err := h.svc.ListAIMessages(middleware.UserID(c), c.Query("session"), limit)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, msgs)
}
// AIQuota GET /api/ai/quota 今日剩余次数
func (h *Handler) AIQuota(c *gin.Context) {
response.OK(c, h.svc.AIQuotaLeft(middleware.UserID(c)))
}
-234
View File
@@ -1,234 +0,0 @@
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/model"
"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
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
AvatarFileID string `json:"avatar_file_id"`
Goals []string `json:"goals"`
Personality string `json:"personality"`
Allergy string `json:"allergy"`
Notes string `json:"notes"`
}
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,
AvatarFileID: r.AvatarFileID,
Personality: r.Personality, Allergy: r.Allergy, Notes: r.Notes,
}
if r.Birthday != "" {
if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil {
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
if !t.After(time.Now()) {
in.Birthday = &t
}
}
}
if r.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", r.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
in.ArrivedAt = &t
}
}
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
// 交给 handler 里 h.svc.NormalizeStage 处理。
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
}
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
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), idParam(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.Personality != "" {
fields["personality"] = req.Personality
}
if req.Allergy != "" {
fields["allergy"] = req.Allergy
}
if req.Notes != "" {
fields["notes"] = req.Notes
}
if req.Breed != "" {
fields["breed"] = req.Breed
}
// 生日和到家日期是日期列,得先解析成 time。之前漏了这两个,
// 导致编辑档案时改到家日期不落库(建档走 toInput 才正常)
if req.Birthday != "" {
if t, err := time.ParseInLocation("2006-01-02", req.Birthday, time.Local); err == nil {
fields["birthday"] = t
}
}
if req.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", req.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
fields["arrived_at"] = t
}
}
if req.AvatarFileID != "" {
fields["avatar_file_id"] = req.AvatarFileID
}
pet, err := h.svc.UpdatePet(middleware.UserID(c), idParam(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), idParam(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
}
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
// Breeds GET /api/breeds?species=cat 品种,按首字母分组。
// 建档第二步的选择器直接照这个渲染,前端不做分组也不做排序
func (h *Handler) Breeds(c *gin.Context) {
response.OK(c, h.svc.ListBreeds(c.Query("species")))
}
// LifeStages GET /api/life-stages?species=dog&size=large
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
func (h *Handler) LifeStages(c *gin.Context) {
species := c.Query("species")
if species == "" {
species = model.SpeciesCat
}
response.OK(c, gin.H{
"species": species,
"size": c.Query("size"),
"stages": service.StageRules(species, c.Query("size")),
"sizes": service.SizeLabels,
})
}
// PetStageDrift GET /api/pets/:id/stage-drift
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
func (h *Handler) PetStageDrift(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, h.svc.CheckStageDrift(pet))
}
// PetIDCard GET /api/pets/:id/id-card 宠物身份卡内容(含小程序码,未发布时无码)
func (h *Handler) PetIDCard(c *gin.Context) {
card, err := h.svc.GetIDCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
-220
View File
@@ -1,220 +0,0 @@
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"
)
// 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.ParseInLocation("2006-01-02", q, time.Local); err == nil {
date = &t
}
}
tasks, err := h.svc.ListTasks(middleware.UserID(c), idParam(c, "id"), date)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tasks)
}
// 任务的增删改统一走 /plan-tasks 那套 —— 「今日任务」和「计划节点」合并之后,
// /tasks 这边只保留「读某天的」和「勾选」,重复的 CRUD 删掉了。
// 留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。
// ToggleTask POST /api/tasks/:id/toggle
func (h *Handler) ToggleTask(c *gin.Context) {
task, err := h.svc.ToggleTask(middleware.UserID(c), idParam(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), idParam(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), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// DayPlan GET /api/pets/:id/day-plan?date=YYYY-MM-DD
func (h *Handler) DayPlan(c *gin.Context) {
res, err := h.svc.DayPlan(middleware.UserID(c), idParam(c, "id"), c.Query("date"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
// 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), idParam(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), idParam(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), idParam(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), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// planTaskReq 用户自己加/改的计划节点
type planTaskReq struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
SheetType string `json:"sheet_type"`
Daily bool `json:"daily"`
}
func (r planTaskReq) input() service.PlanTaskInput {
return service.PlanTaskInput{Date: r.Date, Title: r.Title,
Description: r.Description, SheetType: r.SheetType, Daily: r.Daily}
}
// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条
func (h *Handler) AddPlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
list, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
if err != nil {
respondErr(c, err)
return
}
// daily 会一次建多条,统一返回数组,前端不用分两种情况处理
response.OK(c, list)
}
// UpdatePlanTask PUT /api/plan-tasks/:id
func (h *Handler) UpdatePlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.UpdatePlanTask(middleware.UserID(c), idParam(c, "id"), req.input()); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// DeletePlanTask DELETE /api/plan-tasks/:id
func (h *Handler) DeletePlanTask(c *gin.Context) {
if err := h.svc.DeletePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// PlanMonthStatus GET /api/pets/:id/plan/month-status
// 首页「管理计划」那个提醒角标要的:本月剩几件、下月排了没有
func (h *Handler) PlanMonthStatus(c *gin.Context) {
st, err := h.svc.PlanMonthStatus(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, st)
}
// CareTemplateOptions GET /api/care-templates?species=cat&stage=幼年期
// 建档最后一步和计划页的「套用方案」都读这个
func (h *Handler) CareTemplateOptions(c *gin.Context) {
response.OK(c, h.svc.ListTemplateOptions(c.Query("species"), c.Query("stage")))
}
type applyTemplateReq struct {
TemplateID string `json:"template_id"`
MonthOffset int `json:"month_offset"` // 0 本月,1 下月
}
// ApplyTemplate POST /api/pets/:id/apply-template
func (h *Handler) ApplyTemplate(c *gin.Context) {
var req applyTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.TemplateID == "" {
response.FailParams(c, "缺少方案 id")
return
}
res, err := h.svc.ApplyTemplate(middleware.UserID(c), idParam(c, "id"), req.TemplateID, req.MonthOffset)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
-98
View File
@@ -1,98 +0,0 @@
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"`
ImageFileID string `json:"image_file_id"`
Extra datatypes.JSON `json:"extra"`
OccurredAt string `json:"occurred_at"`
}
// ListRecords GET /api/pets/:id/records?type=
func (h *Handler) ListRecords(c *gin.Context) {
var pq response.PageQuery
_ = c.ShouldBindQuery(&pq)
pq.Normalize()
records, total, err := h.svc.ListRecords(middleware.UserID(c), idParam(c, "id"), c.Query("type"), pq.Offset(), pq.Limit())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, response.NewPage(records, total, pq))
}
// 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, ImageFileID: req.ImageFileID, 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), idParam(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), idParam(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), idParam(c, "id"), 7)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, points)
}
// PetInsights GET /api/pets/:id/insights
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
func (h *Handler) PetInsights(c *gin.Context) {
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// RecordTypes GET /api/record-types 记录页可记事项,按分组归好。
// 类型从表里来而不是前端写死,后台加一种不用发版
func (h *Handler) RecordTypes(c *gin.Context) {
response.OK(c, h.svc.ListRecordTypes())
}
-88
View File
@@ -1,88 +0,0 @@
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), idParam(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.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
in.NextDueDate = &t
}
}
r, err := h.svc.CreateReminder(middleware.UserID(c), idParam(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.Type != "" {
fields["type"] = req.Type
}
if req.Title != "" {
fields["title"] = req.Title
}
if req.Frequency != "" {
fields["frequency"] = req.Frequency
}
if req.NextDueDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
fields["next_due_date"] = t
}
}
r, err := h.svc.UpdateReminder(middleware.UserID(c), idParam(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), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
-49
View File
@@ -1,49 +0,0 @@
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), idParam(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), idParam(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), idParam(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), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, poster)
}
-30
View File
@@ -1,30 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/pkg/response"
)
// Upload POST /api/upload multipart/form-data,字段名 file。
// 按 MD5 去重:相同内容直接返回已有文件,不重复上传。返回 {id,url,md5}。
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()
f, err := h.svc.UploadFile(src, fileHeader.Filename, fileHeader.Header.Get("Content-Type"))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"id": f.ID, "url": f.URL, "md5": f.MD5})
}
-76
View File
@@ -1,76 +0,0 @@
package handler
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
)
// secCallbackMsg 兼容消息推送的两种数据格式:JSON 和 XML(字段名一致,标签两套都标)
type secCallbackMsg struct {
Event string `json:"Event" xml:"Event"`
MsgType string `json:"MsgType" xml:"MsgType"`
TraceID string `json:"trace_id" xml:"trace_id"`
Result struct {
Suggest string `json:"suggest" xml:"suggest"`
} `json:"result" xml:"result"`
}
// 图片异步安全检测的结果回调(微信「消息推送」服务器配置指向这里)。
// GET 校验服务器:把 echostr 原样返回
// POST 接收 wxa_media_check 结果:用 trace_id 找回帖子/评论并放出或打回
//
// 明文模式(消息加解密方式选「明文」)下 body 就是 JSON,直接解析;
// 若配了加密模式还需要 AES 解密,这里暂不处理(上线时消息推送选明文即可)。
// verifyWxSignature 校验 signature = sha1(sort(token, timestamp, nonce))
func (h *Handler) verifyWxSignature(c *gin.Context) bool {
token := h.cfg.WeChat.MsgToken
if token == "" {
return true // 没配 token(本地调试):不校验
}
sig := c.Query("signature")
parts := []string{token, c.Query("timestamp"), c.Query("nonce")}
sort.Strings(parts)
sum := sha1.Sum([]byte(strings.Join(parts, "")))
return hex.EncodeToString(sum[:]) == sig
}
// WxSecCallback GET+POST /api/wx/sec-callback
func (h *Handler) WxSecCallback(c *gin.Context) {
if !h.verifyWxSignature(c) {
c.String(http.StatusForbidden, "invalid signature")
return
}
if c.Request.Method == http.MethodGet {
c.String(http.StatusOK, c.Query("echostr")) // 服务器配置校验
return
}
raw, _ := io.ReadAll(c.Request.Body)
// 数据格式 JSON / XML 都兼容:以 '<' 开头当 XML,否则当 JSON
var msg secCallbackMsg
var err error
if trimmed := strings.TrimSpace(string(raw)); strings.HasPrefix(trimmed, "<") {
err = xml.Unmarshal(raw, &msg)
} else {
err = json.Unmarshal(raw, &msg)
}
if err != nil {
log.Printf("[warn] sec-callback 解析失败: %v, body=%s", err, string(raw))
c.String(http.StatusOK, "success") // 回 success 让微信别重推
return
}
if msg.Event == "wxa_media_check" && msg.TraceID != "" {
h.svc.ResolveMediaCheck(msg.TraceID, msg.Result.Suggest)
}
c.String(http.StatusOK, "success")
}
+99
View File
@@ -0,0 +1,99 @@
package httpapi
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"net/http"
)
func (a *API) adminStats(c *gin.Context) {
counts := gin.H{}
for _, pair := range []struct {
name string
value any
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} {
var n int64
a.db.Model(pair.value).Count(&n)
counts[pair.name] = n
}
ok(c, counts)
}
func (a *API) adminUsers(c *gin.Context) {
var rows []model.User
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取用户失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, u := range rows {
v := a.userView(u)
v["disabled"] = u.Disabled
items = append(items, v)
}
ok(c, items)
}
func (a *API) adminPets(c *gin.Context) {
var rows []model.Pet
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, p := range rows {
v := a.petView(p)
v["user_id"] = p.UserID
v["archived"] = p.Archived
items = append(items, v)
}
ok(c, items)
}
func (a *API) adminWalks(c *gin.Context) {
var rows []model.Walk
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取遛狗失败")
return
}
ok(c, rows)
}
func (a *API) adminMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
}
ok(c, items)
}
func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct {
Disabled bool `json:"disabled"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
fail(c, 400, "参数无效")
return
}
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
if r.Error != nil || r.RowsAffected == 0 {
fail(c, 404, "用户不存在")
return
}
ok(c, gin.H{"disabled": in.Disabled})
}
func (a *API) adminSetMeetupStatus(c *gin.Context) {
var in struct {
Status string `json:"status"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
fail(c, http.StatusBadRequest, "状态无效")
return
}
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
if r.Error != nil || r.RowsAffected == 0 {
fail(c, 404, "约遛不存在")
return
}
ok(c, gin.H{"status": in.Status})
}
+174
View File
@@ -0,0 +1,174 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
func (a *API) SeedAdmin() error {
if a.cfg.Admin.Password == "" {
if a.cfg.Server.Mode == "release" {
return errors.New("admin password is required in release mode")
}
return nil
}
var admin model.Admin
err := a.db.Where("username = ?", a.cfg.Admin.Username).First(&admin).Error
missing := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !missing {
return err
}
if !missing && bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(a.cfg.Admin.Password)) == nil {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
if missing {
return a.db.Create(&model.Admin{Username: a.cfg.Admin.Username, PasswordHash: string(hash)}).Error
}
return a.db.Model(&admin).Update("password_hash", string(hash)).Error
}
func (a *API) adminLogin(c *gin.Context) {
var in struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&in); err != nil {
fail(c, http.StatusBadRequest, "请输入用户名和密码")
return
}
var admin model.Admin
if err := a.db.Where("username = ?", in.Username).First(&admin).Error; err != nil || bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(in.Password)) != nil {
fail(c, http.StatusUnauthorized, "用户名或密码错误")
return
}
token, err := a.token(admin.ID, "admin")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
}
func (a *API) devLogin(c *gin.Context) {
if !a.cfg.Auth.DevLogin || a.cfg.Server.Mode == "release" {
fail(c, http.StatusNotFound, "接口不存在")
return
}
var in struct {
Key string `json:"key" binding:"required"`
Nickname string `json:"nickname"`
}
if err := c.ShouldBindJSON(&in); err != nil || len(in.Key) > 64 {
fail(c, http.StatusBadRequest, "开发登录参数无效")
return
}
name := strings.TrimSpace(in.Nickname)
if name == "" {
name = "柯基家长"
}
user, err := a.findOrCreateUser("dev:"+in.Key, name)
if err != nil {
fail(c, http.StatusInternalServerError, "登录失败")
return
}
a.loginUser(c, user)
}
func (a *API) login(c *gin.Context) {
var in struct {
Code string `json:"code" binding:"required"`
}
if err := c.ShouldBindJSON(&in); err != nil || a.cfg.WeChat.AppID == "" || a.cfg.WeChat.AppSecret == "" {
fail(c, http.StatusBadRequest, "微信登录配置或参数无效")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
defer cancel()
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + url.Values{
"appid": {a.cfg.WeChat.AppID}, "secret": {a.cfg.WeChat.AppSecret},
"js_code": {in.Code}, "grant_type": {"authorization_code"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
fail(c, http.StatusInternalServerError, "微信登录失败")
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
fail(c, http.StatusBadGateway, "微信登录暂不可用")
return
}
defer resp.Body.Close()
var wx struct {
OpenID string `json:"openid"`
ErrCode int `json:"errcode"`
}
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&wx) != nil || wx.ErrCode != 0 || wx.OpenID == "" {
fail(c, http.StatusUnauthorized, "微信登录凭证无效")
return
}
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长")
if err != nil {
fail(c, http.StatusInternalServerError, "登录失败")
return
}
a.loginUser(c, user)
}
func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
var user model.User
err := a.db.Where("open_id = ?", openID).First(&user).Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return user, err
}
user = model.User{OpenID: openID, Nickname: nickname}
if err := a.db.Create(&user).Error; err != nil {
// A concurrent login may have inserted the same open_id.
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil {
return user, nil
}
return user, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (a *API) loginUser(c *gin.Context, user model.User) {
if user.Disabled {
fail(c, http.StatusForbidden, "账号已停用")
return
}
token, err := a.token(user.ID, "user")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
ok(c, gin.H{"token": token, "user": a.userView(user)})
}
func (a *API) me(c *gin.Context) {
var user model.User
if err := a.db.First(&user, "id = ?", actor(c)).Error; err != nil {
fail(c, http.StatusNotFound, "用户不存在")
return
}
ok(c, a.userView(user))
}
+140
View File
@@ -0,0 +1,140 @@
package httpapi
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
)
func (a *API) fileExists(id string) bool {
if !validID(id) {
return false
}
var count int64
a.db.Model(&model.File{}).Where("id = ?", id).Count(&count)
return count == 1
}
const maxUploadSize = 10 << 20
func (a *API) uploadFile(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+1024)
header, err := c.FormFile("file")
if err != nil || header.Size <= 0 || header.Size > maxUploadSize {
fail(c, http.StatusBadRequest, "请选择不超过 10 MB 的图片")
return
}
reader, err := header.Open()
if err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
defer reader.Close()
signature := make([]byte, 512)
n, _ := io.ReadFull(reader, signature)
contentType := http.DetectContentType(signature[:n])
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
ext, allowed := extensions[contentType]
if !allowed {
fail(c, http.StatusBadRequest, "仅支持 JPG、PNG、WebP 图片")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
hasher := md5.New()
if _, err := io.Copy(hasher, reader); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
digest := hex.EncodeToString(hasher.Sum(nil))
var existing model.File
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil {
if existing.Size != header.Size {
fail(c, http.StatusConflict, "文件校验冲突")
return
}
ok(c, a.fileView(existing))
return
}
key := "files/" + digest[:2] + "/" + digest + ext
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
if err := a.storage.Put(ctx, key, reader, header.Size, contentType); err != nil {
fail(c, http.StatusBadGateway, "上传对象存储失败")
return
}
file := model.File{
Bucket: a.storage.Bucket(), ObjectKey: key, MD5: digest, URL: a.storage.PublicURL(key),
Name: strings.TrimSpace(header.Filename), ContentType: contentType,
Size: header.Size,
}
if err := a.db.Create(&file).Error; err != nil {
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil && existing.Size == header.Size {
ok(c, a.fileView(existing))
return
}
fail(c, http.StatusInternalServerError, "保存文件信息失败")
return
}
created(c, a.fileView(file))
}
func (a *API) getFile(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, http.StatusBadRequest, "文件 ID 无效")
return
}
var file model.File
if err := a.db.Where("id = ?", c.Param("id")).First(&file).Error; err != nil {
fail(c, http.StatusNotFound, "文件不存在")
return
}
ok(c, a.fileView(file))
}
func (a *API) fileView(file model.File) gin.H {
return gin.H{"id": file.ID, "name": file.Name, "content_type": file.ContentType,
"size": file.Size, "md5": file.MD5, "url": file.URL}
}
func (a *API) fileURL(id *string) string {
if id == nil || !validID(*id) {
return ""
}
var file model.File
if a.db.Select("url").First(&file, "id = ?", *id).Error != nil {
return ""
}
return file.URL
}
func (a *API) userView(user model.User) gin.H {
return gin.H{"id": user.ID, "nickname": user.Nickname, "avatar_file_id": user.AvatarFileID,
"avatar_url": a.fileURL(user.AvatarFileID), "created_at": user.CreatedAt}
}
func (a *API) petView(pet model.Pet) gin.H {
var birthDate string
if pet.BirthDate != nil {
birthDate = pet.BirthDate.Format("2006-01-02")
}
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
"created_at": pet.CreatedAt}
}
@@ -0,0 +1,38 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
)
func TestLoginRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
New(nil, nil, &config.Config{}).Register(router)
for _, route := range router.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
t.Errorf("API route %q does not have /api/ prefix", route.Path)
}
}
for _, test := range []struct {
path string
want int
}{
{path: "/api/auth/login", want: http.StatusBadRequest},
{path: "/api/auth/wechat", want: http.StatusNotFound},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != test.want {
t.Errorf("POST %s: got %d, want %d", test.path, recorder.Code, test.want)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) meetupView(m model.Meetup) gin.H {
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
return gin.H{"id": m.ID, "host_user_id": m.HostUserID, "title": m.Title, "description": m.Description, "place_name": m.PlaceName, "latitude": m.Latitude, "longitude": m.Longitude, "starts_at": m.StartsAt, "max_people": m.MaxPeople, "status": m.Status, "participants": count, "cover_file_id": m.CoverFileID, "cover_url": a.fileURL(m.CoverFileID)}
}
func (a *API) listMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Where("starts_at > ? AND status = ?", time.Now().Add(-time.Hour), "open").Order("starts_at ASC").Limit(100).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
}
ok(c, items)
}
func (a *API) getMeetup(c *gin.Context) {
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
view := a.meetupView(m)
var joins []model.MeetupParticipant
a.db.Where("meetup_id = ?", m.ID).Find(&joins)
people := make([]gin.H, 0, len(joins))
for _, j := range joins {
var pets []model.MeetupPet
a.db.Where("participant_id = ?", j.ID).Find(&pets)
ids := []string{}
for _, p := range pets {
ids = append(ids, p.PetID)
}
people = append(people, gin.H{"user_id": j.UserID, "pet_ids": ids})
}
view["people"] = people
ok(c, view)
}
type meetupInput struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
PlaceName string `json:"place_name" binding:"required"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
StartsAt time.Time `json:"starts_at" binding:"required"`
MaxPeople int `json:"max_people"`
PetIDs []string `json:"pet_ids" binding:"required"`
CoverFileID *string `json:"cover_file_id"`
}
func (a *API) createMeetup(c *gin.Context) {
var in meetupInput
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.PlaceName) == "" || !in.StartsAt.After(time.Now()) || len(in.PetIDs) == 0 {
fail(c, 400, "约遛资料无效")
return
}
if in.MaxPeople == 0 {
in.MaxPeople = 8
}
if in.MaxPeople < 2 || in.MaxPeople > 100 {
fail(c, 400, "人数应为 2 到 100")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
if in.CoverFileID != nil && !a.fileExists(*in.CoverFileID) {
fail(c, 400, "封面文件无效")
return
}
m := model.Meetup{HostUserID: actor(c), Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), PlaceName: strings.TrimSpace(in.PlaceName), Latitude: in.Latitude, Longitude: in.Longitude, StartsAt: in.StartsAt, MaxPeople: in.MaxPeople, Status: "open", CoverFileID: in.CoverFileID}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "发布失败")
return
}
created(c, a.meetupView(m))
}
func (a *API) joinMeetup(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请选择宠物")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
if m.Status != "open" || !m.StartsAt.After(time.Now()) {
fail(c, 409, "约遛已关闭")
return
}
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
if count >= int64(m.MaxPeople) {
fail(c, 409, "人数已满")
return
}
var existing int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ? AND user_id = ?", m.ID, actor(c)).Count(&existing)
if existing > 0 {
fail(c, 409, "已经参加")
return
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "报名失败")
return
}
ok(c, a.meetupView(m))
}
func (a *API) leaveMeetup(c *gin.Context) {
var j model.MeetupParticipant
if !validID(c.Param("id")) || a.db.Where("meetup_id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&j).Error != nil {
fail(c, 404, "未参加")
return
}
var m model.Meetup
a.db.First(&m, "id = ?", j.MeetupID)
if m.HostUserID == actor(c) {
fail(c, http.StatusConflict, "发起人请取消约遛")
return
}
if a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Where("participant_id = ?", j.ID).Delete(&model.MeetupPet{}).Error; e != nil {
return e
}
return tx.Delete(&j).Error
}) != nil {
fail(c, 500, "退出失败")
return
}
ok(c, gin.H{"left": true})
}
func (a *API) cancelMeetup(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "ID 无效")
return
}
result := a.db.Model(&model.Meetup{}).Where("id = ? AND host_user_id = ? AND status = ?", c.Param("id"), actor(c), "open").Update("status", "cancelled")
if result.Error != nil || result.RowsAffected == 0 {
fail(c, 404, "约遛不存在或已关闭")
return
}
ok(c, gin.H{"status": "cancelled"})
}
+149
View File
@@ -0,0 +1,149 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
type petInput struct {
Name string `json:"name" binding:"required"`
Species string `json:"species"`
Breed string `json:"breed"`
Gender string `json:"gender"`
BirthDate string `json:"birth_date"`
WeightKg float64 `json:"weight_kg"`
AvatarFileID *string `json:"avatar_file_id"`
Coat string `json:"coat"`
Allergies string `json:"allergies"`
CareNotes string `json:"care_notes"`
}
func (a *API) listPets(c *gin.Context) {
var pets []model.Pet
if err := a.db.Where("user_id = ? AND archived = false", actor(c)).Order("created_at ASC").Find(&pets).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(pets))
for _, pet := range pets {
items = append(items, a.petView(pet))
}
ok(c, items)
}
func (a *API) getPet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
ok(c, a.petView(pet))
}
func (a *API) createPet(c *gin.Context) {
var in petInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "宠物资料无效")
return
}
pet, valid := a.buildPet(c, in)
if !valid {
return
}
pet.UserID = actor(c)
if err := a.db.Create(&pet).Error; err != nil {
fail(c, http.StatusInternalServerError, "创建宠物失败")
return
}
created(c, a.petView(pet))
}
func (a *API) updatePet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
var in petInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "宠物资料无效")
return
}
updated, valid := a.buildPet(c, in)
if !valid {
return
}
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
if err := a.db.Save(&pet).Error; err != nil {
fail(c, http.StatusInternalServerError, "更新宠物失败")
return
}
ok(c, a.petView(pet))
}
func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
pet := model.Pet{}
in.Name = strings.TrimSpace(in.Name)
if in.Name == "" || len([]rune(in.Name)) > 64 || in.WeightKg < 0 || in.WeightKg > 200 {
fail(c, http.StatusBadRequest, "名字或体重无效")
return pet, false
}
if in.Species == "" {
in.Species = "dog"
}
if in.Species != "dog" && in.Species != "cat" && in.Species != "other" {
fail(c, http.StatusBadRequest, "宠物类型无效")
return pet, false
}
if in.AvatarFileID != nil {
var count int64
if !validID(*in.AvatarFileID) || a.db.Model(&model.File{}).Where("id = ?", *in.AvatarFileID).Count(&count).Error != nil || count == 0 {
fail(c, http.StatusBadRequest, "头像文件无效")
return pet, false
}
}
var birthday *time.Time
if in.BirthDate != "" {
d, err := parseDay(in.BirthDate)
if err != nil || d.After(time.Now()) {
fail(c, http.StatusBadRequest, "生日无效")
return pet, false
}
birthday = &d
}
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
return pet, true
}
func (a *API) archivePet(c *gin.Context) {
pet, err := a.ownedPet(actor(c), c.Param("id"))
if err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
var active int64
if err := a.db.Table("sundynix_walk_pet AS wp").Joins("JOIN sundynix_walk AS w ON w.id = wp.walk_id").
Where("wp.pet_id = ? AND w.status IN ?", pet.ID, []string{"active", "paused"}).Count(&active).Error; err != nil {
fail(c, http.StatusInternalServerError, "检查遛狗状态失败")
return
}
if active > 0 {
fail(c, http.StatusConflict, "这只宠物正在遛狗,结束后再归档")
return
}
if err := a.db.Model(&model.Pet{}).Where("id = ? AND user_id = ?", pet.ID, actor(c)).Update("archived", true).Error; err != nil && err != gorm.ErrRecordNotFound {
fail(c, http.StatusInternalServerError, "归档失败")
return
}
ok(c, gin.H{"archived": true})
}
+160
View File
@@ -0,0 +1,160 @@
package httpapi
import (
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/storage"
)
type API struct {
db *gorm.DB
storage *storage.Storage
cfg *config.Config
}
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
return &API{db: db, storage: storage, cfg: cfg}
}
func (a *API) Register(r *gin.Engine) {
r.Use(gin.Logger(), gin.Recovery())
_ = r.SetTrustedProxies(nil)
api := r.Group("/api")
api.GET("/ping", func(c *gin.Context) { ok(c, gin.H{"pong": true}) })
api.GET("/share/walk/:token", a.publicWalkLocation)
api.POST("/auth/login", a.login)
api.POST("/auth/dev", a.devLogin)
api.POST("/admin/login", a.adminLogin)
user := api.Group("")
user.Use(a.requireRole("user"))
user.GET("/me", a.me)
user.GET("/pets", a.listPets)
user.POST("/pets", a.createPet)
user.GET("/pets/:id", a.getPet)
user.PUT("/pets/:id", a.updatePet)
user.DELETE("/pets/:id", a.archivePet)
user.POST("/files", a.uploadFile)
user.GET("/files/:id", a.getFile)
user.GET("/tasks", a.listTasks)
user.POST("/tasks", a.createTask)
user.PUT("/tasks/:id", a.updateTask)
user.DELETE("/tasks/:id", a.deleteTask)
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
user.GET("/walks", a.listWalks)
user.POST("/walks", a.startWalk)
user.GET("/walks/:id", a.getWalk)
user.POST("/walks/:id/points", a.addWalkPoint)
user.POST("/walks/:id/pause", a.pauseWalk)
user.POST("/walks/:id/resume", a.resumeWalk)
user.POST("/walks/:id/finish", a.finishWalk)
user.PUT("/walks/:id/share", a.setWalkShare)
user.GET("/meetups", a.listMeetups)
user.POST("/meetups", a.createMeetup)
user.GET("/meetups/:id", a.getMeetup)
user.POST("/meetups/:id/join", a.joinMeetup)
user.DELETE("/meetups/:id/join", a.leaveMeetup)
user.POST("/meetups/:id/cancel", a.cancelMeetup)
admin := api.Group("/admin")
admin.Use(a.requireRole("admin"))
admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers)
admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks)
admin.GET("/meetups", a.adminMeetups)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
}
type claims struct {
Role string `json:"role"`
jwt.RegisteredClaims
}
func (a *API) token(id, role string) (string, error) {
expire := a.cfg.JWT.ExpireHours
if expire <= 0 {
expire = 168
}
now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
}})
return t.SignedString([]byte(a.cfg.JWT.Secret))
}
func (a *API) requireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
fail(c, http.StatusUnauthorized, "请先登录")
c.Abort()
return
}
t, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), &claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, errors.New("unexpected signing method")
}
return []byte(a.cfg.JWT.Secret), nil
})
if err != nil || !t.Valid {
fail(c, http.StatusUnauthorized, "登录已过期")
c.Abort()
return
}
cl, valid := t.Claims.(*claims)
if !valid || cl.Role != role || uuid.Validate(cl.Subject) != nil {
fail(c, http.StatusForbidden, "没有访问权限")
c.Abort()
return
}
if role == "user" {
var user model.User
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
fail(c, http.StatusForbidden, "账号不可用")
c.Abort()
return
}
}
c.Set("actor_id", cl.Subject)
c.Next()
}
}
func actor(c *gin.Context) string { return c.GetString("actor_id") }
func ok(c *gin.Context, data any) {
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data})
}
func created(c *gin.Context, data any) {
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": data})
}
func fail(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"code": status, "message": message})
}
func validID(id string) bool { return uuid.Validate(id) == nil }
func parseDay(value string) (time.Time, error) {
return time.ParseInLocation("2006-01-02", value, time.Local)
}
func (a *API) ownedPet(userID, petID string) (model.Pet, error) {
var pet model.Pet
if !validID(petID) {
return pet, gorm.ErrRecordNotFound
}
err := a.db.Where("id = ? AND user_id = ? AND archived = false", petID, userID).First(&pet).Error
return pet, err
}
+211
View File
@@ -0,0 +1,211 @@
package httpapi
import (
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
type taskInput struct {
PetID string `json:"pet_id" binding:"required"`
Title string `json:"title" binding:"required"`
Category string `json:"category"`
TimeOfDay string `json:"time_of_day"`
RepeatRule string `json:"repeat_rule"`
ReminderEnabled bool `json:"reminder_enabled"`
}
func due(task model.CareTask, date time.Time) bool {
switch task.RepeatRule {
case "daily":
return true
case "weekdays":
return date.Weekday() != time.Saturday && date.Weekday() != time.Sunday
case "weekends":
return date.Weekday() == time.Saturday || date.Weekday() == time.Sunday
default:
return false
}
}
func (a *API) listTasks(c *gin.Context) {
dateString := c.DefaultQuery("date", time.Now().Format("2006-01-02"))
date, err := parseDay(dateString)
if err != nil {
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
return
}
query := a.db.Where("user_id = ?", actor(c))
if petID := c.Query("pet_id"); petID != "" {
if _, err := a.ownedPet(actor(c), petID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return
}
query = query.Where("pet_id = ?", petID)
}
var tasks []model.CareTask
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取任务失败")
return
}
var completions []model.TaskCompletion
if err := a.db.Where("user_id = ? AND date = ?", actor(c), date).Find(&completions).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取完成状态失败")
return
}
done := make(map[string]bool, len(completions))
for _, item := range completions {
done[item.TaskID] = true
}
items := make([]gin.H, 0, len(tasks))
for _, task := range tasks {
if due(task, date) {
items = append(items, taskView(task, done[task.ID]))
}
}
ok(c, gin.H{"date": dateString, "items": items})
}
func taskView(task model.CareTask, completed bool) gin.H {
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
"category": task.Category, "time_of_day": task.TimeOfDay,
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
"completed": completed}
}
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
task := model.CareTask{}
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
}
in.Title = strings.TrimSpace(in.Title)
if in.Title == "" || len([]rune(in.Title)) > 128 {
fail(c, http.StatusBadRequest, "任务名称无效")
return task, false
}
if in.Category == "" {
in.Category = "other"
}
if in.TimeOfDay == "" {
in.TimeOfDay = "09:00"
}
if !clockPattern.MatchString(in.TimeOfDay) {
fail(c, http.StatusBadRequest, "时间格式应为 HH:MM")
return task, false
}
if in.RepeatRule == "" {
in.RepeatRule = "daily"
}
if in.RepeatRule != "daily" && in.RepeatRule != "weekdays" && in.RepeatRule != "weekends" {
fail(c, http.StatusBadRequest, "重复规则无效")
return task, false
}
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
ReminderEnabled: in.ReminderEnabled}
return task, true
}
func (a *API) createTask(c *gin.Context) {
var in taskInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "任务资料无效")
return
}
task, valid := a.buildTask(c, in)
if !valid {
return
}
if err := a.db.Create(&task).Error; err != nil {
fail(c, http.StatusInternalServerError, "添加任务失败")
return
}
created(c, taskView(task, false))
}
func (a *API) updateTask(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in taskInput
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "任务资料无效")
return
}
updated, valid := a.buildTask(c, in)
if !valid {
return
}
task.PetID, task.Title, task.Category = updated.PetID, updated.Title, updated.Category
task.TimeOfDay, task.RepeatRule, task.ReminderEnabled = updated.TimeOfDay, updated.RepeatRule, updated.ReminderEnabled
if a.db.Save(&task).Error != nil {
fail(c, http.StatusInternalServerError, "更新任务失败")
return
}
ok(c, taskView(task, false))
}
func (a *API) deleteTask(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, http.StatusBadRequest, "任务 ID 无效")
return
}
err := a.db.Transaction(func(tx *gorm.DB) error {
var task model.CareTask
if err := tx.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error; err != nil {
return err
}
if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskCompletion{}).Error; err != nil {
return err
}
return tx.Delete(&task).Error
})
if err != nil {
fail(c, http.StatusNotFound, "任务不存在或删除失败")
return
}
ok(c, gin.H{"deleted": true})
}
func (a *API) setTaskCompletion(c *gin.Context) {
var task model.CareTask
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
fail(c, http.StatusNotFound, "任务不存在")
return
}
var in struct {
Date string `json:"date" binding:"required"`
Completed bool `json:"completed"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "完成状态参数无效")
return
}
date, err := parseDay(in.Date)
if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务")
return
}
if in.Completed {
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
fail(c, http.StatusInternalServerError, "保存完成状态失败")
return
}
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
fail(c, http.StatusInternalServerError, "取消完成失败")
return
}
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
}
+254
View File
@@ -0,0 +1,254 @@
package httpapi
import (
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) walk(c *gin.Context) (model.Walk, bool) {
var w model.Walk
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&w).Error != nil {
fail(c, 404, "遛狗记录不存在")
return w, false
}
return w, true
}
func (a *API) walkView(w model.Walk) gin.H {
var links []model.WalkPet
var points []model.WalkPoint
a.db.Where("walk_id = ?", w.ID).Find(&links)
a.db.Where("walk_id = ?", w.ID).Order("recorded_at ASC").Find(&points)
ids := make([]string, 0, len(links))
for _, p := range links {
ids = append(ids, p.PetID)
}
return gin.H{"id": w.ID, "pet_ids": ids, "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "paused_at": w.PausedAt, "paused_seconds": w.PausedSeconds, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled, "share_token": func() string {
if w.ShareEnabled {
return w.ShareToken
}
return ""
}(), "share_expires_at": w.ShareExpiresAt, "points": points}
}
func (a *API) listWalks(c *gin.Context) {
var walks []model.Walk
if a.db.Where("user_id = ?", actor(c)).Order("started_at DESC").Limit(50).Find(&walks).Error != nil {
fail(c, 500, "读取记录失败")
return
}
items := make([]gin.H, 0, len(walks))
for _, w := range walks {
items = append(items, a.walkView(w))
}
ok(c, items)
}
func (a *API) getWalk(c *gin.Context) {
if w, yes := a.walk(c); yes {
ok(c, a.walkView(w))
}
}
func (a *API) startWalk(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请至少选择一只宠物")
return
}
seen := map[string]bool{}
for _, id := range in.PetIDs {
if seen[id] {
fail(c, 400, "宠物重复")
return
}
seen[id] = true
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "选择的宠物不存在")
return
}
}
var active int64
a.db.Model(&model.Walk{}).Where("user_id = ? AND status IN ?", actor(c), []string{"active", "paused"}).Count(&active)
if active > 0 {
fail(c, 409, "已有正在进行的遛狗")
return
}
now := time.Now()
w := model.Walk{UserID: actor(c), Status: "active", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&w).Error; err != nil {
return err
}
for _, id := range in.PetIDs {
if err := tx.Create(&model.WalkPet{WalkID: w.ID, PetID: id}).Error; err != nil {
return err
}
}
return nil
}); err != nil {
fail(c, 500, "开始遛狗失败")
return
}
created(c, a.walkView(w))
}
func (a *API) addWalkPoint(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "遛狗未进行中")
return
}
var in struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if c.ShouldBindJSON(&in) != nil || math.Abs(in.Latitude) > 90 || math.Abs(in.Longitude) > 180 || (in.Latitude == 0 && in.Longitude == 0) {
fail(c, 400, "位置无效")
return
}
point := model.WalkPoint{WalkID: w.ID, Latitude: in.Latitude, Longitude: in.Longitude, RecordedAt: time.Now()}
var last model.WalkPoint
err := a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&last).Error
if err == nil && !last.RecordedAt.Before(w.SegmentStartedAt) {
d := haversine(last.Latitude, last.Longitude, in.Latitude, in.Longitude)
if d > 1000 {
fail(c, 400, "位置跳变过大")
return
}
if d < 5 {
ok(c, a.walkView(w))
return
}
w.DistanceMeters += d
}
if txErr := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&point).Error; e != nil {
return e
}
return tx.Model(&w).Update("distance_meters", w.DistanceMeters).Error
}); txErr != nil {
fail(c, 500, "保存位置失败")
return
}
ok(c, a.walkView(w))
}
func haversine(a, b, c, d float64) float64 {
const r = 6371000.
x1, x2 := a*math.Pi/180, c*math.Pi/180
dx, dy := (c-a)*math.Pi/180, (d-b)*math.Pi/180
h := math.Pow(math.Sin(dx/2), 2) + math.Cos(x1)*math.Cos(x2)*math.Pow(math.Sin(dy/2), 2)
return 2 * r * math.Asin(math.Sqrt(h))
}
func (a *API) pauseWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "当前不能暂停")
return
}
now := time.Now()
w.Status = "paused"
w.PausedAt = &now
if a.db.Save(&w).Error != nil {
fail(c, 500, "暂停失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) resumeWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "paused" || w.PausedAt == nil {
fail(c, 409, "当前不能继续")
return
}
now := time.Now()
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
w.SegmentStartedAt = now
w.Status = "active"
if a.db.Save(&w).Error != nil {
fail(c, 500, "继续失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) finishWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束")
return
}
now := time.Now()
if w.PausedAt != nil {
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
}
w.Status = "finished"
w.EndedAt = &now
w.DurationSeconds = int(now.Sub(w.StartedAt).Seconds()) - w.PausedSeconds
w.ShareEnabled = false
w.ShareExpiresAt = nil
if a.db.Save(&w).Error != nil {
fail(c, 500, "结束遛狗失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) setWalkShare(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束的遛狗不能分享")
return
}
var in struct {
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, 400, "参数无效")
return
}
w.ShareEnabled = in.Enabled
if in.Enabled {
expire := time.Now().Add(4 * time.Hour)
w.ShareExpiresAt = &expire
w.ShareToken = uuid.NewString()
} else {
w.ShareExpiresAt = nil
}
if a.db.Save(&w).Error != nil {
fail(c, 500, "分享设置失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) publicWalkLocation(c *gin.Context) {
var w model.Walk
if !validID(c.Param("token")) || a.db.Where("share_token = ? AND share_enabled = true AND status IN ? AND share_expires_at > ?", c.Param("token"), []string{"active", "paused"}, time.Now()).First(&w).Error != nil {
fail(c, http.StatusNotFound, "分享已失效")
return
}
var p model.WalkPoint
if a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&p).Error != nil {
fail(c, 404, "暂无位置")
return
}
ok(c, gin.H{"latitude": p.Latitude, "longitude": p.Longitude, "recorded_at": p.RecordedAt, "status": w.Status})
}
-77
View File
@@ -1,77 +0,0 @@
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) string {
if v, ok := c.Get(CtxUserID); ok {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
// AdminID 从上下文取当前管理员 ID
func AdminID(c *gin.Context) string {
if v, ok := c.Get(CtxAdminID); ok {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
-28
View File
@@ -1,28 +0,0 @@
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
@@ -1,10 +0,0 @@
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
@@ -1,11 +0,0 @@
package model
// AIMessage AI 聊天消息
type AIMessage struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" 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"`
}
-20
View File
@@ -1,20 +0,0 @@
package model
// AIQuotaConfig 每日 AI 次数上限(单例,固定 id=1)。
// AI 调用是真金白银,不设上限等于把钱包交给用户。
type AIQuotaConfig struct {
Base
Enabled bool `json:"enabled"` // 关掉即不限次
DailyChat int `json:"daily_chat"` // 问问 AI,每人每天
DailySymptom int `json:"daily_symptom"` // 异常观察评估
DailyPlan int `json:"daily_plan"` // AI 生成计划
}
// AIUsage 某人某天某类 AI 的用量。按天存,天然过期,不需要清理任务。
type AIUsage struct {
Base
UserID string `gorm:"size:24;uniqueIndex:idx_user_day_kind" json:"user_id"`
Day string `gorm:"size:10;uniqueIndex:idx_user_day_kind" json:"day"` // YYYY-MM-DD
Kind string `gorm:"size:16;uniqueIndex:idx_user_day_kind" json:"kind"`
Count int `json:"count"`
}
-13
View File
@@ -1,13 +0,0 @@
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"`
}
-57
View File
@@ -1,57 +0,0 @@
package model
import (
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/pkg/idgen"
)
// Base 所有模型的公共字段。主键为雪花算法字符串 ID(非自增)。
type Base struct {
ID string `gorm:"primaryKey;size:24" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 未显式赋值时自动生成雪花 ID(通过嵌入对所有模型生效)。
func (b *Base) BeforeCreate(tx *gorm.DB) error {
if b.ID == "" {
b.ID = idgen.Next()
}
return nil
}
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
func AllModels() []any {
return []any{
&User{},
&Pet{},
&HealthRecord{},
&RecordType{},
&Breed{},
&DailyTask{},
&Plan{},
&PlanTask{},
&Reminder{},
&Post{},
&Comment{},
&PostLike{},
&Article{},
&AIMessage{},
&ProMembership{},
&Admin{},
&DailyAdvice{},
&CareTemplate{},
&CareTemplateItem{},
&CommunityBotConfig{},
&File{},
&Feedback{},
&Follow{},
&RefreshToken{},
&AIQuotaConfig{},
&AIUsage{},
&MediaCheck{},
}
}
-26
View File
@@ -1,26 +0,0 @@
package model
// Breed 品种。原来建档时品种是个自由文本输入框,用户手打「柯基」「柯基犬」
// 「威尔士柯基」都算不同品种,后面想按品种做体重基准、常见病提示这类事就没法做。
type Breed struct {
Base
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Name string `gorm:"size:64" json:"name"`
// Initial 首字母,A-Z,用来做侧边索引。
//
// 存字段而不是运行时算:Go 标准库没有拼音,引一个拼音库只为了取首字母不值得,
// 而且多音字(「柴犬」的柴、「藏獒」的藏)自动转常出错——「藏獒」按 cang
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
Initial string `gorm:"size:1;index" json:"initial"`
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
//
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
SizeClass string `gorm:"size:16;index" json:"size_class"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
}
-77
View File
@@ -1,77 +0,0 @@
package model
// 养护方案:一套「这个物种、这个阶段该做哪些事」的配置。
// 后台可以给同一个 (species, stage) 配多套,建档时让用户挑一套套用,
// 也可以不套用、自己排。原来是一个组合只能有一套(唯一索引卡着),
// 「让用户选模版」这件事在数据模型上做不到。
// 物种取值
const (
SpeciesCat = "cat"
SpeciesDog = "dog"
)
// 明细的类别。原来还有个 task(今日任务),已经和 plan 合并——
// 「今天要做的事」就是「今天那一天的计划节点」,两套并存的结果是
// 用户在计划页加的「每天喂药」在首页看不到。
const (
CareKindPlan = "plan" // 计划节点(含每天/每周/每月重复)
CareKindReminder = "reminder" // 到期提醒(疫苗、驱虫这类有固定周期的)
)
// 重复规则。月度计划要的是「多久做一次」,不是「第几天做」——
// 原来只有 Day 偏移,月底建档的话 day=14/21/30 全落到下个月,
// 用户建完档打开计划页发现本月是空的。
const (
RepeatOnce = "once" // 一次性,按 Day 偏移(到家第 N 天,疫苗序列要用)
RepeatDaily = "daily" // 每天
RepeatWeekly = "weekly" // 每周某几天,看 Weekdays
RepeatMonthly = "monthly" // 每月某天,看 MonthDay
)
// CareTemplate 一套方案
type CareTemplate struct {
Base
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Stage string `gorm:"size:48;index" json:"stage"` // 与 onboarding 阶段一致
// Name 方案名。建档时用户看到的就是这个,要能一眼分辨
// (「幼猫标准照护」/「幼猫省心版」)
Name string `gorm:"size:64" json:"name"`
// Summary 一句话摘要,选择列表里展示
Summary string `gorm:"size:255" json:"summary"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
}
// CareTemplateItem 明细,kind 区分两类;不同类只用到部分字段
type CareTemplateItem struct {
Base
TemplateID string `gorm:"size:24;index" json:"template_id"`
Kind string `gorm:"size:16;index" json:"kind"` // plan | reminder
Sort int `json:"sort"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
// plan 专用
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录类型 code:weight/poop/bath...
// Repeat 列名用 repeat_rule:repeat 是 MySQL 保留字,GORM 虽然会加反引号,
// 但手写 SQL 排查时很容易踩(和 group_key 同样的理由)
Repeat string `gorm:"column:repeat_rule;size:16" json:"repeat"` // once | daily | weekly | monthly
Day int `json:"day"` // once:从套用日起第 N 天(0=当天)
// DayLabel 留着:后台的 AI 生成模板那条链路在产出它。月度计划里日期是绝对的
// 不需要「第 N 天」标签,但删它要连着改 AI 解析,不在这次范围内
DayLabel string `gorm:"size:32" json:"day_label"`
// Weekdays weekly 用:逗号分隔的 1-7(1=周一)。
// 存字符串不另开表——一条模板明细最多 7 个数字,开表换来的只是查询灵活性,
// 而这份数据从来只整条读出来展开
Weekdays string `gorm:"size:16" json:"weekdays"`
MonthDay int `json:"month_day"` // monthly 用:几号(1-31),超出当月天数按月末算
// reminder 专用
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
OffsetDays int `json:"offset_days"` // 到期 = 今天 + N 天;<=0 表示无固定到期,用频率
Frequency string `gorm:"size:64" json:"frequency"` // 频率描述,如「每周二、周五」
}
-23
View File
@@ -1,23 +0,0 @@
package model
import "gorm.io/datatypes"
// Comment 帖子评论
type Comment struct {
Base
PostID string `gorm:"size:24;index" json:"post_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Images datatypes.JSON `json:"images"`
// 两级评论:ParentID 为空是一级评论,否则是某条一级评论下的回复。
// 不做无限层级——手机上层层缩进读到第四层就没法看了,
// 主流产品(微信、小红书、B站)都是两级 + @某人 表达指向。
ParentID string `gorm:"size:24;index" json:"parent_id"`
ReplyToName string `gorm:"size:64" json:"reply_to_name"`
Status string `gorm:"size:16;default:published" json:"status"`
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
Replies []Comment `gorm:"-" json:"replies"` // 计算字段:一级评论下挂的回复
ReplyN int `gorm:"-" json:"reply_n"` // 回复总数(可能多于返回的条数)
}
-10
View File
@@ -1,10 +0,0 @@
package model
// CommunityBotConfig 社区 AI 运营配置(单例,固定 id=1)
type CommunityBotConfig struct {
Base
Enabled bool `json:"enabled"` // 是否开启每日自动生成
DailyCount int `json:"daily_count"` // 每天生成条数
StartHour int `json:"start_hour"` // 白天投放窗口起(含)
EndHour int `json:"end_hour"` // 白天投放窗口止(不含)
}
-9
View File
@@ -1,9 +0,0 @@
package model
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
type DailyAdvice struct {
Base
PetID string `gorm:"size:24;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
@@ -1,17 +0,0 @@
package model
import "time"
// DailyTask 今日任务
type DailyTask struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
TaskDate time.Time `gorm:"size:24;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"`
}
-11
View File
@@ -1,11 +0,0 @@
package model
// Feedback 用户意见反馈
type Feedback struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Nickname string `gorm:"size:64" json:"nickname"`
Content string `gorm:"type:text" json:"content"`
Contact string `gorm:"size:128" json:"contact"` // 可选,方便回访
Handled bool `gorm:"index" json:"handled"`
}
-12
View File
@@ -1,12 +0,0 @@
package model
// File 上传文件表:图片/头像等统一入库,按 MD5 去重,使用处以 file_id 关联。
type File struct {
Base
MD5 string `gorm:"size:32;uniqueIndex" json:"md5"` // 内容 MD5,唯一,用于去重
ObjectName string `gorm:"size:255" json:"object_name"` // MinIO 对象名
URL string `gorm:"size:512" json:"url"` // 可访问 URL
Size int64 `json:"size"`
ContentType string `gorm:"size:128" json:"content_type"`
Ext string `gorm:"size:16" json:"ext"`
}
-8
View File
@@ -1,8 +0,0 @@
package model
// Follow 关注关系:FollowerID 关注了 FolloweeID
type Follow struct {
Base
FollowerID string `gorm:"size:24;uniqueIndex:idx_follower_followee" json:"follower_id"`
FolloweeID string `gorm:"size:24;uniqueIndex:idx_follower_followee;index" json:"followee_id"`
}
-38
View File
@@ -1,38 +0,0 @@
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 string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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"`
ImageFileID string `gorm:"size:24" json:"image_file_id"` // 关联 files 表:记录附图
Extra datatypes.JSON `json:"extra"`
OccurredAt time.Time `gorm:"size:24;index" json:"occurred_at"`
}
-17
View File
@@ -1,17 +0,0 @@
package model
// 图片异步安全检测的一次提交。mediaCheckAsync 返回 trace_id,
// 结果稍后由微信 push 回调,用 trace_id 找回它属于哪条帖子/评论。
const (
MediaPending = "pending"
MediaPass = "pass"
MediaRisky = "risky"
)
type MediaCheck struct {
Base
TraceID string `gorm:"size:64;index" json:"trace_id"`
TargetType string `gorm:"size:16;index" json:"target_type"` // post | comment
TargetID string `gorm:"size:24;index" json:"target_id"`
Status string `gorm:"size:16" json:"status"` // pending | pass | risky
}
+146
View File
@@ -0,0 +1,146 @@
package model
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Base gives every persisted model a PostgreSQL uuid primary key.
// GORM's naming strategy maps Go fields to snake_case columns.
type Base struct {
ID string `gorm:"type:uuid;primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) error {
if b.ID == "" {
b.ID = uuid.NewString()
}
return nil
}
type File struct {
Base
Bucket string `gorm:"size:128;not null" json:"bucket"`
ObjectKey string `gorm:"size:512;uniqueIndex;not null" json:"object_key"`
MD5 string `gorm:"column:md5;size:32;uniqueIndex;not null" json:"md5"`
URL string `gorm:"size:1024;not null" json:"url"`
Name string `gorm:"size:255;not null" json:"name"`
ContentType string `gorm:"size:128;not null" json:"content_type"`
Size int64 `gorm:"not null" json:"size"`
}
type User struct {
Base
OpenID string `gorm:"size:128;uniqueIndex;not null" json:"-"`
Nickname string `gorm:"size:64;not null" json:"nickname"`
AvatarFileID *string `gorm:"type:uuid;index" json:"avatar_file_id"`
Disabled bool `gorm:"not null;default:false" json:"disabled"`
}
type Admin struct {
Base
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
}
type Pet struct {
Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
Name string `gorm:"size:64;not null" json:"name"`
Species string `gorm:"size:16;not null;default:dog" json:"species"`
Breed string `gorm:"size:64" json:"breed"`
Gender string `gorm:"size:16" json:"gender"`
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
AvatarFileID *string `gorm:"type:uuid;index" json:"avatar_file_id"`
Coat string `gorm:"size:64" json:"coat"`
Allergies string `gorm:"size:255" json:"allergies"`
CareNotes string `gorm:"size:1000" json:"care_notes"`
Archived bool `gorm:"not null;default:false" json:"archived"`
}
type CareTask struct {
Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
Title string `gorm:"size:128;not null" json:"title"`
Category string `gorm:"size:32;not null" json:"category"`
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
}
type TaskCompletion struct {
Base
TaskID string `gorm:"type:uuid;uniqueIndex:idx_task_date;not null" json:"task_id"`
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
Date time.Time `gorm:"type:date;uniqueIndex:idx_task_date;not null" json:"date"`
CompletedAt time.Time `gorm:"not null" json:"completed_at"`
}
type Walk struct {
Base
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
Status string `gorm:"size:16;index;not null" json:"status"`
StartedAt time.Time `gorm:"not null" json:"started_at"`
SegmentStartedAt time.Time `gorm:"not null" json:"segment_started_at"`
PausedAt *time.Time `json:"paused_at"`
PausedSeconds int `gorm:"not null;default:0" json:"paused_seconds"`
EndedAt *time.Time `json:"ended_at"`
DurationSeconds int `gorm:"not null;default:0" json:"duration_seconds"`
DistanceMeters float64 `gorm:"type:numeric(12,2);not null;default:0" json:"distance_meters"`
ShareEnabled bool `gorm:"not null;default:false" json:"share_enabled"`
ShareToken string `gorm:"type:uuid;uniqueIndex" json:"-"`
ShareExpiresAt *time.Time `json:"share_expires_at"`
}
type WalkPet struct {
Base
WalkID string `gorm:"type:uuid;uniqueIndex:idx_walk_pet;not null" json:"walk_id"`
PetID string `gorm:"type:uuid;uniqueIndex:idx_walk_pet;index;not null" json:"pet_id"`
}
type WalkPoint struct {
Base
WalkID string `gorm:"type:uuid;index;not null" json:"walk_id"`
Latitude float64 `gorm:"type:numeric(10,7);not null" json:"latitude"`
Longitude float64 `gorm:"type:numeric(10,7);not null" json:"longitude"`
RecordedAt time.Time `gorm:"index;not null" json:"recorded_at"`
}
type Meetup struct {
Base
HostUserID string `gorm:"type:uuid;index;not null" json:"host_user_id"`
Title string `gorm:"size:128;not null" json:"title"`
Description string `gorm:"size:1000" json:"description"`
PlaceName string `gorm:"size:255;not null" json:"place_name"`
Latitude float64 `gorm:"type:numeric(10,7)" json:"latitude"`
Longitude float64 `gorm:"type:numeric(10,7)" json:"longitude"`
StartsAt time.Time `gorm:"index;not null" json:"starts_at"`
MaxPeople int `gorm:"not null;default:8" json:"max_people"`
Status string `gorm:"size:16;index;not null;default:open" json:"status"`
CoverFileID *string `gorm:"type:uuid;index" json:"cover_file_id"`
}
type MeetupParticipant struct {
Base
MeetupID string `gorm:"type:uuid;uniqueIndex:idx_meetup_user;not null" json:"meetup_id"`
UserID string `gorm:"type:uuid;uniqueIndex:idx_meetup_user;index;not null" json:"user_id"`
}
type MeetupPet struct {
Base
ParticipantID string `gorm:"type:uuid;uniqueIndex:idx_participant_pet;not null" json:"participant_id"`
PetID string `gorm:"type:uuid;uniqueIndex:idx_participant_pet;index;not null" json:"pet_id"`
}
func All() []any {
return []any{
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
}
}
-35
View File
@@ -1,35 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// Pet 宠物,属于 User,支持多宠
type Pet struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Name string `gorm:"size:64" json:"name"`
Emoji string `gorm:"size:16" json:"emoji"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表:宠物照片
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
Age string `gorm:"-" 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 目标多选
// —— 身份卡用的补充信息 ——
Personality string `gorm:"size:64" json:"personality"` // 性格标签,逗号分隔,如 "活泼,亲人,贪吃"
Allergy string `gorm:"size:128" json:"allergy"` // 过敏情况,没有就填「无」
Notes string `gorm:"size:255" json:"notes"` // 备注信息(照护提醒)
}
-44
View File
@@ -1,44 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// 计划类型
const (
PlanThirtyDay = "thirty_day"
PlanAI = "ai"
)
// Plan 养宠计划(30 天路线图 / AI 计划)
type Plan struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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 string `gorm:"size:24;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"`
// Priority 从 DailyTask 合并过来的:首页那份「今天要做的事」要能标出哪条重要
Priority string `gorm:"size:16" json:"priority"`
Done bool `json:"done"`
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
}
-34
View File
@@ -1,34 +0,0 @@
package model
import "gorm.io/datatypes"
// 帖子状态
const (
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
PostPublished = "published" // 已过审,正常展示
PostRejected = "rejected" // 内容安全判定风险,打回
PostHidden = "hidden"
PostDeleted = "deleted"
)
// Post 社区帖子
type Post struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" 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"` // 展示用:URL 数组(真实图)或 emoji 数组(机器人)
ImageFileIDs datatypes.JSON `json:"image_file_ids"` // 关联 files 表的 id 数组,读取时解析为 Images
LikeCount int `json:"like_count"`
CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"`
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
// 以下为计算字段,按当前登录用户填充
Followed bool `gorm:"-" json:"followed"` // 我是否已关注该作者
IsSelf bool `gorm:"-" json:"is_self"` // 是不是我自己发的
}
-8
View File
@@ -1,8 +0,0 @@
package model
// PostLike 帖子点赞,(post_id, user_id) 唯一
type PostLike struct {
Base
PostID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"post_id"`
UserID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"user_id"`
}
-21
View File
@@ -1,21 +0,0 @@
package model
import "time"
// Pro 会员状态
const (
ProActive = "active"
ProExpired = "expired"
ProNone = "none"
)
// ProMembership 会员,1:1 于 User
type ProMembership struct {
Base
UserID string `gorm:"size:24;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"`
}
-82
View File
@@ -1,82 +0,0 @@
package model
// 记录类型。原来这 9 种是写在 health_record.go 里的常量,加一种就得发版;
// 挪到表里之后后台可以直接增删改。
//
// 注意这里**没有**替换掉那 9 个常量:weight / poop / food / symptom / cost /
// vaccine 在 record.go、insight.go、report.go、home.go 里有真实的代码分支
// (体重回写宠物档案、食便相关性分析、消费统计、疫苗完成度……),那些分支
// 按具体常量过滤,不是 switch 全枚举。所以:
// - 表负责「展示什么、能记什么」
// - 常量负责「哪几种参与分析」
// 两者各管一段,新增类型不会碰到任何分析逻辑。
// 记录类型分组
const (
RecordGroupDaily = "daily" // 日常
RecordGroupHealth = "health" // 健康
RecordGroupCare = "care" // 洗护
RecordGroupClean = "clean" // 清洁
)
// 弹层形态
const (
RecordFormFull = "full" // 有专用表单(原来那 9 种,各自一个 wx:elif 分支)
RecordFormSimple = "simple" // 通用简易表单:发生时间 + 备注 + 可选照片
)
// RecordType 一种可记录的事项
type RecordType struct {
Base
// Code 就是小程序 bottom-sheet 的 type,也是 health_records.type 落库的值。
// 建过记录之后改 Code 会让历史数据对不上,后台不允许改(只能删了重建)。
Code string `gorm:"size:32;uniqueIndex" json:"code"`
Label string `gorm:"size:32" json:"label"`
Icon string `gorm:"size:32" json:"icon"` // pt-icon 名,见 styles/iconfont.wxss
// GroupKey 存成 group_key 而不是 group:group 是 MySQL 保留字,
// GORM 虽然会加反引号,但手写 SQL 排查时很容易踩
GroupKey string `gorm:"column:group_key;size:16;index" json:"group"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
Form string `gorm:"size:16" json:"form"`
// Locked 有代码分支或模板引用依赖它,后台不许删。
// 硬拦在 service 层,不是靠前端隐藏按钮。
Locked bool `json:"locked"`
// Fields 表单字段配置,一行一个。后台用紧凑写法编辑:
//
// number:体重:kg 数值,落 num_value
// options:状态:正常|软便|拉稀 单选,落 category
// options:症状:呕吐|拉稀|精神差:- 单选,末尾 - 表示不落 category(只进标题)
// text:吃了什么 文本,只进标题
//
// 空 = 这个事项只要「时间 + 描述 + 照片」(洗澡、换猫砂这类)。
//
// 为什么要有「不落 category」这个开关:异常观察有两个单选(症状 + 严重程度),
// 而 category 只有一个坑,周报的高风险数按 category='高' 统计,
// 所以必须能指定是哪一个占这个坑。
Fields string `gorm:"type:text" json:"fields_raw"`
// ParsedFields 由 Fields 解析出来,前端直接照它渲染表单,不用自己写解析器
ParsedFields []RecordField `gorm:"-" json:"fields"`
}
// 字段类型
const (
FieldNumber = "number"
FieldOptions = "options"
FieldText = "text"
)
// RecordField 一个表单字段
type RecordField struct {
Kind string `json:"kind"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Options []string `json:"options,omitempty"`
// ToCategory 这个字段的值是否落到 health_records.category。
// 同一个事项里最多一个字段能占它
ToCategory bool `json:"to_category"`
}
-19
View File
@@ -1,19 +0,0 @@
package model
import "time"
// RefreshToken 刷新令牌。
// access token(JWT) 只有 2 小时,过期后用它换新的,用户不必重新登录;
// 库里只存 sha256,即使数据库泄露也换不出可用令牌。
// 每次刷新都会轮换(旧的立即作废),旧令牌被再次使用视为泄露。
type RefreshToken struct {
Base
TokenHash string `gorm:"size:64;uniqueIndex" json:"-"`
Kind string `gorm:"size:16;index" json:"kind"` // user | admin
SubjectID string `gorm:"size:24;index" json:"subject_id"` // 用户 ID 或管理员 ID
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RevokedAt *time.Time `json:"revoked_at"`
// 作废原因:rotate=正常轮换(有宽限期)/ logout=主动退出 / reuse=检测到泄露被连坐吊销。
// 只有 rotate 允许宽限期内再用一次,否则「吊销全部会话」会被宽限期架空。
RevokedReason string `gorm:"size:16" json:"revoked_reason"`
}
-22
View File
@@ -1,22 +0,0 @@
package model
import "time"
// 提醒类型
const (
ReminderVaccine = "vaccine"
ReminderDeworm = "deworm"
ReminderWeight = "weight"
ReminderMonthlyReport = "monthlyReport"
)
// Reminder 提醒
type Reminder struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;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"`
}
-28
View File
@@ -1,28 +0,0 @@
package model
// User 小程序用户(铲屎官)
type User struct {
Base
OpenID string `gorm:"size:64;index" json:"openid"`
Nickname string `gorm:"size:64" json:"nickname"`
// 昵称是系统随机生成的(还没被用户或微信授权覆盖过)。
// 有这个标记才能安全地在用户授权时覆盖,而不会把他改过的昵称冲掉。
NicknameAuto bool `json:"nickname_auto"`
PetCount int `gorm:"-" json:"pet_count"` // 计算字段:后台列表用
Avatar string `gorm:"size:512" json:"avatar"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表;优先于 Avatar
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Phone string `gorm:"size:32" json:"phone"`
Onboarded bool `json:"onboarded"`
Disabled bool `json:"disabled"`
IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用)
// —— 个人主页装扮 ——
Bio string `gorm:"size:120" json:"bio"` // 个性签名
BgFileID string `gorm:"size:24" json:"bg_file_id"` // 主页头图,关联 files 表
BgURL string `gorm:"-" json:"bg_url"` // 计算字段:由 BgFileID 解析
Theme string `gorm:"size:16" json:"theme"` // 主题色 key,见 service.UserThemes
// 存「隐藏」而不是「展示」,为的是零值即默认:老数据 AutoMigrate 出来是 0,
// 正好等于「展示宠物墙」,不需要给存量用户跑一次刷值
HidePets bool `json:"hide_pets"`
}
-191
View File
@@ -1,191 +0,0 @@
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/") })
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
r.Any("/api/wx/sec-callback", h.WxSecCallback)
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)
// 刷新/退出走刷新令牌,不需要(也不可能带)有效的 access token
auth.POST("/refresh", h.RefreshToken)
auth.POST("/logout", h.Logout)
}
// 小程序用户接口(需 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.POST("/user/phone", h.BindPhone)
g.GET("/user/summary", h.UserSummary)
g.PUT("/user/decoration", h.UpdateDecoration)
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("/record-types", h.RecordTypes)
g.GET("/breeds", h.Breeds)
g.GET("/life-stages", h.LifeStages)
g.GET("/pets/:id/records", h.ListRecords)
g.POST("/pets/:id/records", h.CreateRecord)
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
g.GET("/pets/:id/insights", h.PetInsights)
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.GET("/pets/:id/stage-drift", h.PetStageDrift)
g.GET("/pets/:id/id-card", h.PetIDCard)
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
// 养护方案:列出可选的、套用一套到某个月
g.GET("/care-templates", h.CareTemplateOptions)
g.POST("/pets/:id/apply-template", h.ApplyTemplate)
g.GET("/pets/:id/day-plan", h.DayPlan)
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.POST("/pets/:id/plan-tasks", h.AddPlanTask)
g.PUT("/plan-tasks/:id", h.UpdatePlanTask)
g.DELETE("/plan-tasks/:id", h.DeletePlanTask)
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("/users/:id/profile", h.UserCard)
g.GET("/users/:id/posts", h.UserPosts)
g.GET("/users/:id/relations", h.Relations)
g.POST("/users/:id/follow", h.FollowUser)
g.DELETE("/users/:id/follow", h.UnfollowUser)
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.DELETE("/posts/:id", h.DeleteOwnPost)
g.DELETE("/comments/:id", h.DeleteOwnComment)
g.GET("/comments/:id/replies", h.ListReplies)
g.GET("/articles", h.ListArticles)
g.POST("/feedback", h.CreateFeedback)
g.GET("/articles/:id", h.GetArticle)
g.GET("/pro", h.GetPro)
g.POST("/pro/activate", h.ActivatePro)
g.POST("/ai/chat", h.AIChat)
g.GET("/ai/messages", h.ListAIMessages)
g.GET("/ai/quota", h.AIQuota)
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)
api.POST("/admin/refresh", h.AdminRefresh)
api.POST("/admin/logout", h.AdminLogout)
g := api.Group("/admin")
g.Use(middleware.AuthAdmin(jm))
g.GET("/me", h.AdminMe)
g.GET("/stats", h.AdminStats)
g.GET("/analytics", h.AdminAnalytics)
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.PUT("/comments/:id/status", h.AdminSetCommentStatus)
g.DELETE("/comments/:id", h.AdminDeleteComment)
g.GET("/articles", h.AdminListArticles)
g.POST("/articles", h.AdminSaveArticle)
g.DELETE("/articles/:id", h.AdminDeleteArticle)
g.GET("/breeds", h.AdminListBreeds)
g.POST("/breeds", h.AdminSaveBreed)
g.DELETE("/breeds/:id", h.AdminDeleteBreed)
g.GET("/record-types", h.AdminListRecordTypes)
g.POST("/record-types", h.AdminSaveRecordType)
g.DELETE("/record-types/:id", h.AdminDeleteRecordType)
g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
g.GET("/care-templates", h.AdminListCareTemplates)
g.GET("/care-templates/:id", h.AdminGetCareTemplate)
g.POST("/care-templates", h.AdminSaveCareTemplate)
g.DELETE("/care-templates/:id", h.AdminDeleteCareTemplate)
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
g.GET("/ai-quota", h.AdminGetAIQuota)
g.PUT("/ai-quota", h.AdminSaveAIQuota)
g.GET("/community-bot", h.AdminGetCommunityBot)
g.PUT("/community-bot", h.AdminSaveCommunityBot)
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
g.GET("/feedback", h.AdminListFeedback)
g.PUT("/feedback/:id/handled", h.AdminSetFeedbackHandled)
g.GET("/memberships", h.AdminListPro)
}
-236
View File
@@ -1,236 +0,0 @@
package service
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
if username != s.cfg.Admin.Username {
return nil, ErrNotFound
}
var admin model.Admin
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
return nil, ErrNotFound
}
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 string) (*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, userType string, offset, limit int) ([]model.User, int64, error) {
q := s.db.Model(&model.User{})
if keyword != "" {
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
}
switch userType {
case "bot":
q = q.Where("is_bot = ?", true)
case "real":
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
}
var total int64
q.Count(&total)
var users []model.User
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
if err == nil {
s.fillPetCounts(users)
}
return users, total, err
}
// fillPetCounts 一次 GROUP BY 查出这一页用户各建了几个宠物档,
// 避免每行一次 count 把列表打成 N+1 查询
func (s *Service) fillPetCounts(users []model.User) {
if len(users) == 0 {
return
}
ids := make([]string, 0, len(users))
for i := range users {
ids = append(ids, users[i].ID)
}
var rows []struct {
UserID string
N int
}
if err := s.db.Model(&model.Pet{}).
Select("user_id, count(*) as n").
Where("user_id IN ?", ids).
Group("user_id").Scan(&rows).Error; err != nil {
return
}
m := make(map[string]int, len(rows))
for _, r := range rows {
m[r.UserID] = r.N
}
for i := range users {
users[i].PetCount = m[users[i].ID]
}
}
// SetUserDisabled 启用/禁用用户
func (s *Service) SetUserDisabled(id string, disabled bool) error {
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
return err
}
if disabled {
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
s.RevokeAllRefreshTokens("user", id)
}
return nil
}
// 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
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
s.attachPostImages(posts)
return posts, total, err
}
// SetPostStatus 审核帖子(published/hidden/deleted)
func (s *Service) SetPostStatus(id string, status string) error {
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
// ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
func (s *Service) ListCommentsAdmin(status string, offset, limit int) ([]model.Comment, int64, error) {
q := s.db.Model(&model.Comment{})
if status != "" {
q = q.Where("status = ?", status)
}
var total int64
q.Count(&total)
var comments []model.Comment
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
return comments, total, err
}
// SetCommentStatus 审核评论。从待审核过审时补上楼主的评论数
func (s *Service) SetCommentStatus(id, status string) error {
var c model.Comment
if err := s.db.First(&c, "id = ?", id).Error; err != nil {
return ErrNotFound
}
if c.Status == status {
return nil
}
if err := s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", status).Error; err != nil {
return err
}
// pending → published:这条评论此前没计数,过审后补 1
if status == model.PostPublished && c.Status == model.PostPending {
s.db.Model(&model.Post{}).Where("id = ?", c.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
return nil
}
// DeleteCommentAdmin 删除评论
func (s *Service) DeleteCommentAdmin(id string) 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 == "" {
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 string) 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
}
-199
View File
@@ -1,199 +0,0 @@
package service
import (
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 后台看板的分析数据。真实用户口径(排除 bot),活跃口径=当天产生过
// 健康记录/发帖/评论中的任意一种。数据量在预生产规模下很小,直接拉进内存算,
// 省掉一堆按天分组的 SQL。
// DayPoint 折线/柱状的一个点。Date 视粒度是 MM-DD 或 YYYY-MM
type DayPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
// RetPoint 留存曲线一个点:注册后第 Day 天仍活跃的比例
type RetPoint struct {
Day int `json:"day"` // 注册后天数
Rate float64 `json:"rate"` // 0~1
Base int `json:"base"` // 该口径下够“年龄”的用户数(分母)
}
type Analytics struct {
Summary struct {
TotalUsers int `json:"total_users"` // 真实用户总数
NewToday int `json:"new_today"`
NewMonth int `json:"new_month"`
DAU int `json:"dau"` // 今日活跃
WAU int `json:"wau"` // 近 7 日活跃
MAU int `json:"mau"` // 近 30 日活跃
TotalPets int `json:"total_pets"`
TotalPosts int `json:"total_posts"`
TotalRecords int `json:"total_records"`
} `json:"summary"`
NewTrend []DayPoint `json:"new_trend"` // 每日新增,近 days 天
ActiveTrend []DayPoint `json:"active_trend"` // 每日活跃(DAU),近 days 天
MauTrend []DayPoint `json:"mau_trend"` // 月活,近 6 个月
Retention []RetPoint `json:"retention"` // 留存曲线
}
// dayKey 把时间压成 yyyymmdd 整数,单调,便于比较和当 map key
func dayKey(t time.Time) int {
return t.Year()*10000 + int(t.Month())*100 + t.Day()
}
// AdminAnalytics days=趋势天数(默认 30,封顶 180)
func (s *Service) AdminAnalytics(days int) (*Analytics, error) {
if days <= 0 || days > 180 {
days = 30
}
loc := time.Local
now := time.Now().In(loc)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
out := &Analytics{}
// —— 真实用户(排除 bot)——
type uRow struct {
ID string
CreatedAt time.Time
}
var users []uRow
s.db.Model(&model.User{}).
Where("is_bot IS NULL OR is_bot = 0").
Select("id, created_at").Scan(&users)
signup := make(map[string]int, len(users)) // userID -> 注册日 dayKey
signupTime := make(map[string]time.Time, len(users))
ids := make([]string, 0, len(users))
todayKey := dayKey(today)
monthStart := dayKey(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc))
for _, u := range users {
t := u.CreatedAt.In(loc)
signup[u.ID] = dayKey(t)
signupTime[u.ID] = t
ids = append(ids, u.ID)
k := dayKey(t)
if k == todayKey {
out.Summary.NewToday++
}
if k >= monthStart {
out.Summary.NewMonth++
}
}
out.Summary.TotalUsers = len(users)
// —— 活跃事件:近 180 天,真实用户的 记录/帖子/评论 ——
windowStart := today.AddDate(0, 0, -180)
type ev struct {
UserID string
CreatedAt time.Time
}
// userID -> 活跃日集合;以及每日活跃用户集合(供 DAU/MAU 用)
userActive := make(map[string]map[int]bool)
dayUsers := make(map[int]map[string]bool)
mark := func(table string) {
if len(ids) == 0 {
return
}
var rows []ev
s.db.Table(table).Select("user_id, created_at").
Where("created_at >= ?", windowStart).
Where("user_id IN ?", ids).Scan(&rows)
for _, r := range rows {
k := dayKey(r.CreatedAt.In(loc))
if userActive[r.UserID] == nil {
userActive[r.UserID] = map[int]bool{}
}
userActive[r.UserID][k] = true
if dayUsers[k] == nil {
dayUsers[k] = map[string]bool{}
}
dayUsers[k][r.UserID] = true
}
}
mark("sundynix_health_records")
mark("sundynix_posts")
mark("sundynix_comments")
// distinctInRange 统计 [fromDay, toDay] 内的去重活跃用户
distinctInRange := func(from, to time.Time) int {
seen := map[string]bool{}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
for u := range dayUsers[dayKey(d)] {
seen[u] = true
}
}
return len(seen)
}
out.Summary.DAU = len(dayUsers[todayKey])
out.Summary.WAU = distinctInRange(today.AddDate(0, 0, -6), today)
out.Summary.MAU = distinctInRange(today.AddDate(0, 0, -29), today)
// —— 趋势:新增 + DAU,近 days 天 ——
for i := days - 1; i >= 0; i-- {
d := today.AddDate(0, 0, -i)
k := dayKey(d)
label := d.Format("01-02")
newN := 0
for _, sk := range signup {
if sk == k {
newN++
}
}
out.NewTrend = append(out.NewTrend, DayPoint{Date: label, Count: newN})
out.ActiveTrend = append(out.ActiveTrend, DayPoint{Date: label, Count: len(dayUsers[k])})
}
// —— 月活:近 6 个月 ——
for i := 5; i >= 0; i-- {
mStart := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
mEnd := mStart.AddDate(0, 1, -1)
out.MauTrend = append(out.MauTrend, DayPoint{
Date: mStart.Format("2006-01"),
Count: distinctInRange(mStart, mEnd),
})
}
// —— 留存曲线:注册后第 K 天仍活跃(滚动口径:K 天当天或之后还有活跃)——
// 分母只算“年龄”够 K 天、且在观测窗内的用户
offsets := []int{1, 3, 7, 14, 30}
minSignup := dayKey(windowStart)
for _, K := range offsets {
base, retained := 0, 0
for id, sk := range signup {
if sk < minSignup {
continue // 太老,活跃窗看不全
}
plusK := dayKey(signupTime[id].AddDate(0, 0, K))
if plusK > todayKey {
continue // 还没到第 K 天,不够“年龄”
}
base++
for ak := range userActive[id] {
if ak >= plusK {
retained++
break
}
}
}
rate := 0.0
if base > 0 {
rate = float64(retained) / float64(base)
}
out.Retention = append(out.Retention, RetPoint{Day: K, Rate: rate, Base: base})
}
var pets, posts, records int64
s.db.Model(&model.Pet{}).Count(&pets)
s.db.Model(&model.Post{}).Count(&posts)
s.db.Model(&model.HealthRecord{}).Count(&records)
out.Summary.TotalPets = int(pets)
out.Summary.TotalPosts = int(posts)
out.Summary.TotalRecords = int(records)
return out, nil
}
-55
View File
@@ -1,55 +0,0 @@
package service
import (
"io"
"github.com/sundynix/pets-be/internal/model"
)
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
// 先扣额度再请求大模型:失败也算用掉一次,否则刷接口空转照样烧钱
if err := s.consumeAIQuota(userID, aiKindChat); err != nil {
return "", err
}
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)
}
// ListAIMessages 取当前用户的聊天历史(按会话,默认最近 N 条,返回时间正序)
func (s *Service) ListAIMessages(userID, session string, limit int) ([]model.AIMessage, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
q := s.db.Where("user_id = ?", userID)
if session != "" {
q = q.Where("session = ?", session)
}
var msgs []model.AIMessage
if err := q.Order("id desc").Limit(limit).Find(&msgs).Error; err != nil {
return nil, err
}
// 反转成时间正序,前端直接按顺序渲染
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
msgs[i], msgs[j] = msgs[j], msgs[i]
}
return msgs, nil
}
-144
View File
@@ -1,144 +0,0 @@
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 string) 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 *string, 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 string, in SymptomInput) (*SymptomResult, error) {
if err := s.consumeAIQuota(userID, aiKindSymptom); err != nil {
return nil, err
}
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
}
-110
View File
@@ -1,110 +0,0 @@
package service
import (
"errors"
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// ErrAIQuotaExceeded 今日 AI 次数用完
var ErrAIQuotaExceeded = errors.New("今日 AI 次数已用完")
// AI 调用是真金白银,不设上限等于把钱包交给用户。
// 额度按「用户 + 自然日」计,从后台配置读,改完立刻生效。
const (
aiKindChat = "chat" // 问问 AI
aiKindSymptom = "symptom" // 异常观察评估
aiKindPlan = "plan" // AI 生成计划
)
// aiQuotaConfig 从后台配置取每日额度。取不到就用保守默认值,
// 绝不「取不到就不限制」——那正是配置出问题时最不该发生的事。
func (s *Service) aiQuotaConfig() model.AIQuotaConfig {
var c model.AIQuotaConfig
if err := s.db.First(&c, "id = ?", "1").Error; err != nil {
return model.AIQuotaConfig{DailyChat: 20, DailySymptom: 10, DailyPlan: 5, Enabled: true}
}
return c
}
// aiDailyLimit 某类 AI 功能的每日上限;<=0 表示不限
func (s *Service) aiDailyLimit(kind string) (int, bool) {
c := s.aiQuotaConfig()
if !c.Enabled {
return 0, false
}
switch kind {
case aiKindChat:
return c.DailyChat, true
case aiKindSymptom:
return c.DailySymptom, true
case aiKindPlan:
return c.DailyPlan, true
}
return 0, false
}
// consumeAIQuota 扣一次额度。超限返回 ErrAIQuotaExceeded,调用方要在真正
// 请求大模型之前调用它——扣完再调,失败也算用掉一次,避免刷接口空转烧钱。
func (s *Service) consumeAIQuota(userID, kind string) error {
limit, on := s.aiDailyLimit(kind)
if !on || limit <= 0 {
return nil
}
day := time.Now().Format("2006-01-02")
var u model.AIUsage
err := s.db.Where("user_id = ? AND day = ? AND kind = ?", userID, day, kind).First(&u).Error
if err == nil {
if u.Count >= limit {
return fmt.Errorf("%w(每天 %d 次,明天恢复)", ErrAIQuotaExceeded, limit)
}
return s.db.Model(&model.AIUsage{}).Where("id = ?", u.ID).
UpdateColumn("count", u.Count+1).Error
}
return s.db.Create(&model.AIUsage{UserID: userID, Day: day, Kind: kind, Count: 1}).Error
}
// AIQuotaLeft 返回各类今日剩余次数,给小程序显示
func (s *Service) AIQuotaLeft(userID string) map[string]any {
c := s.aiQuotaConfig()
day := time.Now().Format("2006-01-02")
var rows []model.AIUsage
s.db.Where("user_id = ? AND day = ?", userID, day).Find(&rows)
used := map[string]int{}
for _, r := range rows {
used[r.Kind] = r.Count
}
left := func(limit int, kind string) int {
if !c.Enabled || limit <= 0 {
return -1 // -1 表示不限
}
if n := limit - used[kind]; n > 0 {
return n
}
return 0
}
return map[string]any{
"enabled": c.Enabled,
"chat": left(c.DailyChat, aiKindChat),
"symptom": left(c.DailySymptom, aiKindSymptom),
"plan": left(c.DailyPlan, aiKindPlan),
}
}
// GetAIQuotaConfig / SaveAIQuotaConfig 后台读写
func (s *Service) GetAIQuotaConfig() model.AIQuotaConfig { return s.aiQuotaConfig() }
func (s *Service) SaveAIQuotaConfig(in model.AIQuotaConfig) (model.AIQuotaConfig, error) {
c := s.aiQuotaConfig()
c.ID = "1"
c.Enabled = in.Enabled
c.DailyChat = maxInt(in.DailyChat, 0)
c.DailySymptom = maxInt(in.DailySymptom, 0)
c.DailyPlan = maxInt(in.DailyPlan, 0)
if err := s.db.Save(&c).Error; err != nil {
return c, err
}
return c, nil
}
-26
View File
@@ -1,26 +0,0 @@
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 string) (*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
}
-128
View File
@@ -1,128 +0,0 @@
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) {
autoName := nickname == ""
if autoName {
nickname = s.UniqueNickname()
}
var u model.User
err := s.db.Where("nickname = ?", nickname).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
u = model.User{Nickname: nickname, NicknameAuto: autoName}
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: s.UniqueNickname(), NicknameAuto: true}
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 string) (*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
}
u.AvatarURL = s.fileURL(u.AvatarFileID)
u.BgURL = s.fileURL(u.BgFileID)
if !UserThemes[u.Theme] {
u.Theme = defaultUserTheme // 老用户 theme 是空串,别让前端拿空 key 去查表
}
return &u, nil
}
// UpdateUser 更新用户资料(昵称/头像/手机号)
func (s *Service) UpdateUser(userID string, fields map[string]any) (*model.User, error) {
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(fields).Error; err != nil {
return nil, err
}
return s.GetUser(userID)
}
-114
View File
@@ -1,114 +0,0 @@
package service
import (
"errors"
"log"
"sort"
"strconv"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// BreedGroup 同一首字母下的品种。小程序端的 A-Z 索引直接照这个渲染
type BreedGroup struct {
Initial string `json:"initial"`
Items []model.Breed `json:"items"`
}
// ListBreeds 某个物种的品种,按首字母分组。只返回启用的
func (s *Service) ListBreeds(species string) []BreedGroup {
if species != model.SpeciesCat && species != model.SpeciesDog {
return []BreedGroup{}
}
var all []model.Breed
s.db.Where("species = ? AND enabled = ?", species, true).
Order("initial asc, sort asc, id asc").Find(&all)
if len(all) == 0 {
// 不硬编码兜底一份:那等于把「后台可配」又变回代码常量,
// 改了配置的人会发现改了没用。宁可让选择器明显是空的
log.Printf("[warn] %s 没有任何启用的品种,建档第二步会选不了品种", species)
return []BreedGroup{}
}
byInitial := map[string][]model.Breed{}
for _, b := range all {
k := strings.ToUpper(b.Initial)
if k == "" {
k = "#" // 没填首字母的兜到最后,别让它凭空消失
}
byInitial[k] = append(byInitial[k], b)
}
keys := make([]string, 0, len(byInitial))
for k := range byInitial {
keys = append(keys, k)
}
sort.Strings(keys)
out := make([]BreedGroup, 0, len(keys))
for _, k := range keys {
out = append(out, BreedGroup{Initial: k, Items: byInitial[k]})
}
return out
}
// AdminListBreeds 后台用:连停用的一起返回
func (s *Service) AdminListBreeds(species string) []model.Breed {
q := s.db.Order("species asc, initial asc, sort asc, id asc")
if species == model.SpeciesCat || species == model.SpeciesDog {
q = q.Where("species = ?", species)
}
var all []model.Breed
q.Find(&all)
if all == nil {
return []model.Breed{}
}
return all
}
// SaveBreed 新增或更新
func (s *Service) SaveBreed(in *model.Breed) error {
in.Name = strings.TrimSpace(in.Name)
in.Initial = strings.ToUpper(strings.TrimSpace(in.Initial))
if in.Name == "" {
return ErrInvalidParam
}
if in.Species != model.SpeciesCat && in.Species != model.SpeciesDog {
return errors.New("物种只能是 cat 或 dog")
}
if len(in.Initial) != 1 || in.Initial[0] < 'A' || in.Initial[0] > 'Z' {
return errors.New("首字母要填一个 A-Z 的字母(用来做侧边索引)")
}
// 同物种下不允许同名:品种是拿来做统计口径的,重名会把同一种猫劈成两半
var n int64
q := s.db.Model(&model.Breed{}).Where("species = ? AND name = ?", in.Species, in.Name)
if in.ID != "" {
q = q.Where("id <> ?", in.ID)
}
q.Count(&n)
if n > 0 {
return errors.New("这个物种下已经有「" + in.Name + "」了")
}
if in.ID == "" {
return s.db.Create(in).Error
}
return s.db.Model(&model.Breed{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "name": in.Name, "initial": in.Initial,
"sort": in.Sort, "enabled": in.Enabled,
}).Error
}
// DeleteBreed 已经有宠物在用的品种不许删——删了那些档案的品种就变成一个
// 查不到的字符串。想让它不再被选到应该用 enabled=false
func (s *Service) DeleteBreed(id string) error {
var b model.Breed
if err := s.db.First(&b, "id = ?", id).Error; err != nil {
return ErrNotFound
}
var used int64
s.db.Model(&model.Pet{}).Where("breed = ?", b.Name).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 个档案填了「" +
b.Name + "」,删了它们的品种就查不到了。想隐藏请改成停用")
}
return s.db.Delete(&model.Breed{}, "id = ?", id).Error
}
-260
View File
@@ -1,260 +0,0 @@
package service
import (
"errors"
"strconv"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
// 套用养护方案:把模板里的重复规则展开成某个月的计划节点。
//
// 为什么要展开成一条条实体行、而不是「存规则、读时展开」:
// 勾选状态是按天的。存规则的话得再开一张表记「哪天打过勾」,
// 而且用户改一条(比如把「每周二梳毛」这周挪到周三)就要引入例外记录。
// 一次展开成行,增删改和打勾全都免费。
//
// 代价是套用之后模板和计划就断开了:后台改模板不会影响已套用的宠物。
// 这是有意的——用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。
// TemplateOption 建档/计划页上给用户挑的一套方案
type TemplateOption struct {
ID string `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
// 按重复方式分开报数。只给一个总数会骗人:10 条明细里有 4 条是「每天」,
// 套用一整月会展开成 130 条节点,用户看到「10 条」再看到 130 条会以为出 bug 了
Daily int `json:"daily"` // 每天做的
Weekly int `json:"weekly"` // 每周做的
Once int `json:"once"` // 一次性的
Monthly int `json:"monthly"` // 每月做的
Reminds int `json:"reminders"` // 会建的提醒
}
// ListTemplateOptions 某物种阶段下可选的方案。空列表意味着后台没配,
// 前端要显示「暂无方案,可以自己排」而不是转圈
func (s *Service) ListTemplateOptions(species, stage string) []TemplateOption {
var tpls []model.CareTemplate
s.db.Preload("Items").
Where("species = ? AND stage = ? AND enabled = ?", species, stage, true).
Order("sort asc, id asc").Find(&tpls)
out := make([]TemplateOption, 0, len(tpls))
for _, t := range tpls {
o := TemplateOption{ID: t.ID, Name: t.Name, Summary: t.Summary}
if o.Name == "" {
o.Name = t.Stage + "养护方案" // 老数据没名字,兜一个别显示空白
}
for _, it := range t.Items {
switch {
case it.Kind == model.CareKindReminder:
o.Reminds++
case it.Repeat == model.RepeatDaily:
o.Daily++
case it.Repeat == model.RepeatWeekly:
o.Weekly++
case it.Repeat == model.RepeatMonthly:
o.Monthly++
default:
o.Once++
}
}
out = append(out, o)
}
return out
}
// weekdaysOf 解析 "1,3,5" → {1:true,3:true,5:true},1=周一
func weekdaysOf(s string) map[int]bool {
out := map[int]bool{}
for _, p := range strings.Split(s, ",") {
n, err := strconv.Atoi(strings.TrimSpace(p))
if err == nil && n >= 1 && n <= 7 {
out[n] = true
}
}
return out
}
// isoWeekday 把 Go 的 Sunday=0 换成 1=周一 … 7=周日
func isoWeekday(t time.Time) int {
d := int(t.Weekday())
if d == 0 {
return 7
}
return d
}
// expandItem 把一条明细展开成若干个日期。
// from/to 是套用窗口(通常是「今天」到「本月最后一天」)
func expandItem(it model.CareTemplateItem, from, to time.Time) []time.Time {
var out []time.Time
switch it.Repeat {
case model.RepeatDaily:
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
out = append(out, d)
}
case model.RepeatWeekly:
wd := weekdaysOf(it.Weekdays)
if len(wd) == 0 {
return nil // 配了每周却没选星期几,展开不出东西,跳过比排错天好
}
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
if wd[isoWeekday(d)] {
out = append(out, d)
}
}
case model.RepeatMonthly:
md := it.MonthDay
if md < 1 {
md = 1
}
// 逐月走,因为窗口可能跨月
for cur := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()); !cur.After(to); cur = cur.AddDate(0, 1, 0) {
last := cur.AddDate(0, 1, -1).Day()
day := md
if day > last {
day = last // 「每月 31 号」在 2 月按月末算,不然这个月直接没有
}
d := time.Date(cur.Year(), cur.Month(), day, 0, 0, 0, 0, from.Location())
if !d.Before(from) && !d.After(to) {
out = append(out, d)
}
}
default: // once:从窗口起点偏移 N 天
d := from.AddDate(0, 0, it.Day)
// 偏移出窗口的直接丢掉。硬塞到窗口末尾会让「到家第 14 天驱虫」
// 变成「月底驱虫」,日期错了比没有更糟
if !d.Before(from) && !d.After(to) {
out = append(out, d)
}
}
return out
}
// ApplyTemplateResult 套用结果,给前端一句反馈用
type ApplyTemplateResult struct {
Plans int `json:"plans"` // 排进了几条计划
Reminders int `json:"reminders"` // 建了几条提醒
From string `json:"from"`
To string `json:"to"`
}
// ApplyTemplate 把一套方案套用到某只宠物的某个月。
// monthOffset: 0 本月,1 下月。本月只从今天算起——给用户排一堆昨天的事没意义
func (s *Service) ApplyTemplate(userID, petID, templateID string, monthOffset int) (*ApplyTemplateResult, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
var tpl model.CareTemplate
if e := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("sort, id") }).
First(&tpl, "id = ?", templateID).Error; e != nil {
return nil, ErrNotFound
}
first, last := monthBounds(monthOffset)
from := first
if monthOffset == 0 {
today := time.Now()
t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
if t.After(from) {
from = t
}
}
if from.After(last) {
return nil, errors.New("这个月已经过完了")
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 已经有的节点按「日期+标题」记下来,套用不产生重复。
// 用户可能先自己排了「每天喂药」,再套一套含同样一条的方案
var exist []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&exist)
seen := map[string]bool{}
for _, t := range exist {
d := start.AddDate(0, 0, t.Day)
seen[d.Format("2006-01-02")+"|"+t.Title] = true
}
var rows []model.PlanTask
for _, it := range tpl.Items {
if it.Kind == model.CareKindReminder {
continue
}
for _, d := range expandItem(it, from, last) {
key := d.Format("2006-01-02") + "|" + it.Title
if seen[key] {
continue
}
seen[key] = true
day := int(d.Sub(start).Hours() / 24)
if day < 0 {
continue // 早于计划开始日的排不进去
}
rows = append(rows, model.PlanTask{
PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day),
Title: it.Title, Description: it.Description, SheetType: it.SheetType,
})
}
}
res := &ApplyTemplateResult{From: from.Format("2006-01-02"), To: last.Format("2006-01-02")}
if len(rows) > 0 {
if e := s.db.Create(&rows).Error; e != nil {
return nil, e
}
res.Plans = len(rows)
}
// 提醒只在第一次套用时建:它是「到期提醒」,按物种阶段来就一份,
// 套两次方案不该出现两条疫苗提醒
res.Reminders = s.applyTemplateReminders(pet, tpl.Items)
return res, nil
}
// applyTemplateReminders 建模板里的提醒,已存在同标题的跳过
func (s *Service) applyTemplateReminders(pet *model.Pet, items []model.CareTemplateItem) int {
var exist []model.Reminder
s.db.Where("pet_id = ?", pet.ID).Find(&exist)
seen := map[string]bool{}
for _, r := range exist {
seen[r.Title] = true
}
goals := petGoals(pet)
var rows []model.Reminder
for _, it := range items {
if it.Kind != model.CareKindReminder || seen[it.Title] {
continue
}
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
seen[it.Title] = true
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
// dueDaysFor 会按宠物月龄调整(幼猫的「下一针」不该按成年猫的周期算)
due := time.Now().AddDate(0, 0, dueDaysFor(it, pet, time.Now()))
r.NextDueDate = &due
}
rows = append(rows, r)
}
if len(rows) == 0 {
return 0
}
if err := s.db.Create(&rows).Error; err != nil {
return 0
}
return len(rows)
}
-258
View File
@@ -1,258 +0,0 @@
package service
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了:
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据。
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
// CareSpecies 支持的物种(字符串与 onboarding 一致)
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
// monthsSince 从某天到今天的整月数(不足一月算 0)
func monthsSince(t time.Time) int {
now := time.Now()
m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month())
if now.Day() < t.Day() {
m--
}
if m < 0 {
m = 0
}
return m
}
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
func SpeciesOf(petType string) string {
if strings.Contains(petType, "狗") {
return model.SpeciesDog
}
return model.SpeciesCat
}
func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog }
func validStage(s string) bool {
for _, x := range CareStages {
if x == s {
return true
}
}
return false
}
// careTemplateItems 取某组合的模板明细。模板是后台配的内容数据,代码里不再留
// 一套内置默认——没配就是没配,这里返回空,由调用方决定怎么处理(见 seedPetDefaults)。
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
var tpl model.CareTemplate
err := s.db.
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
if err != nil {
return nil
}
return tpl.Items
}
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
// AdminListTemplates 后台:全部方案(含停用),按物种阶段排
func (s *Service) AdminListTemplates() []model.CareTemplate {
var all []model.CareTemplate
s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
Order("species asc, stage asc, sort asc, id asc").Find(&all)
if all == nil {
return []model.CareTemplate{}
}
return all
}
// AdminGetTemplate 后台:取一套
func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) {
var tpl model.CareTemplate
if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
First(&tpl, "id = ?", id).Error; err != nil {
return nil, ErrNotFound
}
return &tpl, nil
}
// DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响——
// 套用时就已经展开成实体行、和模板断开了
func (s *Service) DeleteTemplate(id string) error {
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error
})
}
// SaveCareTemplate 后台保存:整表替换该组合的明细
// SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
// 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
// 「让用户选方案」根本无从选起
func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error {
if !validSpecies(in.Species) || !validStage(in.Stage) {
return errors.New("物种或阶段不合法")
}
if strings.TrimSpace(in.Name) == "" {
return errors.New("方案要有名字 —— 用户建档时看到的就是它")
}
// 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时
// 静默少排几条,用户看不出来是配错了
clean := make([]model.CareTemplateItem, 0, len(items))
for i := range items {
it := items[i]
it.Title = strings.TrimSpace(it.Title)
if it.Title == "" {
continue
}
if it.Kind != model.CareKindReminder {
it.Kind = model.CareKindPlan
switch it.Repeat {
case model.RepeatDaily, model.RepeatMonthly:
case model.RepeatWeekly:
if len(weekdaysOf(it.Weekdays)) == 0 {
return errors.New("「" + it.Title + "」选了每周,但没勾星期几")
}
default:
it.Repeat = model.RepeatOnce
}
if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) {
return errors.New("「" + it.Title + "」选了每月,几号要填 1-31")
}
}
it.ID = ""
it.Sort = i
clean = append(clean, it)
}
return s.db.Transaction(func(tx *gorm.DB) error {
if in.ID == "" {
if err := tx.Create(in).Error; err != nil {
return err
}
} else {
if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
"species": in.Species, "stage": in.Stage, "name": in.Name,
"summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
}).Error; err != nil {
return err
}
if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
return err
}
}
for i := range clean {
clean[i].TemplateID = in.ID
}
if len(clean) > 0 {
return tx.Create(&clean).Error
}
return nil
})
}
// GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。
// AI 未开启或失败时回退内置默认,保证总有可用草稿。
func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
if !validSpecies(species) || !validStage(stage) {
return nil, errors.New("invalid species or stage")
}
if !s.ai.Enabled() {
return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细")
}
spName := "猫"
if species == model.SpeciesDog {
spName = "狗"
}
system := aiSafetyPrompt + `
你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。
只输出 JSON,结构如下(不要多余文字):
{
"tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}],
"plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}],
"reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}]
}
要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。`
user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return nil, fmt.Errorf("AI 生成失败:%w", err)
}
items, ok := parseCareItems(out)
if !ok || len(items) == 0 {
return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加")
}
return items, nil
}
// parseCareItems 解析 AI 返回的分组 JSON → 扁平明细
func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
var raw struct {
Tasks []struct {
Title, Description, Priority, SheetType string
} `json:"tasks"`
Plan []struct {
Day int
DayLabel, Title, Description, SheetType string
} `json:"plan"`
Reminders []struct {
ReminderType, Title, Frequency string
OffsetDays int
} `json:"reminders"`
}
if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil {
return nil, false
}
var items []model.CareTemplateItem
for _, t := range raw.Tasks {
if t.Title == "" {
continue
}
items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType))
}
for _, p := range raw.Plan {
if p.Title == "" {
continue
}
items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType))
}
for _, r := range raw.Reminders {
if r.Title == "" {
continue
}
items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency))
}
return items, len(items) > 0
}
// ---- 明细构造小工具(供 AI 草稿解析用)----
// careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并——
// 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatDaily,
Title: title, Description: desc, Priority: priority, SheetType: sheet}
}
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatOnce,
Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
}
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
}
-375
View File
@@ -1,375 +0,0 @@
package service
import (
"encoding/json"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 Images(URL 数组)。
// 机器人帖 ImageFileIDs 为空,保留其 Images(emoji)不变。
func (s *Service) attachPostImages(posts []model.Post) {
perPost := make([][]string, len(posts))
all := []string{}
for i := range posts {
if len(posts[i].ImageFileIDs) == 0 {
continue
}
var ids []string
if json.Unmarshal(posts[i].ImageFileIDs, &ids) == nil && len(ids) > 0 {
perPost[i] = ids
all = append(all, ids...)
}
}
if len(all) == 0 {
return
}
urlMap := s.fileURLs(all)
for i := range posts {
if len(perPost[i]) == 0 {
continue
}
urls := make([]string, 0, len(perPost[i]))
for _, id := range perPost[i] {
if u := urlMap[id]; u != "" {
urls = append(urls, u)
}
}
if b, err := json.Marshal(urls); err == nil {
posts[i].Images = datatypes.JSON(b)
}
}
}
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
func tabTag(tab string) string {
switch tab {
case "新手求助":
return "新手求助"
case "晒宠":
return "晒宠"
case "经验":
return "经验"
default: // 推荐 / 关注
return ""
}
}
// ListPosts 帖子分页列表。userID 用于「关注」tab 过滤和标记关注状态(可为空)。
func (s *Service) ListPosts(userID, 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+`"`)
}
if tab == "关注" {
ids := s.followeeIDs(userID)
if len(ids) == 0 {
// 一个都没关注:返回空列表,而不是退化成「推荐」
return []model.Post{}, 0, nil
}
q = q.Where("user_id IN ?", ids)
}
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
}
s.attachPostImages(posts)
s.markFollowed(userID, posts)
return posts, total, nil
}
// GetPost 帖子详情
func (s *Service) GetPost(postID string) (*model.Post, error) {
var p model.Post
if err := s.db.First(&p, postID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrNotFound
}
return nil, err
}
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// PostInput 发帖入参
type PostInput struct {
PetID *string
Identity string
Content string
Tags datatypes.JSON
Images datatypes.JSON
ImageFileIDs datatypes.JSON
}
// CreatePost 发帖
func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
// 取作者信息:统一用发布者昵称(去掉了发布身份选择),openid 给内容安全接口用
var user model.User
s.db.Select("id, open_id, nickname").First(&user, userID)
authorName := user.Nickname
if authorName == "" {
authorName = "宠友"
}
authorEmoji := "🐾"
imgURLs := s.resolveImageURLs(in.ImageFileIDs)
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
status, _ := s.moderateInitial(in.Content, user.OpenID, 3, len(imgURLs) > 0)
p := model.Post{
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
ImageFileIDs: in.ImageFileIDs, Status: status,
}
if err := s.db.Create(&p).Error; err != nil {
return nil, err
}
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
if status == model.PostPending && len(imgURLs) > 0 {
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
}
one := []model.Post{p}
s.attachPostImages(one)
return &one[0], nil
}
// resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表
func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string {
if len(fileIDs) == 0 {
return nil
}
var ids []string
if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 {
return nil
}
m := s.fileURLs(ids)
urls := make([]string, 0, len(ids))
for _, id := range ids {
if u := m[id]; u != "" {
urls = append(urls, u)
}
}
return urls
}
// LikePost 点赞(幂等:已赞则不重复计数)
func (s *Service) LikePost(userID, postID string) (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 string) (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 string) (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(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
base := func() *gorm.DB {
return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
}
// total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上
var total int64
if err := base().Count(&total).Error; err != nil {
return nil, 0, err
}
// 只分页一级评论,回复跟着它爹一起返回
var roots []model.Comment
if err := base().Where("parent_id = ? OR parent_id IS NULL", "").
Order("id desc").Offset(offset).Limit(limit).Find(&roots).Error; err != nil {
return nil, 0, err
}
if len(roots) == 0 {
return []model.Comment{}, total, nil
}
ids := make([]string, 0, len(roots))
for i := range roots {
roots[i].IsSelf = roots[i].UserID == userID
ids = append(ids, roots[i].ID)
}
// 一次查完这一页所有一级评论的回复,不要每条一次查询
var replies []model.Comment
s.db.Where("parent_id IN ? AND status = ?", ids, "published").Order("id asc").Find(&replies)
byParent := map[string][]model.Comment{}
for i := range replies {
replies[i].IsSelf = replies[i].UserID == userID
byParent[replies[i].ParentID] = append(byParent[replies[i].ParentID], replies[i])
}
for i := range roots {
list := byParent[roots[i].ID]
if list == nil {
list = []model.Comment{} // 空切片而不是 nil,否则 JSON 出来是 null
}
roots[i].ReplyN = len(list)
// 默认只带前 3 条,其余等前端点「展开」再要,避免热门评论一次拉几百条
if len(list) > 3 {
list = list[:3]
}
roots[i].Replies = list
}
return roots, total, nil
}
// ListReplies 展开某条一级评论下的全部回复
func (s *Service) ListReplies(userID, commentID string) ([]model.Comment, error) {
var list []model.Comment
if err := s.db.Where("parent_id = ? AND status = ?", commentID, "published").
Order("id asc").Find(&list).Error; err != nil {
return nil, err
}
for i := range list {
list[i].IsSelf = list[i].UserID == userID
}
return list, nil
}
// CreateComment 评论
func (s *Service) CreateComment(userID, postID string, content string, images []string, parentID 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
}
// 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树
replyTo := ""
if parentID != "" {
var parent model.Comment
if err := s.db.Where("id = ? AND post_id = ?", parentID, postID).First(&parent).Error; err != nil {
return nil, ErrNotFound
}
if parent.ParentID != "" {
replyTo = parent.AuthorName
parentID = parent.ParentID
}
}
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
comment := model.Comment{
PostID: postID, UserID: userID, AuthorName: name, Content: content,
Status: status, ParentID: parentID, ReplyToName: replyTo,
}
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
comment.Images = b
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&comment).Error; err != nil {
return err
}
// 待审核/打回的评论先不计入楼主的评论数,过审后再补
if status == model.PostPublished {
return tx.Model(&model.Post{}).Where("id = ?", postID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
}
return nil
}); err != nil {
return nil, err
}
// 文本已过、有图 → 异步查图
if status == model.PostPending && len(images) > 0 {
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
}
return &comment, nil
}
// DeleteOwnPost 用户删除自己的帖子(软删除,保留数据可追溯)
func (s *Service) DeleteOwnPost(userID, postID string) error {
var p model.Post
if err := s.db.Where("id = ?", postID).First(&p).Error; err != nil {
return ErrNotFound
}
if p.UserID != userID {
return ErrForbidden
}
return s.db.Model(&model.Post{}).Where("id = ?", postID).
Update("status", model.PostDeleted).Error
}
// DeleteOwnComment 用户删除自己的评论,并同步帖子评论数
func (s *Service) DeleteOwnComment(userID, commentID string) error {
var c model.Comment
if err := s.db.Where("id = ?", commentID).First(&c).Error; err != nil {
return ErrNotFound
}
if c.UserID != userID {
return ErrForbidden
}
if c.Status == "deleted" {
return nil // 幂等
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Comment{}).Where("id = ?", commentID).
Update("status", "deleted").Error; err != nil {
return err
}
// 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿
n := int64(1)
if c.ParentID == "" {
res := tx.Model(&model.Comment{}).
Where("parent_id = ? AND status = ?", commentID, "published").
Update("status", "deleted")
if res.Error != nil {
return res.Error
}
n += res.RowsAffected
}
return tx.Model(&model.Post{}).Where("id = ? AND comment_count >= ?", c.PostID, n).
UpdateColumn("comment_count", gorm.Expr("comment_count - ?", n)).Error
})
}
-247
View File
@@ -1,247 +0,0 @@
package service
import (
"encoding/json"
"fmt"
"math/rand"
"time"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
const botTickMinutes = 15
// 话题种子,增加生成多样性
var botTopics = []string{
"新手第一次给猫换粮的经历和注意点",
"幼犬疫苗期间的日常照护",
"猫咪软便观察和调理的经验",
"遛狗时防止捡食的小技巧",
"换季掉毛严重怎么护理",
"刚到家的猫如何度过应激期",
"给狗狗刷牙、清口腔的经验",
"记录体重时发现的小变化",
"驱虫到期提醒和实操",
"多猫家庭的相处磨合",
"老年猫饮水和肾脏健康关注点",
"幼猫社会化和玩耍引导",
}
// 默认虚拟宠友账号池(昵称 + emoji 头像)
var botSeeds = []struct{ Name, Emoji string }{
{"团子的日常", "🐱"}, {"布丁妈妈", "🐶"}, {"三花小报告", "🐱"},
{"柯基屁股蛋", "🐶"}, {"橘座驾到", "🐱"}, {"奶牛猫饲养员", "🐮"},
{"金毛暖暖", "🦮"}, {"布偶少爷", "😺"}, {"英短小灰灰", "🐱"},
{"泰迪不乖", "🐩"}, {"狸花猫巡逻队", "🐈"}, {"雪纳瑞老张", "🐶"},
{"银渐层日记", "😸"}, {"哈士奇拆家现场", "🐺"}, {"暹罗小王子", "🐱"},
{"边牧上学记", "🐕"}, {"美短起司", "🧀"}, {"柴犬麻薯", "🐕"},
{"无毛猫斯芬克斯", "🐱"}, {"比熊棉花糖", "🐶"}, {"缅因大橘", "🦁"},
{"腊肠一米五", "🌭"}, {"加菲脸圆圆", "😻"}, {"萨摩耶微笑", "🐻‍❄️"},
{"蓝猫布加迪", "🐱"}, {"法斗打呼噜", "🐶"}, {"折耳软软", "🐱"},
{"阿拉斯加大脸", "🐺"},
}
// 兜底文案(AI 未开启/失败时用)
var fallbackPosts = []string{
"换粮第3天,软便基本好了,7天过渡法真的有用,分享给同样在换粮的姐妹~",
"幼猫社会化真的重要,每天陪玩20分钟,现在一点都不怕生人了🐱",
"提醒大家驱虫别忘记,我差点漏了,手机日历设个提醒最保险。",
"金毛换季掉毛太夸张了,每天梳一次+补点卵磷脂,情况好多了。",
}
// getBotConfig 取单例配置(不存在返回内置默认,不写库)
func (s *Service) getBotConfig() model.CommunityBotConfig {
var c model.CommunityBotConfig
if err := s.db.First(&c, "1").Error; err != nil {
return model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
}
return c
}
// GetCommunityBotConfig 后台读取配置
func (s *Service) GetCommunityBotConfig() model.CommunityBotConfig {
return s.getBotConfig()
}
// SaveCommunityBotConfig 后台保存配置(固定 id=1 upsert)
func (s *Service) SaveCommunityBotConfig(in model.CommunityBotConfig) (model.CommunityBotConfig, error) {
if in.DailyCount < 0 {
in.DailyCount = 0
}
if in.StartHour < 0 || in.StartHour > 23 {
in.StartHour = 9
}
if in.EndHour < 1 || in.EndHour > 24 {
in.EndHour = 21
}
if in.EndHour <= in.StartHour {
in.EndHour = in.StartHour + 1
}
in.ID = "1"
if err := s.db.Save(&in).Error; err != nil {
return in, err
}
return in, nil
}
// SeedCommunityBot 启动幂等 seed:虚拟账号池(按昵称补齐新增账号)+ 配置单例
func (s *Service) SeedCommunityBot() error {
// 迁移前的旧用户 is_bot 为 NULL,回填为 false 便于筛选
if err := s.db.Model(&model.User{}).Where("is_bot IS NULL").Update("is_bot", false).Error; err != nil {
return err
}
for _, b := range botSeeds {
var n int64
if err := s.db.Model(&model.User{}).Where("nickname = ? AND is_bot = ?", b.Name, true).Count(&n).Error; err != nil {
return err
}
if n == 0 {
u := model.User{Nickname: b.Name, Avatar: b.Emoji, IsBot: true, Onboarded: true}
if err := s.db.Create(&u).Error; err != nil {
return err
}
}
}
var cfgCount int64
if err := s.db.Model(&model.CommunityBotConfig{}).Count(&cfgCount).Error; err != nil {
return err
}
if cfgCount == 0 {
c := model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
c.ID = "1"
if err := s.db.Create(&c).Error; err != nil {
return err
}
}
return nil
}
// countAIPostsToday 今天已生成的 AI 帖数
func (s *Service) countAIPostsToday() int64 {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var n int64
s.db.Model(&model.Post{}).Where("is_ai = ? AND created_at >= ?", true, start).Count(&n)
return n
}
// GenerateCommunityPosts 生成 n 条 AI 帖并入库,返回实际生成数
func (s *Service) GenerateCommunityPosts(n int) (int, error) {
if n <= 0 {
return 0, nil
}
var bots []model.User
if err := s.db.Where("is_bot = ?", true).Find(&bots).Error; err != nil {
return 0, err
}
if len(bots) == 0 {
return 0, nil
}
made := 0
for i := 0; i < n; i++ {
bot := bots[rand.Intn(len(bots))]
content, tag := s.genOnePostContent()
if content == "" {
continue
}
tagsJSON, _ := json.Marshal([]string{tag})
imagesJSON, _ := json.Marshal(pickPhotos())
p := model.Post{
UserID: bot.ID, AuthorName: bot.Nickname, AuthorEmoji: bot.Avatar,
Identity: "petName", Content: content, Tags: datatypes.JSON(tagsJSON),
Images: datatypes.JSON(imagesJSON),
LikeCount: rand.Intn(40), Status: model.PostPublished, IsAI: true,
}
if err := s.db.Create(&p).Error; err != nil {
return made, err
}
made++
}
return made, nil
}
// 配图 emoji 池(App 用 emoji 当照片渲染),当作帖子的「图」
var photoPool = []string{"🐱", "🐶", "🐈", "🐕", "🍚", "🦴", "🧶", "🪀", "🏠", "🌿", "🛁", "💊", "🐾", "🍗", "🥩", "🧸", "☀️", "🛏️"}
// pickPhotos ~65% 概率随机贴 1-3 张配图,其余纯文字(更自然)
func pickPhotos() []string {
if rand.Float64() > 0.65 {
return []string{}
}
n := 1 + rand.Intn(3)
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, photoPool[rand.Intn(len(photoPool))])
}
return out
}
// genOnePostContent 生成一条文案 + 标签;AI 不可用/失败回退内置文案
func (s *Service) genOnePostContent() (string, string) {
tags := []string{"晒宠", "求助", "经验", "避坑"}
tag := tags[rand.Intn(len(tags))]
if !s.ai.Enabled() {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
topic := botTopics[rand.Intn(len(botTopics))]
system := aiSafetyPrompt + `
你在为宠物社区「宠友圈」生成一条真实、口语化的养宠动态,像普通铲屎官发的朋友圈。
要求:40-120 字;真实有细节,可带 1-2 个 emoji;涉及异常要顺带提醒何时就医;不要标题、不要话题标签、不要营销口吻。
只输出 JSON:{"content":"","tag":"晒宠|求助|经验|避坑"}`
user := fmt.Sprintf("主题:%s。请生成一条。", topic)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
var r struct {
Content string `json:"content"`
Tag string `json:"tag"`
}
if err := json.Unmarshal([]byte(extractJSON(out)), &r); err != nil || r.Content == "" {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
if r.Tag != "" {
tag = r.Tag
}
return r.Content, tag
}
// RunCommunityBotTick 定时器每次调用:白天窗口内按剩余名额概率投放,天然打散在白天
func (s *Service) RunCommunityBotTick() {
cfg := s.getBotConfig()
if !cfg.Enabled || cfg.DailyCount <= 0 {
return
}
now := time.Now()
h := now.Hour()
if h < cfg.StartHour || h >= cfg.EndHour {
return
}
today := s.countAIPostsToday()
if today >= int64(cfg.DailyCount) {
return
}
remaining := cfg.DailyCount - int(today)
minsLeft := (cfg.EndHour-h)*60 - now.Minute()
ticksLeft := minsLeft / botTickMinutes
if ticksLeft < 1 {
ticksLeft = 1
}
if rand.Float64() <= float64(remaining)/float64(ticksLeft) {
_, _ = s.GenerateCommunityPosts(1)
}
}
// StartCommunityBot 启动后台定时投放协程
func (s *Service) StartCommunityBot() {
go func() {
for {
s.RunCommunityBotTick()
time.Sleep(botTickMinutes * time.Minute)
}
}()
}
-50
View File
@@ -1,50 +0,0 @@
package service
import (
"errors"
"strings"
"github.com/sundynix/pets-be/internal/model"
)
// CreateFeedback 提交意见反馈
func (s *Service) CreateFeedback(userID, content, contact string) (*model.Feedback, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, errors.New("反馈内容不能为空")
}
if len([]rune(content)) > 1000 {
return nil, errors.New("反馈内容太长了")
}
name := ""
var u model.User
if err := s.db.Select("nickname").First(&u, "id = ?", userID).Error; err == nil {
name = u.Nickname
}
f := model.Feedback{UserID: userID, Nickname: name, Content: content, Contact: strings.TrimSpace(contact)}
if err := s.db.Create(&f).Error; err != nil {
return nil, err
}
return &f, nil
}
// ListFeedbackAdmin 后台反馈分页
func (s *Service) ListFeedbackAdmin(offset, limit int) ([]model.Feedback, int64, error) {
var total int64
s.db.Model(&model.Feedback{}).Count(&total)
var list []model.Feedback
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
return list, total, err
}
// SetFeedbackHandled 标记反馈已处理
func (s *Service) SetFeedbackHandled(id string, handled bool) error {
res := s.db.Model(&model.Feedback{}).Where("id = ?", id).Update("handled", handled)
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
-92
View File
@@ -1,92 +0,0 @@
package service
import (
"bytes"
"crypto/md5"
"encoding/hex"
"errors"
"io"
"path"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 允许的图片扩展名(服务端兜底校验,前端已限制仅图片)
var imageExts = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
".webp": true, ".bmp": true, ".heic": true, ".heif": true,
}
// UploadFile 上传文件并按 MD5 去重:内容相同直接返回已存记录,不重复占用 MinIO 空间。
func (s *Service) UploadFile(reader io.Reader, filename, contentType string) (*model.File, error) {
data, err := io.ReadAll(reader)
if err != nil {
return nil, err
}
sum := md5.Sum(data)
md5hex := hex.EncodeToString(sum[:])
// 已有相同内容 → 直接复用(URL 按当前配置重建,避免旧地址)
var existing model.File
if err := s.db.Where("md5 = ?", md5hex).First(&existing).Error; err == nil {
existing.URL = s.storage.PublicURL(existing.ObjectName)
return &existing, nil
}
ext := strings.ToLower(path.Ext(filename))
if !imageExts[ext] && !strings.HasPrefix(contentType, "image/") {
return nil, errors.New("只允许上传图片文件")
}
if contentType == "" {
contentType = "application/octet-stream"
}
// 按上传月份分文件夹:uploads/2026-07/<md5>.jpg。文件名仍用 md5 保证唯一。
// 同内容命中上面的去重直接复用,不会因为跨月再传一份
objectName := "uploads/" + time.Now().Format("2006-01") + "/" + md5hex + ext
url, err := s.storage.Upload(objectName, bytes.NewReader(data), int64(len(data)), contentType)
if err != nil {
return nil, err
}
f := model.File{
MD5: md5hex, ObjectName: objectName, URL: url,
Size: int64(len(data)), ContentType: contentType, Ext: ext,
}
if err := s.db.Create(&f).Error; err != nil {
return nil, err
}
return &f, nil
}
// fileURL 按 file_id 取 URL(由 object_name + 当前配置拼出,空 id 或不存在返回空串)
func (s *Service) fileURL(id string) string {
if id == "" {
return ""
}
var f model.File
if err := s.db.Select("object_name").First(&f, "id = ?", id).Error; err != nil {
return ""
}
return s.storage.PublicURL(f.ObjectName)
}
// fileURLs 批量按 file_id 取 URL
func (s *Service) fileURLs(ids []string) map[string]string {
out := map[string]string{}
clean := make([]string, 0, len(ids))
for _, id := range ids {
if id != "" {
clean = append(clean, id)
}
}
if len(clean) == 0 {
return out
}
var files []model.File
s.db.Select("id, object_name").Where("id IN ?", clean).Find(&files)
for _, f := range files {
out[f.ID] = s.storage.PublicURL(f.ObjectName)
}
return out
}
-70
View File
@@ -1,70 +0,0 @@
package service
import (
"errors"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// FollowUser 关注某人(幂等;不能关注自己)
func (s *Service) FollowUser(followerID, followeeID string) error {
if followerID == "" || followeeID == "" {
return errors.New("参数不完整")
}
if followerID == followeeID {
return errors.New("不能关注自己")
}
var u model.User
if err := s.db.Select("id").First(&u, "id = ?", followeeID).Error; err != nil {
return ErrNotFound
}
f := model.Follow{FollowerID: followerID, FolloweeID: followeeID}
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
FirstOrCreate(&f).Error
}
// UnfollowUser 取消关注(幂等)
func (s *Service) UnfollowUser(followerID, followeeID string) error {
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
Delete(&model.Follow{}).Error
}
// followeeIDs 我关注的所有人
func (s *Service) followeeIDs(userID string) []string {
var ids []string
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Pluck("followee_id", &ids)
return ids
}
// markFollowed 标记帖子作者是否已被我关注(用于前端显示「关注」按钮)
func (s *Service) markFollowed(userID string, posts []model.Post) {
if userID == "" || len(posts) == 0 {
return
}
authors := make([]string, 0, len(posts))
for i := range posts {
if posts[i].UserID != "" && posts[i].UserID != userID {
authors = append(authors, posts[i].UserID)
}
}
if len(authors) == 0 {
return
}
var followed []string
s.db.Model(&model.Follow{}).
Where("follower_id = ? AND followee_id IN ?", userID, authors).
Pluck("followee_id", &followed)
set := make(map[string]bool, len(followed))
for _, id := range followed {
set[id] = true
}
for i := range posts {
posts[i].IsSelf = posts[i].UserID == userID
posts[i].Followed = set[posts[i].UserID]
}
}
// ensureNotFound 供 handler 区分错误类型
var _ = gorm.ErrRecordNotFound
-247
View File
@@ -1,247 +0,0 @@
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"`
Date string `json:"date"` // YYYY-MM-DD,供首页点击查当日任务
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 string) (*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 string) 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 string, now time.Time) []HomeInsight {
insights := make([]HomeInsight, 0, 3)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// 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 string, now time.Time) int {
streak := 0
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for i := 0; i < 60; i++ {
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 string, now time.Time) []WeekDay {
// 本周一为起点
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
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(),
Date: d.Format("2006-01-02"),
Active: d.Equal(today),
HasDot: dotSet[d.Format("2006-01-02")],
}
}
return week
}
// datesWithActivity 区间内有任务/记录/提醒的日期集合
func (s *Service) datesWithActivity(petID string, start, end time.Time) map[string]bool {
set := map[string]bool{}
var ts []time.Time
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
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
}
}
// 30 天计划路线图节点也算「有活动」,让首页周历与计划日历一致
for _, d := range s.planNodeDates(petID, start, end) {
set[d.Format("2006-01-02")] = true
}
return set
}
func (s *Service) todayCompletionPct(petID string, now time.Time) int {
// 任务是 PlanTask(DailyTask 已空)。取宠物的 active 30 天计划,算今天那天的完成占比
var plan model.Plan
if s.db.Where("pet_id = ? AND kind = ? AND status = ?", petID, model.PlanThirtyDay, "active").
Order("id desc").First(&plan).Error != nil {
return 0
}
day := dayIndexOf(&plan, now)
if day < 0 {
return 0
}
var total, done int64
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ?", plan.ID, day).Count(&total)
if total == 0 {
return 0
}
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, 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"`
Following int64 `json:"following"` // 我关注了多少人
Followers int64 `json:"followers"` // 多少人关注我
ProStatus string `json:"pro_status"`
}
// GetUserSummary 汇总当前用户名下计数
func (s *Service) GetUserSummary(userID string) (*UserSummary, error) {
var sum UserSummary
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
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
}
-174
View File
@@ -1,174 +0,0 @@
package service
import (
"log"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// IDCard 宠物身份卡的全部内容,前端拿去 canvas 上画
type IDCard struct {
Name string `json:"name"`
Species string `json:"species"` // 猫猫 / 狗狗
Gender string `json:"gender"`
Breed string `json:"breed"`
Birthday string `json:"birthday"` // "2023 年 12 月 15 日"
Age string `json:"age"` // 年龄,如 "2岁7个月"
Days int `json:"days"` // 一起生活的天数(按到家日)
Zodiac string `json:"zodiac"` // 星座,按生日算
Weight string `json:"weight"` // 体重,如 "13.5kg"
Intro string `json:"intro"`
Color string `json:"color"` // 毛色
Personality string `json:"personality"` // 性格标签,逗号分隔
AvatarURL string `json:"avatar_url"`
IDNo string `json:"id_no"` // 玩具身份号,不入库
Validity string `json:"validity"` // "2023.12.15 - 永远"
// —— 背面:照护信息 ——
Owner string `json:"owner"` // 主人昵称
Phone string `json:"phone"` // 联系电话(脱敏)
Allergy string `json:"allergy"` // 过敏情况
Notes string `json:"notes"` // 备注
VaccineState string `json:"vaccine_state"` // 疫苗情况
DewormState string `json:"deworm_state"` // 驱虫情况
Org string `json:"org"` // 发证机构
QRURL string `json:"qr_url"` // 小程序码,未发布时为空
QRReady bool `json:"qr_ready"` // 码有没有生成出来
}
// petIDNo 造一个稳定的「身份号」。纯装饰,不入库、不做校验。
// 规律照竞品那种:一串数字,扫不出真实含义但看着像证件号。
// 用 宠物雪花 id 的数字 + 生日 拼,同一只宠物每次都一样
func petIDNo(pet *model.Pet) string {
digits := make([]byte, 0, 18)
for i := 0; i < len(pet.ID) && len(digits) < 10; i++ {
if pet.ID[i] >= '0' && pet.ID[i] <= '9' {
digits = append(digits, pet.ID[i])
}
}
for len(digits) < 10 {
digits = append(digits, '0')
}
bd := "00000000"
if pet.Birthday != nil && !pet.Birthday.IsZero() {
bd = pet.Birthday.Format("20060102")
}
raw := string(digits[:10]) + bd // 18 位
// 每 4 位空一下,像证件号
var b strings.Builder
for i, c := range raw {
if i > 0 && i%4 == 0 {
b.WriteByte(' ')
}
b.WriteRune(c)
}
return b.String()
}
// zodiacOf 按月日算西方星座。按各星座起始日(含)顺序判定,
// 落在 12/22–1/19 的默认摩羯座
func zodiacOf(month, day int) string {
starts := []struct {
m, d int
name string
}{
{1, 20, "水瓶座"}, {2, 19, "双鱼座"}, {3, 21, "白羊座"}, {4, 20, "金牛座"},
{5, 21, "双子座"}, {6, 22, "巨蟹座"}, {7, 23, "狮子座"}, {8, 23, "处女座"},
{9, 23, "天秤座"}, {10, 24, "天蝎座"}, {11, 22, "射手座"}, {12, 22, "摩羯座"},
}
name := "摩羯座"
for _, s := range starts {
if month > s.m || (month == s.m && day >= s.d) {
name = s.name
}
}
return name
}
// maskPhone 脱敏手机号:138****8888。空或非 11 位原样返回
func maskPhone(p string) string {
if len(p) != 11 {
return p
}
return p[:3] + "****" + p[7:]
}
// GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡
func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) {
pet, err := s.GetPet(userID, petID)
if err != nil {
return nil, err
}
intro := "地球来了个新居民~"
switch pet.Type {
case "狗狗":
intro = "汪星人来地球啦~"
case "猫猫":
intro = "喵星人来地球啦~"
}
bd, validity := "未知", "永久有效"
if pet.Birthday != nil && !pet.Birthday.IsZero() {
bd = pet.Birthday.Format("2006 年 1 月 2 日")
validity = pet.Birthday.Format("2006.01.02") + " - 永远"
}
zodiac := ""
if pet.Birthday != nil && !pet.Birthday.IsZero() {
zodiac = zodiacOf(int(pet.Birthday.Month()), pet.Birthday.Day())
}
// 主人昵称 + 脱敏电话
var user model.User
s.db.First(&user, userID)
owner := user.Nickname
if owner == "" {
owner = "铲屎官"
}
// 疫苗:有记录算已接种;驱虫:取下一次到期提醒的日期
var vaccineCnt int64
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineCnt)
vaccineState := "待接种"
if vaccineCnt > 0 {
vaccineState = "已接种"
}
dewormState := "—"
var dw model.Reminder
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dw).Error; err == nil && dw.NextDueDate != nil {
dewormState = dw.NextDueDate.Format("2006.01.02")
}
allergy := pet.Allergy
if allergy == "" {
allergy = "无"
}
// 一起生活的天数:按到家日算,没填就 0
days := 0
if pet.ArrivedAt != nil && !pet.ArrivedAt.IsZero() {
days = int(time.Since(*pet.ArrivedAt).Hours()/24) + 1
}
card := &IDCard{
Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed,
Birthday: bd, Age: pet.Age, Days: days, Zodiac: zodiac, Weight: pet.Weight, Intro: intro,
Color: pet.Color, Personality: pet.Personality,
AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity,
Owner: owner, Phone: user.Phone, Allergy: allergy, Notes: pet.Notes,
VaccineState: vaccineState, DewormState: dewormState, Org: "肉垫计划",
}
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
if url, e := s.PetQRCode(petID); e == nil {
card.QRURL = url
card.QRReady = true
} else {
log.Printf("[info] 宠物 %s 的小程序码暂不可用(多为小程序未发布):%v", petID, e)
}
return card, nil
}
-287
View File
@@ -1,287 +0,0 @@
package service
import (
"fmt"
"sort"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// Insight 一条健康洞察。
// 这是「记录」这件事的兑现方式:用户记了几十条数据,之前只换回一条折线,
// 数据没有回到他身上。洞察要说出他自己看不出来的东西。
type Insight struct {
Kind string `json:"kind"` // trend | correlation | overdue | benchmark | habit
Level string `json:"level"` // info | warn | alert
Title string `json:"title"` // 一句话结论
Detail string `json:"detail"` // 依据 + 建议
Action string `json:"action"` // 关联的记录弹层类型,前端据此给按钮
Evidence string `json:"evidence"` // 支撑这条结论的具体数据
}
const (
insightInfo = "info"
insightWarn = "warn"
insightAlert = "alert"
)
// PetInsights 汇总一只宠物当前值得说的事。按严重度排序,最多 6 条。
func (s *Service) PetInsights(userID, petID string) ([]Insight, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 只看最近 90 天。更早的数据对「现在怎么样」没有参考价值,
// 还会让关联分析找出一堆巧合。
since := time.Now().AddDate(0, 0, -90)
var records []model.HealthRecord
if err := s.db.Where("pet_id = ? AND occurred_at >= ?", petID, since).
Order("occurred_at asc").Find(&records).Error; err != nil {
return nil, err
}
var out []Insight
out = appendIf(out, s.weightTrendInsight(records))
out = appendIf(out, s.foodPoopCorrelation(records))
out = appendIf(out, s.symptomClusterInsight(records))
out = appendIf(out, s.overdueReminderInsight(petID))
out = appendIf(out, s.weightBenchmark(pet, records))
out = appendIf(out, s.recordHabitInsight(records))
// alert > warn > info,同级保持原顺序
rank := map[string]int{insightAlert: 0, insightWarn: 1, insightInfo: 2}
sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Level] < rank[out[j].Level] })
if len(out) > 6 {
out = out[:6]
}
return out, nil
}
func appendIf(list []Insight, in *Insight) []Insight {
if in != nil {
list = append(list, *in)
}
return list
}
func byType(records []model.HealthRecord, t string) []model.HealthRecord {
var out []model.HealthRecord
for _, r := range records {
if r.Type == t {
out = append(out, r)
}
}
return out
}
// weightTrendInsight 体重趋势。单次数字没意义,连续同向变化才有。
func (s *Service) weightTrendInsight(records []model.HealthRecord) *Insight {
w := byType(records, model.RecordWeight)
if len(w) < 3 {
return nil
}
first, last := w[0], w[len(w)-1]
if first.NumValue <= 0 || last.NumValue <= 0 {
return nil
}
days := int(last.OccurredAt.Sub(first.OccurredAt).Hours() / 24)
if days < 14 {
return nil // 时间跨度太短,波动多半是称重误差
}
pct := (last.NumValue - first.NumValue) / first.NumValue * 100
ev := fmt.Sprintf("%d 天内 %.2fkg → %.2fkg(%d 次记录)", days, first.NumValue, last.NumValue, len(w))
switch {
case pct <= -10:
return &Insight{
Kind: "trend", Level: insightAlert, Action: "symptom",
Title: fmt.Sprintf("体重掉了 %.0f%%,建议就医检查", -pct),
Detail: "短期内明显掉秤,常见于甲亢、慢性肾病、糖尿病、寄生虫和口腔问题。即使精神食欲看起来正常,也建议做一次血检。",
Evidence: ev,
}
case pct >= 15:
return &Insight{
Kind: "trend", Level: insightWarn, Action: "food",
Title: fmt.Sprintf("体重涨了 %.0f%%,注意控制", pct),
Detail: "超重是绝大多数慢性病的起点。摸得到肋骨但看不见明显轮廓才是标准体型,先从减少 10-20% 的喂食量开始。",
Evidence: ev,
}
case pct <= -5:
return &Insight{
Kind: "trend", Level: insightWarn, Action: "weight",
Title: fmt.Sprintf("体重在缓慢下降(%.0f%%)", -pct),
Detail: "还不到紧急程度,但值得继续盯。保持每周称重,如果一个月内继续下降就去做次体检。",
Evidence: ev,
}
}
return &Insight{
Kind: "trend", Level: insightInfo, Action: "weight",
Title: "体重保持稳定",
Detail: "这段时间的波动在正常范围内,继续保持现在的喂食量和运动量。",
Evidence: ev,
}
}
// foodPoopCorrelation 换粮/饮食变化 → 排便异常的时间关联。
// 这是用户自己最看不出来的一类:两条记录隔了两三天,翻时间轴翻不出规律。
func (s *Service) foodPoopCorrelation(records []model.HealthRecord) *Insight {
foods := byType(records, model.RecordFood)
poops := byType(records, model.RecordPoop)
if len(foods) == 0 || len(poops) == 0 {
return nil
}
for _, p := range poops {
if p.Category == "" || p.Category == "正常" {
continue
}
// 往前找 3 天内的饮食记录
for i := len(foods) - 1; i >= 0; i-- {
f := foods[i]
gap := p.OccurredAt.Sub(f.OccurredAt)
if gap < 0 || gap > 72*time.Hour {
continue
}
return &Insight{
Kind: "correlation", Level: insightWarn, Action: "food",
Title: fmt.Sprintf("这次「%s」之前 %d 天有过饮食变化", p.Category, int(gap.Hours()/24)),
Detail: "换粮或加新食物引起的软便很常见。回到原来的粮,之后按 7 天过渡:每天替换约 1/7,软便就放慢速度。持续超过 3 天或带血要就医。",
Evidence: fmt.Sprintf("%s 记录「%s」→ %s 出现「%s」",
f.OccurredAt.Format("1月2日"), f.Title, p.OccurredAt.Format("1月2日"), p.Category),
}
}
}
return nil
}
// symptomClusterInsight 异常记录扎堆。单次异常不说明什么,短期内反复才是信号。
func (s *Service) symptomClusterInsight(records []model.HealthRecord) *Insight {
sym := byType(records, model.RecordSymptom)
if len(sym) < 2 {
return nil
}
recent := 0
cut := time.Now().AddDate(0, 0, -14)
var last time.Time
for _, r := range sym {
if r.OccurredAt.After(cut) {
recent++
last = r.OccurredAt
}
}
if recent < 2 {
return nil
}
return &Insight{
Kind: "habit", Level: insightAlert, Action: "symptom",
Title: fmt.Sprintf("两周内记了 %d 次异常", recent),
Detail: "反复出现的异常和偶发一次完全不是一回事。把这些记录导出成就医摘要带去医院,比口头描述有用得多。",
Evidence: fmt.Sprintf("最近一次:%s", last.Format("1月2日")),
}
}
// overdueReminderInsight 已经过期的提醒。设了不看等于没设。
func (s *Service) overdueReminderInsight(petID string) *Insight {
var rems []model.Reminder
if err := s.db.Where("pet_id = ? AND next_due_date IS NOT NULL AND next_due_date < ?",
petID, time.Now()).Order("next_due_date asc").Find(&rems).Error; err != nil {
return nil
}
if len(rems) == 0 {
return nil
}
r := rems[0]
days := int(time.Since(*r.NextDueDate).Hours() / 24)
action := "vaccine"
if r.Type == model.ReminderDeworm {
action = "medicine"
}
return &Insight{
Kind: "overdue", Level: insightAlert, Action: action,
Title: fmt.Sprintf("「%s」已经过期 %d 天", r.Title, days),
Detail: "疫苗和驱虫拖久了保护力会断档,尤其驱虫,遛弯和接触其它动物的风险是持续的。补上之后记得把下次日期也设好。",
Evidence: fmt.Sprintf("原定 %s", r.NextDueDate.Format("2006年1月2日")),
}
}
// weightBenchmark 同龄同物种对比。
// 这是单机数据做不到、只有平台侧能给的价值。样本不足就不给结论——
// 拿 3 只猫算出来的「中位数」比不给还糟。
func (s *Service) weightBenchmark(pet *model.Pet, records []model.HealthRecord) *Insight {
w := byType(records, model.RecordWeight)
if len(w) == 0 || pet.Birthday == nil || pet.Birthday.IsZero() {
return nil
}
mine := w[len(w)-1].NumValue
if mine <= 0 {
return nil
}
months := monthsSince(*pet.Birthday)
// 同物种、月龄相差 2 个月以内的其它宠物,取各自最近一次体重
lo := time.Now().AddDate(0, -(months + 2), 0)
hi := time.Now().AddDate(0, -maxInt(months-2, 0), 0)
var peers []float64
rows, err := s.db.Raw(`
SELECT r.num_value FROM sundynix_health_records r
JOIN (
SELECT pet_id, MAX(occurred_at) AS t FROM sundynix_health_records
WHERE type = ? GROUP BY pet_id
) latest ON latest.pet_id = r.pet_id AND latest.occurred_at = latest.t
JOIN sundynix_pets p ON p.id = r.pet_id
WHERE r.type = ? AND r.num_value > 0 AND p.id <> ?
AND p.type = ? AND p.birthday BETWEEN ? AND ?`,
model.RecordWeight, model.RecordWeight, pet.ID, pet.Type, lo, hi).Rows()
if err != nil {
return nil
}
defer rows.Close()
for rows.Next() {
var v float64
if rows.Scan(&v) == nil && v > 0 {
peers = append(peers, v)
}
}
// 样本太少给不出可信的参考值,宁可不显示
if len(peers) < 8 {
return nil
}
sort.Float64s(peers)
med := peers[len(peers)/2]
diff := (mine - med) / med * 100
level, title := insightInfo, "体重和同龄伙伴差不多"
if diff >= 20 {
level, title = insightWarn, fmt.Sprintf("比同龄伙伴重 %.0f%%", diff)
} else if diff <= -20 {
level, title = insightWarn, fmt.Sprintf("比同龄伙伴轻 %.0f%%", -diff)
}
return &Insight{
Kind: "benchmark", Level: level, Action: "weight",
Title: title,
Detail: "这只是同月龄的横向参考,品种和体型差异很大,不能当成标准。真正要看的还是它自己的体重曲线是否平稳。",
Evidence: fmt.Sprintf("%d 月龄 · 它 %.2fkg,%d 只同龄%s的中位数 %.2fkg",
months, mine, len(peers), pet.Type, med),
}
}
// recordHabitInsight 记录习惯。没数据就没洞察,这条负责把用户拉回来记。
func (s *Service) recordHabitInsight(records []model.HealthRecord) *Insight {
if len(records) >= 5 {
return nil
}
return &Insight{
Kind: "habit", Level: insightInfo, Action: "weight",
Title: "再记几条,这里就能看出规律了",
Detail: "体重、便便、饮食三类各记几次之后,能自动帮你发现「换粮第几天开始软便」这种自己翻记录看不出来的关联。",
Evidence: fmt.Sprintf("目前共 %d 条记录", len(records)),
}
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
-250
View File
@@ -1,250 +0,0 @@
package service
import (
"fmt"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 生命阶段的划分标准。
//
// 这张表既用来判定阶段,也直接吐给前端当「划分依据」展示 —— 一份数据两个用途,
// 而不是「代码里判定 + 文档里写标准」。后者一定会漂:改了阈值忘了改文档,
// 用户看到的说明和实际行为对不上,比没有说明更糟。
//
// ── 为什么猫狗不能共用阈值 ──
//
// 猫的发育在品种间差别不大。狗完全取决于体型:
// 吉娃娃 8 个月已经性成熟、可以换成犬粮;大丹犬 8 个月还在长骨头,
// 这时候喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
// 吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
// 大丹犬 7 岁已经是老年(平均寿命 8-10 年)。
//
// 原来代码里是一刀切「<12 月幼年,>=7 岁老年」,对猫勉强,对狗两头都错。
//
// 体型不能靠当前体重判断 —— 幼犬的体重说明不了它成年后多大。只能靠品种,
// 所以 breeds 表带 size_class。
// 五个阶段。标签故意做成物种中立(不分「幼猫期/幼犬期」):
// 物种本来就是另一个字段,分开会让 (species, stage) 的组合和模板配置都翻倍。
const (
StageJuvenile = "幼年期"
StageYoung = "青年期"
StageAdult = "成年期"
StageMature = "中年期"
StageSenior = "老年期"
// StageNewHome 和年龄正交:刚接回家的成年猫两套照护都需要。
// 它不参与年龄判定,是叠加上去的一层。
StageNewHome = "刚到家 0-30 天"
)
// CareStages 后台配方案时可选的阶段。刚到家放最前面,它是所有宠物都会经历的
var CareStages = []string{StageNewHome, StageJuvenile, StageYoung, StageAdult, StageMature, StageSenior}
// 犬类体型。分界取自成年体重,是行业通行的分法
const (
SizeSmall = "small" // < 10kg
SizeMedium = "medium" // 10-25kg
SizeLarge = "large" // 25-40kg
SizeGiant = "giant" // > 40kg
)
// SizeLabels 体型的中文名和依据,后台和小程序都用它
var SizeLabels = map[string]string{
SizeSmall: "小型犬(成年 <10kg)",
SizeMedium: "中型犬(成年 10-25kg)",
SizeLarge: "大型犬(成年 25-40kg)",
SizeGiant: "巨型犬(成年 >40kg)",
}
// StageRule 一个阶段的月龄区间和依据
type StageRule struct {
Stage string `json:"stage"`
FromMon int `json:"from_month"` // 含
ToMon int `json:"to_month"` // 不含;0 = 无上限
Range string `json:"range"` // 展示用,如「6-12 月」
Basis string `json:"basis"` // 划分依据,直接展示给用户
Focus string `json:"focus"` // 这个阶段的养护重点
}
// 猫:AAFP/AAHA 猫生命阶段指南那套,在「成年」里细分出中年
var catStages = []StageRule{
{StageJuvenile, 0, 6, "0-6 月", "断奶到六月龄是生长最快的阶段,三针核心疫苗和首次驱虫都在这里完成",
"疫苗序列、驱虫、社会化、少量多餐"},
{StageYoung, 6, 12, "6-12 月", "六月龄前后性成熟,绝育窗口;一岁前后换成猫粮",
"绝育安排、换成猫粮、开始控体重"},
{StageAdult, 12, 84, "1-7 岁", "身体状态最稳定的时期,重点是把基线数据记下来",
"每月称重、年度体检、口腔护理"},
{StageMature, 84, 132, "7-11 岁", "七岁起代谢变慢,年度血检开始能查出早期问题",
"年度血检、体重和牙结石重点看"},
{StageSenior, 132, 0, "11 岁以上", "十一岁后肾脏和甲状腺问题高发,体检频率要加倍",
"半年一次体检、肾和甲状腺、饮水量重点记"},
}
// 狗:同样五段,阈值按体型走。越大的犬种生长期越长、老得越早
var dogStages = map[string][]StageRule{
SizeSmall: {
{StageJuvenile, 0, 8, "0-8 月", "小型犬生长期短,八月龄基本定型", "疫苗序列、驱虫、社会化"},
{StageYoung, 8, 12, "8-12 月", "性成熟早,绝育窗口也早", "绝育安排、换成犬粮"},
{StageAdult, 12, 96, "1-8 岁", "小型犬寿命长,成年期也长", "每月称重、年度体检、牙结石"},
{StageMature, 96, 132, "8-11 岁", "八岁起进入中年,心脏和牙齿是重点", "年度血检、心脏听诊、洁牙"},
{StageSenior, 132, 0, "11 岁以上", "小型犬普遍能活到 13-16 岁,十一岁后算老年", "半年一次体检、关节和心脏"},
},
SizeMedium: {
{StageJuvenile, 0, 12, "0-12 月", "一岁前后完成生长", "疫苗序列、驱虫、社会化"},
{StageYoung, 12, 18, "12-18 月", "骨骼刚闭合,可以开始正常运动量", "换成犬粮、运动量上调"},
{StageAdult, 18, 84, "1.5-7 岁", "维持期", "每月称重、年度体检、牙结石"},
{StageMature, 84, 120, "7-10 岁", "七岁起体重和关节要重点看", "年度血检、体重控制、关节"},
{StageSenior, 120, 0, "10 岁以上", "中型犬平均寿命 10-13 岁", "半年一次体检、关节和肿瘤筛查"},
},
SizeLarge: {
{StageJuvenile, 0, 15, "0-15 月", "大型犬生长期长到 15 个月,这期间不能过量补钙或过早剧烈运动",
"控制生长速度、疫苗序列、避免跳跃"},
{StageYoung, 15, 24, "15-24 月", "两岁前骨骼才完全闭合", "换成犬粮、逐步加运动量"},
{StageAdult, 24, 72, "2-6 岁", "维持期较短", "每月称重、年度体检、髋关节"},
{StageMature, 72, 96, "6-8 岁", "六岁起就要按中年管,比中小型犬早", "年度血检、髋关节、体重控制"},
{StageSenior, 96, 0, "8 岁以上", "大型犬平均寿命 8-12 岁,八岁进老年", "半年一次体检、关节和心脏"},
},
SizeGiant: {
{StageJuvenile, 0, 18, "0-18 月", "巨型犬生长期最长,18 个月还在长;这阶段最忌过度补钙",
"严格控制生长速度、疫苗序列、限制跳跃"},
{StageYoung, 18, 24, "18-24 月", "两岁才算成年", "换成犬粮、关节保养开始"},
{StageAdult, 24, 60, "2-5 岁", "成年期很短", "每月称重、年度体检、心脏和关节"},
{StageMature, 60, 84, "5-7 岁", "五岁就要按中年管 —— 巨型犬老得最快", "半年一次血检、心脏、体重"},
{StageSenior, 84, 0, "7 岁以上", "巨型犬平均寿命 7-10 岁", "每半年体检、心脏和骨科肿瘤"},
},
}
// StageRules 某个物种(狗还要看体型)的完整划分标准,给前端展示用
func StageRules(species, sizeClass string) []StageRule {
if species == model.SpeciesDog {
if rs, ok := dogStages[sizeClass]; ok {
return rs
}
return dogStages[SizeMedium] // 品种没标体型时按中型算,最不容易错得离谱
}
return catStages
}
// stageOfMonths 按月龄在规则表里落位
func stageOfMonths(rules []StageRule, months int) string {
for _, r := range rules {
if months >= r.FromMon && (r.ToMon == 0 || months < r.ToMon) {
return r.Stage
}
}
return StageAdult
}
// StageFromAge 由物种、体型、月龄推断阶段
func StageFromAge(species, sizeClass string, months int) string {
if months < 0 {
return StageAdult // 没有生日只能按成年算,它是最不容易出错的默认
}
return stageOfMonths(StageRules(species, sizeClass), months)
}
// sizeClassOfBreed 查品种的体型。查不到返回空,上层按中型兜底
func (s *Service) sizeClassOfBreed(species, breed string) string {
if species != model.SpeciesDog || breed == "" {
return ""
}
var b model.Breed
if err := s.db.Where("species = ? AND name = ?", species, breed).First(&b).Error; err != nil {
return ""
}
return b.SizeClass
}
// StageOf 由生日推断阶段(狗会查品种体型)
func (s *Service) StageOf(species, breed string, birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return StageAdult
}
return StageFromAge(species, s.sizeClassOfBreed(species, breed), monthsSince(*birthday))
}
// NormalizeStage 定下最终使用的阶段。
// 用户手选的优先(领养的猫生日是估的,主人按体型判断更准;而模型里的月龄阈值
// 对混种和边界体型都是一刀切);没选或选了不认识的值才按生日推。
func (s *Service) NormalizeStage(species, breed, picked string, birthday *time.Time) string {
for _, v := range CareStages {
if picked == v {
return picked
}
}
return s.StageOf(species, breed, birthday)
}
// AgeLabel 月龄的中文展示。不入库 —— 存下来第二天就过期了
func AgeLabel(birthday *time.Time) string {
if birthday == nil || birthday.IsZero() {
return ""
}
m := monthsSince(*birthday)
if m < 0 {
return ""
}
if m < 24 {
return fmt.Sprintf("%d个月", m)
}
if m%12 == 0 {
return fmt.Sprintf("%d岁", m/12)
}
return fmt.Sprintf("%d岁%d个月", m/12, m%12)
}
// StageDrift 「档案里存的阶段」和「按生日算出来的阶段」不一致。
//
// 为什么不自动改:
// 1. 用户可能手改过 —— 领养的猫生日是估的,主人按体型判断比月龄阈值准
// 2. 已经排好的这个月计划不该被推翻。套用方案是「往里加」,不是「替换」
// 3. 偷偷换会让用户莫名发现计划变了,找不到原因
//
// 所以系统只负责发现,用户负责决定:小程序端提示「它已经进入成年期了,
// 要不要换一套方案」,点了才套用。
type StageDrift struct {
Drifted bool `json:"drifted"`
Stored string `json:"stored"` // 档案里现在存的
Computed string `json:"computed"` // 按生日算出来的
AgeLabel string `json:"age_label"` // 「1岁3个月」
Basis string `json:"basis"` // 新阶段的划分依据,一并给出来
Focus string `json:"focus"` // 新阶段的养护重点
Size string `json:"size"` // 犬类的体型说明,猫为空
}
// CheckStageDrift 检查某只宠物的阶段有没有漂
func (s *Service) CheckStageDrift(pet *model.Pet) *StageDrift {
out := &StageDrift{Stored: pet.Stage, AgeLabel: AgeLabel(pet.Birthday)}
if pet.Birthday == nil || pet.Birthday.IsZero() {
return out // 没生日算不了,不提示
}
// 「刚到家」是叠加层不是年龄段,处在这个状态时不谈漂移
if pet.Stage == StageNewHome {
return out
}
species := SpeciesOf(pet.Type)
size := s.sizeClassOfBreed(species, pet.Breed)
out.Computed = StageFromAge(species, size, monthsSince(*pet.Birthday))
if species == model.SpeciesDog {
if l, ok := SizeLabels[size]; ok {
out.Size = l
} else {
out.Size = SizeLabels[SizeMedium] + "(品种未标体型,按中型算)"
}
}
if out.Computed == out.Stored || out.Computed == "" {
return out
}
out.Drifted = true
for _, r := range StageRules(species, size) {
if r.Stage == out.Computed {
out.Basis = r.Basis
out.Focus = r.Focus
break
}
}
return out
}
-45
View File
@@ -1,45 +0,0 @@
package service
import (
"crypto/rand"
"fmt"
"math/big"
"github.com/sundynix/pets-be/internal/model"
)
// 新用户默认昵称的前缀。微信登录拿不到昵称(要用户主动授权),
// 全都叫「微信用户」的话,社区里一屏全是同名,后台也没法区分。
var nickPrefixes = []string{
"铲屎官", "猫奴", "狗子家长", "毛孩子家长", "养宠新手",
"肉垫爱好者", "撸猫达人", "遛狗选手", "猫窝管理员", "狗窝管理员",
}
// UniqueNickname 生成一个库里没有的昵称,形如「铲屎官12138」。
// 5 位数字 × 10 个前缀 = 百万级空间,撞了就重试;连撞 20 次说明
// 随机源出问题了,退回带更长数字的形式,绝不返回空。
func (s *Service) UniqueNickname() string {
for i := 0; i < 20; i++ {
name := fmt.Sprintf("%s%05d", pick(nickPrefixes), randInt(100000))
var count int64
if err := s.db.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
return name // 查不了就直接用,重名也比登录失败强
}
if count == 0 {
return name
}
}
return fmt.Sprintf("%s%09d", pick(nickPrefixes), randInt(1000000000))
}
func pick(list []string) string {
return list[randInt(int64(len(list)))]
}
func randInt(n int64) int64 {
v, err := rand.Int(rand.Reader, big.NewInt(n))
if err != nil {
return 0
}
return v.Int64()
}
-315
View File
@@ -1,315 +0,0 @@
package service
import (
"encoding/json"
"log"
"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
ArrivedAt *time.Time
Weight string
Stage string
Age string
Color string
Breed string
AvatarFileID string
Goals datatypes.JSON
Personality string
Allergy string
Notes string
}
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 string) ([]model.Pet, error) {
var pets []model.Pet
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
ids := make([]string, 0, len(pets))
for i := range pets {
ids = append(ids, pets[i].AvatarFileID)
}
urls := s.fileURLs(ids)
for i := range pets {
pets[i].AvatarURL = urls[pets[i].AvatarFileID]
// Age 和 Stage 都是「会过期」的值。Age 直接现算(不入库);
// Stage 存着但要校准 —— 半年前建档时是幼年期,现在可能已经进青年期了。
// 校准只改返回值不写库:用户手改过的阶段不该被静默冲掉,
// 「该换一套方案了」这件事要提示他、由他决定(见 StageDrift)
pets[i].Age = AgeLabel(pets[i].Birthday)
}
return pets, err
}
// GetPet 取单只宠物(校验归属)
func (s *Service) GetPet(userID, petID string) (*model.Pet, error) {
p, err := s.ownedPet(userID, petID)
if err == nil && p != nil {
p.AvatarURL = s.fileURL(p.AvatarFileID)
p.Age = AgeLabel(p.Birthday)
}
return p, err
}
// CreatePet 新增宠物并生成默认数据
func (s *Service) CreatePet(userID string, 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,
ArrivedAt: in.ArrivedAt,
Weight: normalizeWeight(in.Weight),
Stage: in.Stage,
Age: in.Age,
Color: in.Color,
Breed: in.Breed,
AvatarFileID: in.AvatarFileID,
Personality: in.Personality,
Allergy: in.Allergy,
Notes: in.Notes,
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
}
// Age 是计算字段,创建后的返回也得填 —— 不填前端拿到的是空字符串
pet.Age = AgeLabel(pet.Birthday)
pet.AvatarURL = s.fileURL(pet.AvatarFileID)
return &pet, nil
}
// UpdatePet 更新宠物字段
func (s *Service) UpdatePet(userID, petID string, fields map[string]any) (*model.Pet, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
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 string) error {
if _, err := s.ownedPet(userID, petID); err != nil {
return err
}
return s.db.Transaction(func(tx *gorm.DB) error {
// 先删计划明细(按 plan_id),再删计划
var planIDs []string
tx.Model(&model.Plan{}).Where("pet_id = ?", petID).Pluck("id", &planIDs)
if len(planIDs) > 0 {
if err := tx.Where("plan_id IN ?", planIDs).Delete(&model.PlanTask{}).Error; err != nil {
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 string, 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 天计划
// seedPetDefaults 建档时只做一件事:建「必须有」的到期提醒(疫苗、驱虫)。
//
// 原来它会自动把整套模板铺开成今日任务 + 30 天计划。改成不自动铺了——
// 用户在建档最后一步自己挑方案,或者选「先空着我自己排」。
// 自动铺的问题是:用户还没看懂这个 App 是干什么的,首页已经躺着十几条
// 他没答应过的任务,第一反应是「这些是什么」而不是「我该做什么」。
//
// 疫苗驱虫这两条例外,因为漏针是真会出事的,而且它们是「到期提醒」不是
// 「今天要做的事」——不占计划页的格子,只在到期时冒出来。
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
species := SpeciesOf(pet.Type)
items := s.careTemplateItems(species, pet.Stage)
if len(items) == 0 {
log.Printf("care: 养护方案为空 species=%s stage=%s,新宠 %s 建档后没有可选方案,请到后台配置",
species, pet.Stage, pet.ID)
}
goals := petGoals(pet)
now := time.Now()
var reminders []model.Reminder
seen := map[string]bool{}
for _, it := range items {
if it.Kind != model.CareKindReminder || seen[it.Title] {
continue
}
if !goalWantsReminder(goals, it.ReminderType) {
continue
}
seen[it.Title] = true
r := model.Reminder{
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
Title: it.Title, Frequency: it.Frequency,
}
if it.OffsetDays > 0 {
due := now.AddDate(0, 0, dueDaysFor(it, pet, now))
r.NextDueDate = &due
}
reminders = append(reminders, r)
}
if len(reminders) > 0 {
if err := tx.Create(&reminders).Error; err != nil {
return err
}
}
return nil
}
// ---- 建档默认值的几个判断 ----
// isNewHome 到家不足 30 天。没填到家日期的按「不是新到家」处理,
// 宁可少排几条安置期任务,也不要给养了三年的狗排「先只开放一个房间」。
func isNewHome(arrivedAt *time.Time, now time.Time) bool {
if arrivedAt == nil || arrivedAt.IsZero() {
return false
}
return now.Sub(*arrivedAt) < 30*24*time.Hour && !arrivedAt.After(now)
}
// petGoals 取建档时勾选的目标
func petGoals(pet *model.Pet) []string {
if len(pet.Goals) == 0 {
return nil
}
var goals []string
if err := json.Unmarshal(pet.Goals, &goals); err != nil {
return nil
}
return goals
}
func wantsGoal(goals []string, keyword string) bool {
for _, g := range goals {
if strings.Contains(g, keyword) {
return true
}
}
return false
}
// goalWantsReminder 目标 → 该不该建这类提醒。
// 一条都没勾(比如从「添加宠物」弹层建的档)时全部建,不做减法。
func goalWantsReminder(goals []string, reminderType string) bool {
if len(goals) == 0 {
return true
}
switch reminderType {
case model.ReminderVaccine, model.ReminderDeworm:
// 疫苗和驱虫不给关。狂犬是法定要求,漏驱虫是真会出事的,
// 不能因为用户建档时没勾「疫苗驱虫怕忘记」就干脆不提醒。
return true
case model.ReminderWeight:
return wantsGoal(goals, "体重") || wantsGoal(goals, "健康")
case model.ReminderMonthlyReport:
return wantsGoal(goals, "报告")
}
return true
}
// dueDaysFor 算这条提醒几天后到期。
// 模板里的 OffsetDays 是「典型情况」的天数,疫苗和驱虫必须按真实月龄修正——
// 原来不管多大都排「14 天后首针」,一只 8 月龄才接回家的猫会被安排去打首免。
func dueDaysFor(it model.CareTemplateItem, pet *model.Pet, now time.Time) int {
months := -1
if pet.Birthday != nil && !pet.Birthday.IsZero() {
months = monthsSince(*pet.Birthday)
}
switch it.ReminderType {
case model.ReminderVaccine:
if months < 0 {
return it.OffsetDays // 不知道月龄就按模板给的典型值
}
switch {
case months < 2:
// 首免 8 周龄起。还没到就等到那天,已经到了就尽快约。
if d := 56 - int(now.Sub(*pet.Birthday).Hours()/24); d > 0 {
return d
}
return 7
case months < 5:
return 21 // 幼崽针次之间隔 3-4 周
case months < 12:
return 14 // 月龄偏大但多半没打全,两周内补齐
default:
return it.OffsetDays // 成年后走模板:年度加强用频率而非到期日
}
case model.ReminderDeworm:
if months >= 0 && months < 6 {
return 30 // 6 月龄前每月一次
}
return it.OffsetDays
}
return it.OffsetDays
}
// reminderTitle 修正提醒标题。
// 阶段是用户可以手改的,改错了模板就会给一只 3 月龄的幼崽发「年度疫苗加强」。
// 到期日已经按月龄算过(见 dueDaysFor),标题也得跟上,否则用户照着标题去打错针。
func reminderTitle(it model.CareTemplateItem, pet *model.Pet) string {
if it.ReminderType != model.ReminderVaccine || pet.Birthday == nil || pet.Birthday.IsZero() {
return it.Title
}
months := monthsSince(*pet.Birthday)
if months >= 12 {
return it.Title
}
if months < 2 {
return "首针疫苗接种"
}
return "下一针疫苗(按月龄安排)"
}
-515
View File
@@ -1,515 +0,0 @@
package service
import (
"errors"
"math"
"strconv"
"strings"
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// TogglePlanTask 切换计划明细完成状态(校验归属)
func (s *Service) TogglePlanTask(userID, taskID string) 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
}
newDone := !pt.Done
if err := s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", newDone).Error; err != nil {
return err
}
// 勾选完成、且没关联记录类型的任务(自定义/无 sheet_type),补一条 note 记录,
// 让最近记录里留痕。有 sheet_type 的任务是走记录表单单独落记录的,这里不重复。
if newDone && pt.SheetType == "" {
_, _ = s.CreateRecord(userID, plan.PetID, RecordInput{Type: "note", Title: "完成:" + pt.Title})
}
return nil
}
// newStagePlan 按宠物当前阶段的养护模板生成一份 30 天计划(未保存)
// newStagePlan 只建一个空计划壳子(记住开始日,PlanTask.Day 是相对它算的)。
//
// 原来它顺手把整套模板铺成节点。那是第二条「自动铺」的路径——我把
// seedPetDefaults 改成不自动铺之后,建档后首页仍然躺着 5 条任务,就是这里干的。
// 惰性生成尤其难查:不是建档时发生的,是第一次读计划才发生的。
//
// 现在铺不铺由用户决定:建档最后一步挑方案,或者之后在计划页套用。
func (s *Service) newStagePlan(pet *model.Pet, start time.Time) model.Plan {
end := start.AddDate(0, 0, 30)
return model.Plan{
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
StartDate: &start, EndDate: &end, CompletionPct: 0, Status: "active",
}
}
// ensurePlan 计划到期自动续期:active 计划过期则归档并按当前阶段生成新一轮;无 active 则补建
func (s *Service) ensurePlan(pet *model.Pet) {
now := time.Now()
var plan model.Plan
err := s.db.Where("pet_id = ? AND kind = ? AND status = ?", pet.ID, model.PlanThirtyDay, "active").
Order("id desc").First(&plan).Error
if err == gorm.ErrRecordNotFound {
p := s.newStagePlan(pet, now)
s.db.Create(&p)
return
}
if err == nil && plan.EndDate != nil && plan.EndDate.Before(now) {
s.db.Model(&model.Plan{}).Where("id = ?", plan.ID).Update("status", "archived")
p := s.newStagePlan(pet, now)
s.db.Create(&p)
}
}
// GetPlan 取宠物的 30 天计划(含明细);过期自动续期
func (s *Service) GetPlan(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
err = s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Where("status = ?", "active").
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
}
// 计算每个路线图节点的真实日期(开始日 + Day),供前端展示并与日历对齐
if plan.StartDate != nil {
for i := range plan.Tasks {
plan.Tasks[i].Date = plan.StartDate.AddDate(0, 0, plan.Tasks[i].Day).Format("2006-01-02")
}
}
return &plan, nil
}
// planNodeDates 30 天计划路线图节点在 [start,end) 内的真实日期(开始日 + Day)
func (s *Service) planNodeDates(petID string, start, end time.Time) []time.Time {
var plan model.Plan
if err := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; err != nil || plan.StartDate == nil {
return nil
}
var out []time.Time
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
dd := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
if !dd.Before(start) && dd.Before(end) {
out = append(out, dd)
}
}
return out
}
// CalendarResult 日历视图
type CalendarResult struct {
Year int `json:"year"`
Month int `json:"month"`
TaskedDays []int `json:"tasked_days"`
Today int `json:"today"`
}
// Calendar 某月有任务/提醒的日期
func (s *Service) Calendar(userID, petID string, 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)
// 与首页周历同源:任务/记录/提醒/计划路线图节点
set := s.datesWithActivity(petID, start, end)
days := make([]int, 0, len(set))
for k := range set {
if t, err := time.ParseInLocation("2006-01-02", k, time.Local); err == nil {
days = append(days, t.Day())
}
}
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
}
// DayPlan 某一天的安排:计划路线图节点 + 今日任务 + 到期提醒
func (s *Service) DayPlan(userID, petID, dateStr string) (map[string]any, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
day, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return nil, ErrNotFound
}
next := day.AddDate(0, 0, 1)
nodes := []model.PlanTask{}
var plan model.Plan
if e := s.db.Preload("Tasks").
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
Order("id desc").First(&plan).Error; e == nil && plan.StartDate != nil {
for _, t := range plan.Tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
if d.Year() == day.Year() && d.YearDay() == day.YearDay() {
t.Date = d.Format("2006-01-02")
nodes = append(nodes, t)
}
}
}
var tasks []model.DailyTask
s.db.Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, day, next).Find(&tasks)
var reminders []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, day, next).Find(&reminders)
return map[string]any{
"date": dateStr,
"plan_nodes": nodes,
"tasks": tasks,
"reminders": reminders,
}, nil
}
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
func (s *Service) CreateAIPlan(userID, petID string, input string) (*model.Plan, error) {
if err := s.consumeAIQuota(userID, aiKindPlan); err != nil {
return nil, err
}
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 string) (*model.Plan, error) {
var plan model.Plan
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
if err == gorm.ErrRecordNotFound {
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,
}
}
// PlanTaskInput 用户自己加/改的计划节点
type PlanTaskInput struct {
Date string // "2026-08-05",落到 Day = 这天 - 计划开始日
Title string
Description string
SheetType string // 关联的记录类型 code,可空
// Daily 应用到每天:从 Date 起到窗口末尾,一天建一条。
// 不做「一条 + repeat 字段读时展开」是因为勾选状态是按天的——
// 那样得再开一张表记「哪天打过勾」,而直接建 N 条天然就有这个能力。
Daily bool
}
// planOf 取这只宠物当前的 30 天计划,顺带做归属校验
func (s *Service) planOf(userID, petID string) (*model.Plan, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
s.ensurePlan(pet)
var plan model.Plan
if e := s.db.Where("pet_id = ? AND kind = ? AND status = ?",
petID, model.PlanThirtyDay, "active").Order("id desc").First(&plan).Error; e != nil {
return nil, ErrNotFound
}
return &plan, nil
}
// 计划以「自然月」为单位管理。
//
// 不用「从计划开始日起 30 天」那种滚动窗口:计划可能是月中建的,那样窗口会
// 跨两个月,用户看着一个 7月26日—8月25日 的日历,既不像本月也不像下月,
// 「这个月还有什么没做」根本对不上。按自然月切,日历就是一眼能认的月历。
//
// 允许安排到下月末,否则「做下月计划」这件事本身做不了。
// monthBounds 某个月的首尾。off=0 本月,off=1 下月
func monthBounds(off int) (time.Time, time.Time) {
n := time.Now()
first := time.Date(n.Year(), n.Month(), 1, 0, 0, 0, 0, time.Local).AddDate(0, off, 0)
return first, first.AddDate(0, 1, -1)
}
// dayOffset 把日期换算成 PlanTask.Day(相对计划开始日)。
//
// 校验按**日期**而不是 Day 上限:Day 是相对计划开始日的,月份边界是相对今天的,
// 两者会错开。按 Day<=30 卡的话,计划建了几天之后下月末就换算成 Day=40 多,
// 用户在日历上明明看得到那格、点进去却存不了。
func dayOffset(plan *model.Plan, dateStr string) (int, error) {
if plan.StartDate == nil {
return 0, errors.New("计划还没有开始日期")
}
d, err := time.ParseInLocation("2006-01-02", dateStr, time.Local)
if err != nil {
return 0, ErrInvalidParam
}
st := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
0, 0, 0, 0, time.Local)
// 下界放到计划开始日而不是本月 1 号:模板生成的历史节点可能在上个月,
// 那些要允许原地改(改标题时日期会原样传回来)
if d.Before(st) {
return 0, errors.New("这天在计划开始之前,排不了")
}
_, nextEnd := monthBounds(1)
if d.After(nextEnd) {
return 0, errors.New("最远只能排到下个月月底(" + nextEnd.Format("1月2日") + ")")
}
return int(d.Sub(st).Hours() / 24), nil
}
// AddPlanTask 用户自己往计划里加。Daily=true 时从这天起到窗口末尾每天一条
func (s *Service) AddPlanTask(userID, petID string, in PlanTaskInput) ([]model.PlanTask, error) {
title := strings.TrimSpace(in.Title)
if title == "" {
return nil, ErrInvalidParam
}
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
day, err := dayOffset(plan, in.Date)
if err != nil {
return nil, err
}
// 要建几天。非 daily 就一天;daily 从这天排到窗口末尾
// 要建几天。daily 从这天排到**这一天所在那个月**的月底:
// 「应用到每天」说的是这个月每天,跨到下月去就变成用户没要求的事了
count := 1
if in.Daily {
from, e := time.ParseInLocation("2006-01-02", in.Date, time.Local)
if e != nil {
return nil, ErrInvalidParam
}
monthEnd := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, time.Local).
AddDate(0, 1, -1)
count = int(monthEnd.Sub(from).Hours()/24) + 1
if count < 1 {
count = 1
}
if count > 31 {
count = 31 // 兜一层,别让脏日期算出个巨大的数
}
}
out := make([]model.PlanTask, 0, count)
for i := 0; i < count; i++ {
d := day + i
out = append(out, model.PlanTask{
PlanID: plan.ID, Day: d, DayLabel: dayLabelFor(d),
Title: title, Description: strings.TrimSpace(in.Description),
SheetType: in.SheetType,
})
}
if err := s.db.Create(&out).Error; err != nil {
return nil, err
}
for i := range out {
out[i].Date = plan.StartDate.AddDate(0, 0, out[i].Day).Format("2006-01-02")
}
return out, nil
}
// UpdatePlanTask 改标题/描述/日期
func (s *Service) UpdatePlanTask(userID, taskID string, in PlanTaskInput) error {
plan, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
fields := map[string]any{}
if strings.TrimSpace(in.Title) != "" {
fields["title"] = strings.TrimSpace(in.Title)
}
fields["description"] = strings.TrimSpace(in.Description)
if in.Date != "" {
day, err := dayOffset(plan, in.Date)
if err != nil {
return err
}
fields["day"] = day
fields["day_label"] = dayLabelFor(day)
}
return s.db.Model(&model.PlanTask{}).Where("id = ?", t.ID).Updates(fields).Error
}
// DeletePlanTask 删掉一条
func (s *Service) DeletePlanTask(userID, taskID string) error {
_, t, err := s.ownedPlanTask(userID, taskID)
if err != nil {
return err
}
return s.db.Delete(&model.PlanTask{}, "id = ?", t.ID).Error
}
// ownedPlanTask 校验这条节点属于当前用户的宠物。
// 不校验的话,拿到别人的 task id 就能改删别人的计划
func (s *Service) ownedPlanTask(userID, taskID string) (*model.Plan, *model.PlanTask, error) {
var t model.PlanTask
if err := s.db.First(&t, "id = ?", taskID).Error; err != nil {
return nil, nil, ErrNotFound
}
var plan model.Plan
if err := s.db.First(&plan, "id = ?", t.PlanID).Error; err != nil {
return nil, nil, ErrNotFound
}
if _, err := s.ownedPet(userID, plan.PetID); err != nil {
return nil, nil, ErrForbidden
}
return &plan, &t, nil
}
// dayLabelFor 第 N 天的展示标签,和内置模板生成的那批保持一致
func dayLabelFor(day int) string {
switch {
case day == 0:
return "今天"
case day == 1:
return "明天"
default:
return "第 " + strconv.Itoa(day+1) + " 天"
}
}
// PlanMonthStatus 首页那个提醒角标要的东西
type PlanMonthStatus struct {
Month string `json:"month"` // "2026-07"
ThisMonth int `json:"this_month"` // 本月有几件
ThisUndone int `json:"this_undone"` // 本月还剩几件
NextMonth int `json:"next_month"` // 下月有几件
// NeedNext 该提醒用户排下月计划了。
// 只在月末最后 7 天提醒:月初就催「排下个月」太早,用户会当成噪音关掉。
NeedNext bool `json:"need_next"`
NextLabel string `json:"next_label"` // "8 月"
}
// PlanMonthStatus 统计本月/下月的计划件数
func (s *Service) PlanMonthStatus(userID, petID string) (*PlanMonthStatus, error) {
plan, err := s.planOf(userID, petID)
if err != nil {
return nil, err
}
thisFirst, thisEnd := monthBounds(0)
nextFirst, nextEnd := monthBounds(1)
var tasks []model.PlanTask
s.db.Where("plan_id = ?", plan.ID).Find(&tasks)
out := &PlanMonthStatus{
Month: thisFirst.Format("2006-01"),
NextLabel: nextFirst.Format("1 月"),
}
for _, t := range tasks {
d := plan.StartDate.AddDate(0, 0, t.Day)
switch {
case !d.Before(thisFirst) && !d.After(thisEnd):
out.ThisMonth++
if !t.Done {
out.ThisUndone++
}
case !d.Before(nextFirst) && !d.After(nextEnd):
out.NextMonth++
}
}
// 距月底还剩几天。用 24h 取整会在夏令时地区差一天,这里按日期差算
left := int(thisEnd.Sub(time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(),
0, 0, 0, 0, time.Local)).Hours()/24)
out.NeedNext = out.NextMonth == 0 && left <= 7
return out, nil
}
-70
View File
@@ -1,70 +0,0 @@
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 string) (*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 开通年费会员
// ActivatePro 开通会员。
// ⚠️ 支付尚未接入:在接上微信支付、且校验到真实支付成功之前,这里必须直接拒绝,
// 否则任何人调一次接口就白拿一年会员。接支付时把下面这段换成「校验支付单据后再写库」。
func (s *Service) ActivatePro(userID string) (*ProInfo, error) {
return nil, ErrPayNotReady
}
// activateProAfterPaid 支付成功后真正写入会员状态(当前无人调用,等接支付时使用)
func (s *Service) activateProAfterPaid(userID string) (*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
}
-133
View File
@@ -1,133 +0,0 @@
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
ImageFileID string
Extra datatypes.JSON
OccurredAt *time.Time
}
// ListRecords 列出宠物的健康记录(可按 type 过滤)
func (s *Service) ListRecords(userID, petID string, recordType string, offset, limit int) ([]model.HealthRecord, int64, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, 0, err
}
q := s.db.Model(&model.HealthRecord{}).Where("pet_id = ?", petID)
if recordType != "" {
q = q.Where("type = ?", recordType)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []model.HealthRecord
err := q.Order("occurred_at desc, id desc").Offset(offset).Limit(limit).Find(&records).Error
// 有 file_id 的记录,图片 URL 一律按当前配置重建(避免存旧地址)
ids := make([]string, 0)
for i := range records {
if records[i].ImageFileID != "" {
ids = append(ids, records[i].ImageFileID)
}
}
if len(ids) > 0 {
urls := s.fileURLs(ids)
for i := range records {
if u := urls[records[i].ImageFileID]; u != "" {
records[i].ImageURL = u
}
}
}
return records, total, err
}
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
func (s *Service) CreateRecord(userID, petID string, 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, ImageFileID: in.ImageFileID, Extra: in.Extra, OccurredAt: occurred,
}
// 传了 file_id 未传 url 时,用 file 表的 url 回填 ImageURL 便于直接展示
if in.ImageFileID != "" && rec.ImageURL == "" {
rec.ImageURL = s.fileURL(in.ImageFileID)
}
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&rec).Error; err != nil {
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 string) 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"`
Note string `json:"note"` // 记录时填的备注
}
// WeightTrend 最近 N 次体重(升序)
func (s *Service) WeightTrend(userID, petID string, 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, Note: records[i].Description})
}
return points, nil
}
-204
View File
@@ -1,204 +0,0 @@
package service
import (
"errors"
"log"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 分组展示顺序。前端按这个顺序渲染,不靠 group_key 的字典序
var recordGroupOrder = []struct {
Key string
Label string
}{
{model.RecordGroupDaily, "日常记录"},
{model.RecordGroupHealth, "健康记录"},
{model.RecordGroupCare, "洗护记录"},
{model.RecordGroupClean, "清洁记录"},
}
// RecordTypeGroup 一组类型,给小程序记录页直接渲染
type RecordTypeGroup struct {
Key string `json:"key"`
Label string `json:"label"`
Items []model.RecordType `json:"items"`
}
// parseFields 把后台那份紧凑写法解析成结构。
// 解析放服务端而不是前端:写错了要在后台保存时就报出来,
// 不能等用户点开表单才发现渲染不出东西
func parseFields(raw string) ([]model.RecordField, error) {
out := []model.RecordField{}
catTaken := false
for i, line := range strings.Split(raw, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
seg := strings.Split(line, ":")
no := strconv.Itoa(i + 1)
if len(seg) < 2 {
return nil, errors.New("第 " + no + " 行格式不对,至少要 类型:名称")
}
f := model.RecordField{Kind: strings.TrimSpace(seg[0]), Label: strings.TrimSpace(seg[1])}
if f.Label == "" {
return nil, errors.New("第 " + no + " 行缺名称")
}
extra := ""
if len(seg) > 2 {
extra = strings.TrimSpace(seg[2])
}
switch f.Kind {
case model.FieldNumber:
f.Unit = extra
// 数值一律落 num_value,不占 category
case model.FieldOptions:
for _, o := range strings.Split(extra, "|") {
if o = strings.TrimSpace(o); o != "" {
f.Options = append(f.Options, o)
}
}
if len(f.Options) == 0 {
return nil, errors.New("第 " + no + " 行 options 没有选项,用 | 分隔")
}
// 末段是 - 表示放弃 category
f.ToCategory = !(len(seg) > 3 && strings.TrimSpace(seg[3]) == "-")
case model.FieldText:
// 文本只进标题
default:
return nil, errors.New("第 " + no + " 行类型 " + f.Kind + " 不认识,只能是 number / options / text")
}
if f.ToCategory {
if catTaken {
return nil, errors.New("第 " + no + " 行:一个事项里只能有一个字段落 category," +
"多出来的那个末尾加 :- ")
}
catTaken = true
}
out = append(out, f)
}
return out, nil
}
// fillFields 读取时把解析结果挂上。解析失败不让整个接口挂掉——
// 那会让一条配错的数据把整个记录页打空;只让这一种事项没有额外字段
func fillFields(list []model.RecordType) {
for i := range list {
f, err := parseFields(list[i].Fields)
if err != nil {
log.Printf("[warn] 记录类型 %s 的字段配置解析失败,按无额外字段处理:%v", list[i].Code, err)
f = []model.RecordField{}
}
list[i].ParsedFields = f
}
}
// ListRecordTypes 小程序用:只返回启用的,按分组归好
func (s *Service) ListRecordTypes() []RecordTypeGroup {
var all []model.RecordType
s.db.Where("enabled = ?", true).Order("sort asc, id asc").Find(&all)
fillFields(all)
if len(all) == 0 {
// 表被清空了。这里不硬编码兜底一份——那等于把「可配置」又变回代码里的常量,
// 下次改配置的人会发现改了没用。宁可返回空让记录页明显是空的,也别静默用旧值。
log.Println("[warn] record_types 表为空,记录页会没有任何可记事项")
return []RecordTypeGroup{}
}
byGroup := map[string][]model.RecordType{}
for _, t := range all {
byGroup[t.GroupKey] = append(byGroup[t.GroupKey], t)
}
out := make([]RecordTypeGroup, 0, len(recordGroupOrder))
for _, g := range recordGroupOrder {
items := byGroup[g.Key]
if len(items) == 0 {
continue
}
out = append(out, RecordTypeGroup{Key: g.Key, Label: g.Label, Items: items})
}
return out
}
// AdminListRecordTypes 后台用:连禁用的一起返回,按分组顺序再按 sort
func (s *Service) AdminListRecordTypes() []model.RecordType {
var all []model.RecordType
s.db.Order("group_key asc, sort asc, id asc").Find(&all)
fillFields(all)
if all == nil {
return []model.RecordType{}
}
return all
}
// SaveRecordType 新增或更新。Code 一旦建过记录就不允许改——
// 改了历史记录的 type 会对不上任何类型,列表里直接变成一行没图标没名字的东西
func (s *Service) SaveRecordType(in *model.RecordType) error {
if in.Code == "" || in.Label == "" {
return ErrInvalidParam
}
if in.Form != model.RecordFormFull && in.Form != model.RecordFormSimple {
in.Form = model.RecordFormSimple
}
if in.GroupKey == "" {
in.GroupKey = model.RecordGroupDaily
}
// 字段配置写错了必须在这里挡住,不能存进库等用户点开表单才暴露
if _, err := parseFields(in.Fields); err != nil {
return err
}
if in.ID == "" {
// 新增:Code 不能和已有的撞
var n int64
s.db.Model(&model.RecordType{}).Where("code = ?", in.Code).Count(&n)
if n > 0 {
return errors.New("这个 code 已经存在了")
}
in.Locked = false // 新加的一律可删;locked 只由 bootstrap 给那 9 种
return s.db.Create(in).Error
}
var old model.RecordType
if err := s.db.First(&old, "id = ?", in.ID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if old.Code != in.Code {
var used int64
s.db.Model(&model.HealthRecord{}).Where("type = ?", old.Code).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 条记录用了这个 code,不能改,只能新建一个")
}
}
// locked 和 form 不由后台改:前者是代码依赖,后者对应弹层里真实存在的分支
return s.db.Model(&model.RecordType{}).Where("id = ?", in.ID).Updates(map[string]any{
"code": in.Code, "label": in.Label, "icon": in.Icon,
"group_key": in.GroupKey, "sort": in.Sort, "enabled": in.Enabled,
"fields": in.Fields,
}).Error
}
// DeleteRecordType locked 的删不了;已经有记录的也删不了(会让历史数据变孤儿)。
// 想让它从记录页消失应该用 enabled=false,历史记录还能正常显示
func (s *Service) DeleteRecordType(id string) error {
var t model.RecordType
if err := s.db.First(&t, "id = ?", id).Error; err != nil {
return ErrNotFound
}
if t.Locked {
return errors.New("「" + t.Label + "」有统计和分析逻辑依赖它,不能删。想隐藏请改成停用")
}
var used int64
s.db.Model(&model.HealthRecord{}).Where("type = ?", t.Code).Count(&used)
if used > 0 {
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 条「" + t.Label + "」记录,删了这些记录会变成孤儿。想隐藏请改成停用")
}
return s.db.Delete(&model.RecordType{}, "id = ?", id).Error
}
-64
View File
@@ -1,64 +0,0 @@
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 string) ([]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 string, 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 string, fields map[string]any) (*model.Reminder, error) {
var r model.Reminder
if err := s.db.Where("id = ? AND user_id = ?", reminderID, userID).First(&r).Error; err != nil {
return nil, ErrNotFound
}
if err := s.db.Model(&r).Updates(fields).Error; err != nil {
return nil, err
}
return &r, nil
}
// DeleteReminder 删除提醒
func (s *Service) DeleteReminder(userID, reminderID string) error {
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return ErrNotFound
}
return nil
}
-240
View File
@@ -1,240 +0,0 @@
package service
import (
"fmt"
"strings"
"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 string) (*WeeklyReport, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
weekAgo := time.Now().AddDate(0, 0, -7)
// 任务已并成 PlanTask(DailyTask 表已空),要经 plan 关联到宠物来统计本周完成数
var tasksCompleted int64
s.db.Model(&model.PlanTask{}).
Where("plan_id IN (SELECT id FROM sundynix_plans 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 := "稳定成长"
risk := "无高风险异常记录"
if highRisk > 0 {
status = "需要关注"
// 原来这句写死成「无高风险异常记录」,和上面刚算出来的 highRisk 自相矛盾:
// 记了 3 条高风险,摘要还说没有
risk = fmt.Sprintf("有 %d 条高风险异常记录", highRisk)
}
return &WeeklyReport{
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,%s。",
pet.Name, tasksCompleted, gain, risk),
TasksCompleted: tasksCompleted,
WeightGain: gain,
HighRiskCount: highRisk,
HealthStatus: status,
NextWeekFocus: s.nextWeekFocus(petID, highRisk, gain),
}, nil
}
// nextWeekFocus 下周重点。原来是一句写死的「第 2 针疫苗提醒、继续观察体重趋势、
// 避免频繁更换食物」——不管谁的宠物、多大年纪、有没有异常,看到的都是这句,
// 而且「第 2 针疫苗」对成年猫狗根本不适用。改成按真实数据拼。
func (s *Service) nextWeekFocus(petID string, highRisk int64, gain float64) string {
var parts []string
// 未来 7 天到期的提醒,是最具体的「下周要做什么」
now := time.Now()
var rems []model.Reminder
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date <= ?",
petID, now, now.AddDate(0, 0, 7)).Order("next_due_date asc").Limit(2).Find(&rems)
for _, r := range rems {
parts = append(parts, r.Title)
}
if highRisk > 0 {
parts = append(parts, "继续观察上周记录的异常,必要时就医")
}
// 一周没称过体重(gain 恰好为 0 也可能是真没变,所以查条数而不是看 gain)
var weighed int64
s.db.Model(&model.HealthRecord{}).
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, now.AddDate(0, 0, -7)).
Count(&weighed)
if weighed == 0 {
parts = append(parts, "本周还没称体重,补记一次")
}
if len(parts) == 0 {
return "各项都稳定,保持现在的记录节奏就好。"
}
return strings.Join(parts, ";") + "。"
}
// 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 string, 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 string) (*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"`
PetAvatarURL string `json:"pet_avatar_url"` // 上传过照片就有,海报优先用它,没有才用 emoji
Age string `json:"age"`
Weight string `json:"weight"`
Stage string `json:"stage"`
Period string `json:"period"` // 报告周期,如 "07.25 - 07.31"
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 string) (*Poster, error) {
pet, err := s.ownedPet(userID, petID)
if err != nil {
return nil, err
}
// 这是周报告,所有统计按近 7 天算(和报告页「本周概览」口径一致)
now := time.Now()
weekAgo := now.AddDate(0, 0, -7)
var tasksDone, weightRecs, vaccineRecs, highRisk int64
s.db.Model(&model.PlanTask{}).
Where("plan_id IN (SELECT id FROM sundynix_plans WHERE pet_id = ?) AND done = ? AND updated_at >= ?", petID, true, weekAgo).
Count(&tasksDone)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).Count(&weightRecs)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordVaccine, weekAgo).Count(&vaccineRecs)
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).Count(&highRisk)
// ownedPet 只填了 Age,头像 URL 得在这里从 file id 解析(否则是空的)
return &Poster{
PetName: pet.Name, PetEmoji: pet.Emoji, PetAvatarURL: s.fileURL(pet.AvatarFileID),
Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
Period: weekAgo.Format("01.02") + " - " + now.Format("01.02"),
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
HighRiskCount: highRisk, Headline: "稳定成长",
}, nil
}
-171
View File
@@ -1,171 +0,0 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/sundynix/pets-be/internal/model"
)
// 微信官方 UGC 内容安全:
// - msg_sec_check 文本,同步返回 pass/review/risky
// - media_check_async 图片,异步:先提交拿 trace_id,结果由微信 push 回调
//
// 口径:社区所有内容默认不自动放出,只有文本检测明确 pass 且没有图片时才自动过审;
// 有图片走异步检测,结果回来再决定;检测不了(如小程序未发布、无 openid)一律转人工。
// MsgSecCheck 文本安全检测(v2)。scene: 1资料 2评论 3论坛 4日志。
// 返回 suggest: pass / review / risky
func (s *Service) MsgSecCheck(content, openid string, scene int) (string, error) {
content = strings.TrimSpace(content)
if content == "" {
return "pass", nil // 没文本不用查
}
token, err := s.wechatAccessToken()
if err != nil {
return "", err
}
payload, _ := json.Marshal(map[string]any{
"content": content,
"version": 2,
"scene": scene,
"openid": openid,
})
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Result struct {
Suggest string `json:"suggest"`
Label int `json:"label"`
} `json:"result"`
}
if err := postJSON("https://api.weixin.qq.com/wxa/msg_sec_check?access_token="+token, payload, &out); err != nil {
return "", err
}
if out.ErrCode != 0 {
return "", fmt.Errorf("msg_sec_check(%d): %s", out.ErrCode, out.ErrMsg)
}
if out.Result.Suggest == "" {
return "pass", nil
}
return out.Result.Suggest, nil
}
// MediaCheckAsync 提交一张图片做异步检测,返回 trace_id。mediaType 2=图片
func (s *Service) MediaCheckAsync(mediaURL, openid string, scene int) (string, error) {
token, err := s.wechatAccessToken()
if err != nil {
return "", err
}
payload, _ := json.Marshal(map[string]any{
"media_url": mediaURL,
"media_type": 2,
"version": 2,
"scene": scene,
"openid": openid,
})
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
TraceID string `json:"trace_id"`
}
if err := postJSON("https://api.weixin.qq.com/wxa/media_check_async?access_token="+token, payload, &out); err != nil {
return "", err
}
if out.ErrCode != 0 {
return "", fmt.Errorf("media_check_async(%d): %s", out.ErrCode, out.ErrMsg)
}
return out.TraceID, nil
}
// moderateInitial 发布前的初判:跑文本检测,决定初始状态和是否要提交图片异步检测。
// 检测不了一律转 pending 人工审核——保证「必须通过后才自动放出」。
func (s *Service) moderateInitial(content, openid string, scene int, hasImages bool) (status string, submitImages bool) {
suggest, err := s.MsgSecCheck(content, openid, scene)
if err != nil {
return model.PostPending, false // 验证不了 → 人工
}
switch suggest {
case "risky":
return model.PostRejected, false
case "review":
return model.PostPending, false
}
// 文本 pass
if hasImages {
return model.PostPending, true // 图片还要异步查,先挂 pending
}
return model.PostPublished, true // 纯文本且 pass:自动过审(submitImages 无意义)
}
// submitImageChecks 逐张提交图片检测,落 trace 记录,供回调找回目标
func (s *Service) submitImageChecks(targetType, targetID, openid string, urls []string, scene int) {
for _, u := range urls {
if u == "" {
continue
}
trace, err := s.MediaCheckAsync(u, openid, scene)
if err != nil || trace == "" {
continue // 提交失败的图不阻塞,靠人工兜底
}
s.db.Create(&model.MediaCheck{
TraceID: trace, TargetType: targetType, TargetID: targetID, Status: model.MediaPending,
})
}
}
// ResolveMediaCheck 处理一条异步回调结果:更新 trace 记录,并据此决定目标状态。
// 任一张图 risky → 打回;全部 pass 且目标仍 pending → 放出。
func (s *Service) ResolveMediaCheck(traceID, suggest string) {
var mc model.MediaCheck
if err := s.db.Where("trace_id = ?", traceID).First(&mc).Error; err != nil {
return // 不认识的 trace,忽略
}
st := model.MediaPass
if suggest == "risky" || suggest == "review" {
st = model.MediaRisky
}
s.db.Model(&model.MediaCheck{}).Where("id = ?", mc.ID).Update("status", st)
if st == model.MediaRisky {
s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostRejected, model.PostPending)
return
}
// 这张 pass 了——看这条目标是否还有没回来的图
var pending int64
s.db.Model(&model.MediaCheck{}).
Where("target_type = ? AND target_id = ? AND status = ?", mc.TargetType, mc.TargetID, model.MediaPending).
Count(&pending)
if pending == 0 {
s.setTargetStatus(mc.TargetType, mc.TargetID, model.PostPublished, model.PostPending)
}
}
// setTargetStatus 把帖子/评论改成 newStatus,仅当它当前还是 onlyIf(避免覆盖人工已处理的)
func (s *Service) setTargetStatus(targetType, targetID, newStatus, onlyIf string) {
switch targetType {
case "post":
s.db.Model(&model.Post{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus)
case "comment":
s.db.Model(&model.Comment{}).Where("id = ? AND status = ?", targetID, onlyIf).Update("status", newStatus)
}
}
// postJSON 一个小的 JSON POST 帮手
func postJSON(url string, body []byte, out any) error {
ctx, cancel := context.WithTimeout(context.Background(), 12*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return json.NewDecoder(resp.Body).Decode(out)
}
-53
View File
@@ -1,53 +0,0 @@
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")
// ErrPayNotReady 支付未接入。会员开通必须走真实支付,未接入前一律拒绝,
// 避免「点一下白拿会员」。
var ErrPayNotReady = errors.New("支付功能即将开放,敬请期待")
// ErrForbidden 越权操作(如删除别人的内容)
var ErrForbidden = errors.New("无权操作")
// ErrInvalidParam 参数不合法(handler 据此返回 40000)
var ErrInvalidParam = errors.New("参数不合法")
// 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 string) (*model.Pet, error) {
var pet model.Pet
err := s.db.Where("id = ? AND user_id = ?", petID, userID).First(&pet).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// Age 是计算字段(gorm:"-")。在这里填而不是在每个调用点填 ——
// ownedPet 是所有「取一只宠物」的路径的收口,漏一个就会返回空年龄
pet.Age = AgeLabel(pet.Birthday)
return &pet, nil
}

Some files were not shown because too many files have changed in this diff Show More