init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台
- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导), 服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar - pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页, 微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台 - 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
||||
# Go
|
||||
/bin/
|
||||
*.out
|
||||
|
||||
# Node
|
||||
web/admin/node_modules/
|
||||
web/admin/.vite/
|
||||
|
||||
# 本地环境/日志
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,10 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Ignored default folder with query files
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GoImports">
|
||||
<option name="excludedPackages">
|
||||
<array>
|
||||
<option value="golang.org/x/net/context" />
|
||||
</array>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/pets-be.iml" filepath="$PROJECT_DIR$/.idea/pets-be.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,29 @@
|
||||
.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
|
||||
|
||||
# 后台前端
|
||||
admin-install:
|
||||
cd web/admin && npm install
|
||||
|
||||
admin-build:
|
||||
cd web/admin && npm run build
|
||||
|
||||
admin-dev:
|
||||
cd web/admin && npm run dev
|
||||
|
||||
# 构建单二进制(先出后台产物再 go build,embed 打包)
|
||||
build: admin-build
|
||||
go build -o bin/pets-be ./cmd/server
|
||||
|
||||
# 直接运行后端(使用当前已存在的 internal/admin/dist)
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
@@ -0,0 +1,49 @@
|
||||
# 毛孩子计划 · 后端 (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
|
||||
```
|
||||
|
||||
## 目录
|
||||
- `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
|
||||
|
||||
## 登录鉴权
|
||||
默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。
|
||||
接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"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/storage"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("load config: %v", err)
|
||||
}
|
||||
|
||||
db, err := database.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("connect mysql: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
log.Fatalf("migrate: %v", 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)
|
||||
}
|
||||
|
||||
engine := ai.New(cfg.AI)
|
||||
log.Printf("AI provider: %s", engine.ProviderName())
|
||||
|
||||
svc := service.New(db, st, cfg, engine)
|
||||
jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||
h := handler.New(svc, jm, cfg)
|
||||
|
||||
r := router.New(h, jm, cfg.Server.Mode)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
server:
|
||||
port: 8080
|
||||
mode: debug # debug | release
|
||||
|
||||
mysql:
|
||||
host: 127.0.0.1
|
||||
port: 3306
|
||||
user: root
|
||||
password: root
|
||||
database: pets
|
||||
charset: utf8mb4
|
||||
|
||||
minio:
|
||||
endpoint: 127.0.0.1:9000
|
||||
access_key: sundynix
|
||||
secret_key: sundynix
|
||||
bucket: pets
|
||||
use_ssl: false
|
||||
public_base_url: http://127.0.0.1:9000 # 拼接可访问的对象 URL
|
||||
|
||||
jwt:
|
||||
secret: change-me-to-a-long-random-string # 生产用 PETS_JWT_SECRET 覆盖
|
||||
|
||||
wechat:
|
||||
app_id: your-wechat-app-id
|
||||
app_secret: your-wechat-app-secret # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
|
||||
|
||||
auth:
|
||||
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 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
|
||||
@@ -0,0 +1,45 @@
|
||||
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:
|
||||
@@ -0,0 +1,70 @@
|
||||
module github.com/sundynix/pets-be
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
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/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
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
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/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
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/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/ini.v1 v1.67.2 // indirect
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
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=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
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=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
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=
|
||||
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/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=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
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=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.2.1 h1:PfBfwvKB/MmqyN8Vb1G9voWisaM9OrLv+WwOvMwS9Dw=
|
||||
github.com/minio/minio-go/v7 v7.2.1/go.mod h1:EU9hENAStx/xXduNdrGO5e4X5vk19NtgB+RIPjZO8o0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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.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=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
|
||||
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/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=
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin</title>
|
||||
<script type="module" crossorigin src="/admin/assets/index-WALewLI5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-ZqmdrDyd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
|
||||
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback
|
||||
func Register(r *gin.Engine) {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(sub))
|
||||
|
||||
handler := func(c *gin.Context) {
|
||||
// 去掉前缀 /admin,交给内嵌文件服务器
|
||||
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
|
||||
reqPath = strings.TrimPrefix(reqPath, "/")
|
||||
|
||||
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
|
||||
if reqPath != "" {
|
||||
if f, err := sub.Open(reqPath); err == nil {
|
||||
_ = f.Close()
|
||||
c.Request.URL.Path = "/" + reqPath
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA fallback
|
||||
data, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "admin dist missing")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
|
||||
}
|
||||
|
||||
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") })
|
||||
r.GET("/admin/*filepath", handler)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// Package ai 提供 provider 中立的大模型调用能力。
|
||||
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
|
||||
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
|
||||
package ai
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Message 一条对话消息
|
||||
type Message struct {
|
||||
Role string // system / user / assistant
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options 单次调用参数
|
||||
type Options struct {
|
||||
JSON bool // 要求返回 JSON(结构化输出)
|
||||
MaxTokens int // 0 用配置默认
|
||||
Temperature float64 // <0 用配置默认
|
||||
}
|
||||
|
||||
// Provider 大模型供应商接口
|
||||
type Provider interface {
|
||||
Name() string
|
||||
// Complete 传入 system 提示与多轮消息,返回助手回复文本
|
||||
Complete(system string, messages []Message, opts Options) (string, error)
|
||||
}
|
||||
|
||||
// Engine 对外统一入口,持有当前 provider 与开关
|
||||
type Engine struct {
|
||||
provider Provider
|
||||
cfg config.AIConfig
|
||||
}
|
||||
|
||||
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
|
||||
func New(cfg config.AIConfig) *Engine {
|
||||
e := &Engine{cfg: cfg}
|
||||
if !cfg.Enabled || cfg.APIKey == "" {
|
||||
return e
|
||||
}
|
||||
switch cfg.Provider {
|
||||
case "", "openai":
|
||||
e.provider = newOpenAICompatProvider(cfg)
|
||||
default:
|
||||
// 未知 provider 视为未启用,回退规则化
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Enabled 是否可用真实模型
|
||||
func (e *Engine) Enabled() bool {
|
||||
return e.provider != nil
|
||||
}
|
||||
|
||||
// ProviderName 当前供应商名(诊断用)
|
||||
func (e *Engine) ProviderName() string {
|
||||
if e.provider == nil {
|
||||
return "disabled"
|
||||
}
|
||||
return e.provider.Name()
|
||||
}
|
||||
|
||||
// Complete 代理到当前 provider(调用前请先判断 Enabled)
|
||||
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
if opts.MaxTokens == 0 {
|
||||
opts.MaxTokens = e.cfg.MaxTokens
|
||||
}
|
||||
if opts.Temperature < 0 {
|
||||
opts.Temperature = e.cfg.Temperature
|
||||
}
|
||||
return e.provider.Complete(system, messages, opts)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
|
||||
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
|
||||
type openaiCompatProvider struct {
|
||||
baseURL string
|
||||
apiKey string
|
||||
model string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
|
||||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
return &openaiCompatProvider{
|
||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
||||
apiKey: cfg.APIKey,
|
||||
model: cfg.Model,
|
||||
client: &http.Client{Timeout: timeout},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
|
||||
|
||||
type chatReq struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMsg `json:"messages"`
|
||||
Temperature float64 `json:"temperature,omitempty"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
ResponseFormat *respFormat `json:"response_format,omitempty"`
|
||||
}
|
||||
|
||||
type chatMsg struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type respFormat struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type chatResp struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
|
||||
msgs := make([]chatMsg, 0, len(messages)+1)
|
||||
if system != "" {
|
||||
msgs = append(msgs, chatMsg{Role: "system", Content: system})
|
||||
}
|
||||
for _, m := range messages {
|
||||
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
|
||||
body := chatReq{
|
||||
Model: p.model,
|
||||
Messages: msgs,
|
||||
Temperature: opts.Temperature,
|
||||
MaxTokens: opts.MaxTokens,
|
||||
}
|
||||
if opts.JSON {
|
||||
body.ResponseFormat = &respFormat{Type: "json_object"}
|
||||
}
|
||||
raw, _ := json.Marshal(body)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, _ := io.ReadAll(resp.Body)
|
||||
var out chatResp
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
|
||||
}
|
||||
if out.Error != nil {
|
||||
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
|
||||
}
|
||||
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
|
||||
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
|
||||
}
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config 全局配置
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
MySQL MySQLConfig `mapstructure:"mysql"`
|
||||
MinIO MinIOConfig `mapstructure:"minio"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
WeChat WeChatConfig `mapstructure:"wechat"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
Admin AdminConfig `mapstructure:"admin"`
|
||||
AI AIConfig `mapstructure:"ai"`
|
||||
}
|
||||
|
||||
// AIConfig 大模型配置(provider 中立,openai 兼容 base_url)
|
||||
type AIConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Provider string `mapstructure:"provider"` // openai(兼容) / mock
|
||||
BaseURL string `mapstructure:"base_url"` // 如 https://api.deepseek.com
|
||||
APIKey string `mapstructure:"api_key"` // 用 PETS_AI_API_KEY 覆盖
|
||||
Model string `mapstructure:"model"` // 如 deepseek-chat
|
||||
Temperature float64 `mapstructure:"temperature"`
|
||||
MaxTokens int `mapstructure:"max_tokens"`
|
||||
TimeoutSec int `mapstructure:"timeout_sec"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port int `mapstructure:"port"`
|
||||
Mode string `mapstructure:"mode"`
|
||||
}
|
||||
|
||||
type MySQLConfig struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
Database string `mapstructure:"database"`
|
||||
Charset string `mapstructure:"charset"`
|
||||
}
|
||||
|
||||
// DSN 返回 GORM MySQL 连接串
|
||||
func (m MySQLConfig) DSN() string {
|
||||
return fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
|
||||
m.User, m.Password, m.Host, m.Port, m.Database, m.Charset,
|
||||
)
|
||||
}
|
||||
|
||||
type MinIOConfig struct {
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
Secret string `mapstructure:"secret"`
|
||||
ExpireHours int `mapstructure:"expire_hours"`
|
||||
}
|
||||
|
||||
type WeChatConfig struct {
|
||||
AppID string `mapstructure:"app_id"`
|
||||
AppSecret string `mapstructure:"app_secret"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
DevLogin bool `mapstructure:"dev_login"`
|
||||
}
|
||||
|
||||
type AdminConfig struct {
|
||||
Username string `mapstructure:"username"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
// Load 载入配置:configs/config.yaml + 环境变量覆盖(PETS_ 前缀,点转下划线)
|
||||
// 例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password
|
||||
func Load() (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./configs")
|
||||
v.AddConfigPath("../../configs")
|
||||
v.AddConfigPath(".")
|
||||
|
||||
v.SetEnvPrefix("PETS")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
"gorm.io/gorm/schema"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
|
||||
func New(cfg *config.Config) (*gorm.DB, error) {
|
||||
logLevel := logger.Info
|
||||
if cfg.Server.Mode == "release" {
|
||||
logLevel = logger.Warn
|
||||
}
|
||||
|
||||
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
|
||||
NamingStrategy: schema.NamingStrategy{
|
||||
TablePrefix: "sundynix_",
|
||||
SingularTable: false,
|
||||
},
|
||||
Logger: logger.Default.LogMode(logLevel),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gorm open: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(50)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate 自动建表
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(model.AllModels()...)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// Seed 初始化种子数据:管理员账号 + 新手文章
|
||||
func Seed(db *gorm.DB, cfg *config.Config) error {
|
||||
if err := seedAdmin(db, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return seedArticles(db)
|
||||
}
|
||||
|
||||
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Create(&model.Admin{
|
||||
Username: cfg.Admin.Username,
|
||||
PasswordHash: string(hash),
|
||||
Role: "admin",
|
||||
}).Error
|
||||
}
|
||||
|
||||
func seedArticles(db *gorm.DB) error {
|
||||
var count int64
|
||||
if err := db.Model(&model.Article{}).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
articles := []model.Article{
|
||||
{Icon: "💉", Title: "幼猫疫苗和驱虫怎么安排?", Description: "适合搜索流量和社群传播,引导生成提醒。", Category: "vaccine", RelatedSheetType: "vaccine", Published: true},
|
||||
{Icon: "💩", Title: "猫咪软便要不要去医院?", Description: "接异常记录和 AI 观察建议。", Category: "symptom", RelatedSheetType: "symptom", Published: true},
|
||||
{Icon: "🍽️", Title: "7 天换粮计划怎么做?", Description: "接计划模板,适合一次性付费。", Category: "food", RelatedSheetType: "applyPlan", Published: true},
|
||||
{Icon: "💰", Title: "一个月养猫大概花多少钱?", Description: "接养宠账本和年度账单。", Category: "cost", RelatedSheetType: "cost", Published: true},
|
||||
}
|
||||
return db.Create(&articles).Error
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type adminLoginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// AdminLogin POST /api/admin/login
|
||||
func (h *Handler) AdminLogin(c *gin.Context) {
|
||||
var req adminLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
admin, err := h.svc.AdminLogin(req.Username, req.Password)
|
||||
if err != nil {
|
||||
response.Fail(c, 40100, "账号或密码错误")
|
||||
return
|
||||
}
|
||||
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "admin": admin})
|
||||
}
|
||||
|
||||
// AdminMe GET /api/admin/me
|
||||
func (h *Handler) AdminMe(c *gin.Context) {
|
||||
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, admin)
|
||||
}
|
||||
|
||||
// AdminStats GET /api/admin/stats
|
||||
func (h *Handler) AdminStats(c *gin.Context) {
|
||||
stats, err := h.svc.Stats()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, stats)
|
||||
}
|
||||
|
||||
type adminListReq struct {
|
||||
response.PageQuery
|
||||
Keyword string `form:"keyword"`
|
||||
Status string `form:"status"`
|
||||
}
|
||||
|
||||
// AdminListUsers GET /api/admin/users
|
||||
func (h *Handler) AdminListUsers(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
users, total, err := h.svc.ListUsers(req.Keyword, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(users, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type disableReq struct {
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
|
||||
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
|
||||
var req disableReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetUserDisabled(uintParam(c, "id"), req.Disabled); err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListPets GET /api/admin/pets
|
||||
func (h *Handler) AdminListPets(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(pets, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// AdminListPosts GET /api/admin/posts
|
||||
func (h *Handler) AdminListPosts(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type postStatusReq struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AdminSetPostStatus PUT /api/admin/posts/:id/status
|
||||
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
|
||||
var req postStatusReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.svc.SetPostStatus(uintParam(c, "id"), req.Status); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListComments GET /api/admin/comments
|
||||
func (h *Handler) AdminListComments(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
comments, total, err := h.svc.ListCommentsAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// AdminDeleteComment DELETE /api/admin/comments/:id
|
||||
func (h *Handler) AdminDeleteComment(c *gin.Context) {
|
||||
if err := h.svc.DeleteCommentAdmin(uintParam(c, "id")); err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListArticles GET /api/admin/articles
|
||||
func (h *Handler) AdminListArticles(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(articles, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type articleReq struct {
|
||||
ID uint `json:"id"`
|
||||
Icon string `json:"icon"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content"`
|
||||
Category string `json:"category"`
|
||||
RelatedSheetType string `json:"related_sheet_type"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// AdminSaveArticle POST /api/admin/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(uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AdminListPro GET /api/admin/memberships
|
||||
func (h *Handler) AdminListPro(c *gin.Context) {
|
||||
var req adminListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(list, total, req.PageQuery))
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type loginReq struct {
|
||||
Nickname string `json:"nickname"`
|
||||
}
|
||||
|
||||
// Login 开发态 Mock 登录
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
if !h.cfg.Auth.DevLogin {
|
||||
response.Fail(c, 40300, "开发登录未开启,请使用微信登录")
|
||||
return
|
||||
}
|
||||
var req loginReq
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
user, err := h.svc.MockLogin(req.Nickname)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
type wechatLoginReq struct {
|
||||
Code string `json:"code"`
|
||||
Nickname string `json:"nickname"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录:前端 wx.login 拿 code 传入
|
||||
func (h *Handler) WechatLogin(c *gin.Context) {
|
||||
var req wechatLoginReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Code == "" {
|
||||
response.FailParams(c, "缺少 code")
|
||||
return
|
||||
}
|
||||
user, err := h.svc.WechatLogin(req.Code)
|
||||
if err != nil {
|
||||
response.Fail(c, 40100, err.Error())
|
||||
return
|
||||
}
|
||||
// 可选:首次登录写入用户授权的昵称/头像
|
||||
if fields := map[string]any{}; true {
|
||||
if req.Nickname != "" && user.Nickname == "微信用户" {
|
||||
fields["nickname"] = req.Nickname
|
||||
}
|
||||
if req.Avatar != "" && user.Avatar == "" {
|
||||
fields["avatar"] = req.Avatar
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
if u, e := h.svc.UpdateUser(user.ID, fields); e == nil {
|
||||
user = u
|
||||
}
|
||||
}
|
||||
}
|
||||
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"token": token, "user": user})
|
||||
}
|
||||
|
||||
// Profile 当前用户资料
|
||||
func (h *Handler) Profile(c *gin.Context) {
|
||||
user, err := h.svc.GetUser(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, user)
|
||||
}
|
||||
|
||||
type updateProfileReq struct {
|
||||
Nickname *string `json:"nickname"`
|
||||
Avatar *string `json:"avatar"`
|
||||
Phone *string `json:"phone"`
|
||||
}
|
||||
|
||||
// UpdateProfile 更新资料
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
var req updateProfileReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Nickname != nil {
|
||||
fields["nickname"] = *req.Nickname
|
||||
}
|
||||
if req.Avatar != nil {
|
||||
fields["avatar"] = *req.Avatar
|
||||
}
|
||||
if req.Phone != nil {
|
||||
fields["phone"] = *req.Phone
|
||||
}
|
||||
user, err := h.svc.UpdateUser(middleware.UserID(c), fields)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, user)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type postListReq struct {
|
||||
response.PageQuery
|
||||
Tab string `form:"tab"`
|
||||
}
|
||||
|
||||
// ListPosts GET /api/posts?tab=&page=&page_size=
|
||||
func (h *Handler) ListPosts(c *gin.Context) {
|
||||
var req postListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
|
||||
posts, total, err := h.svc.ListPosts(req.Tab, req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
||||
}
|
||||
|
||||
// GetPost GET /api/posts/:id
|
||||
func (h *Handler) GetPost(c *gin.Context) {
|
||||
post, err := h.svc.GetPost(uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, post)
|
||||
}
|
||||
|
||||
type postReq struct {
|
||||
PetID *uint `json:"pet_id"`
|
||||
Identity string `json:"identity"`
|
||||
Content string `json:"content"`
|
||||
Tags []string `json:"tags"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
// CreatePost POST /api/posts
|
||||
func (h *Handler) CreatePost(c *gin.Context) {
|
||||
var req postReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.PostInput{PetID: req.PetID, Identity: req.Identity, Content: req.Content}
|
||||
if b, err := jsonMarshal(req.Tags); err == nil {
|
||||
in.Tags = datatypes.JSON(b)
|
||||
}
|
||||
if b, err := jsonMarshal(req.Images); err == nil {
|
||||
in.Images = datatypes.JSON(b)
|
||||
}
|
||||
post, err := h.svc.CreatePost(middleware.UserID(c), in)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, post)
|
||||
}
|
||||
|
||||
// LikePost POST /api/posts/:id/like
|
||||
func (h *Handler) LikePost(c *gin.Context) {
|
||||
count, err := h.svc.LikePost(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"like_count": count})
|
||||
}
|
||||
|
||||
// UnlikePost DELETE /api/posts/:id/like
|
||||
func (h *Handler) UnlikePost(c *gin.Context) {
|
||||
count, err := h.svc.UnlikePost(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"like_count": count})
|
||||
}
|
||||
|
||||
type commentListReq struct {
|
||||
response.PageQuery
|
||||
}
|
||||
|
||||
// ListComments GET /api/posts/:id/comments
|
||||
func (h *Handler) ListComments(c *gin.Context) {
|
||||
var req commentListReq
|
||||
_ = c.ShouldBindQuery(&req)
|
||||
req.Normalize()
|
||||
|
||||
comments, total, err := h.svc.ListComments(uintParam(c, "id"), req.Offset(), req.Limit())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
||||
}
|
||||
|
||||
type commentReq struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// CreateComment POST /api/posts/:id/comments
|
||||
func (h *Handler) CreateComment(c *gin.Context) {
|
||||
var req commentReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
response.FailParams(c, "评论内容不能为空")
|
||||
return
|
||||
}
|
||||
comment, err := h.svc.CreateComment(middleware.UserID(c), uintParam(c, "id"), req.Content)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, comment)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
)
|
||||
|
||||
// Handler 所有 HTTP 处理器聚合,方法按领域分散在各文件
|
||||
type Handler struct {
|
||||
svc *service.Service
|
||||
jwt *appjwt.Manager
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func New(svc *service.Service, jm *appjwt.Manager, cfg *config.Config) *Handler {
|
||||
return &Handler{svc: svc, jwt: jm, cfg: cfg}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/errcode"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// uintParam 解析路径参数为 uint
|
||||
func uintParam(c *gin.Context, key string) uint {
|
||||
v, _ := strconv.ParseUint(c.Param(key), 10, 64)
|
||||
return uint(v)
|
||||
}
|
||||
|
||||
// jsonMarshal 便捷序列化
|
||||
func jsonMarshal(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
// respondErr 统一错误响应:区分 not found 与内部错误
|
||||
func respondErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, service.ErrNotFound) {
|
||||
response.Fail(c, errcode.ErrNotFound, "")
|
||||
return
|
||||
}
|
||||
response.FailErr(c, err)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// ListArticles GET /api/articles
|
||||
func (h *Handler) ListArticles(c *gin.Context) {
|
||||
articles, err := h.svc.ListArticles()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, articles)
|
||||
}
|
||||
|
||||
// GetArticle GET /api/articles/:id
|
||||
func (h *Handler) GetArticle(c *gin.Context) {
|
||||
article, err := h.svc.GetArticle(uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, article)
|
||||
}
|
||||
|
||||
// GetPro GET /api/pro
|
||||
func (h *Handler) GetPro(c *gin.Context) {
|
||||
info, err := h.svc.GetPro(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, info)
|
||||
}
|
||||
|
||||
// ActivatePro POST /api/pro/activate
|
||||
func (h *Handler) ActivatePro(c *gin.Context) {
|
||||
info, err := h.svc.ActivatePro(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, info)
|
||||
}
|
||||
|
||||
type aiChatReq struct {
|
||||
PetID *uint `json:"pet_id"`
|
||||
Session string `json:"session"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// AIChat POST /api/ai/chat
|
||||
func (h *Handler) AIChat(c *gin.Context) {
|
||||
var req aiChatReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Text == "" {
|
||||
response.FailParams(c, "问题不能为空")
|
||||
return
|
||||
}
|
||||
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reply": reply})
|
||||
}
|
||||
|
||||
// HomeSummary GET /api/pets/:id/home-summary
|
||||
func (h *Handler) HomeSummary(c *gin.Context) {
|
||||
res, err := h.svc.GetHomeSummary(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// UserSummary GET /api/user/summary
|
||||
func (h *Handler) UserSummary(c *gin.Context) {
|
||||
res, err := h.svc.GetUserSummary(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
type assessSymptomReq struct {
|
||||
Symptoms []string `json:"symptoms"`
|
||||
Duration string `json:"duration"`
|
||||
Spirit string `json:"spirit"`
|
||||
}
|
||||
|
||||
// AssessSymptom POST /api/pets/:id/ai/assess-symptom
|
||||
func (h *Handler) AssessSymptom(c *gin.Context) {
|
||||
var req assessSymptomReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.svc.AssessSymptom(middleware.UserID(c), uintParam(c, "id"), service.SymptomInput{
|
||||
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
|
||||
})
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type petReq struct {
|
||||
Name string `json:"name"`
|
||||
Emoji string `json:"emoji"`
|
||||
Type string `json:"type"`
|
||||
Gender string `json:"gender"`
|
||||
Birthday string `json:"birthday"` // YYYY-MM-DD
|
||||
Weight string `json:"weight"`
|
||||
Stage string `json:"stage"`
|
||||
Age string `json:"age"`
|
||||
Color string `json:"color"`
|
||||
Breed string `json:"breed"`
|
||||
Goals []string `json:"goals"`
|
||||
}
|
||||
|
||||
func (r petReq) toInput() service.PetInput {
|
||||
in := service.PetInput{
|
||||
Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender,
|
||||
Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed,
|
||||
}
|
||||
if r.Birthday != "" {
|
||||
if t, err := time.Parse("2006-01-02", r.Birthday); err == nil {
|
||||
in.Birthday = &t
|
||||
}
|
||||
}
|
||||
if r.Goals != nil {
|
||||
if b, err := jsonMarshal(r.Goals); err == nil {
|
||||
in.Goals = datatypes.JSON(b)
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// ListPets GET /api/pets
|
||||
func (h *Handler) ListPets(c *gin.Context) {
|
||||
pets, err := h.svc.ListPets(middleware.UserID(c))
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pets)
|
||||
}
|
||||
|
||||
// CreatePet POST /api/pets
|
||||
func (h *Handler) CreatePet(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
pet, err := h.svc.CreatePet(middleware.UserID(c), req.toInput())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// GetPet GET /api/pets/:id
|
||||
func (h *Handler) GetPet(c *gin.Context) {
|
||||
pet, err := h.svc.GetPet(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// UpdatePet PUT /api/pets/:id
|
||||
func (h *Handler) UpdatePet(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Name != "" {
|
||||
fields["name"] = req.Name
|
||||
}
|
||||
if req.Emoji != "" {
|
||||
fields["emoji"] = req.Emoji
|
||||
}
|
||||
if req.Weight != "" {
|
||||
fields["weight"] = req.Weight
|
||||
}
|
||||
if req.Stage != "" {
|
||||
fields["stage"] = req.Stage
|
||||
}
|
||||
if req.Gender != "" {
|
||||
fields["gender"] = req.Gender
|
||||
}
|
||||
if req.Age != "" {
|
||||
fields["age"] = req.Age
|
||||
}
|
||||
if req.Color != "" {
|
||||
fields["color"] = req.Color
|
||||
}
|
||||
if req.Breed != "" {
|
||||
fields["breed"] = req.Breed
|
||||
}
|
||||
pet, err := h.svc.UpdatePet(middleware.UserID(c), uintParam(c, "id"), fields)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
|
||||
// DeletePet DELETE /api/pets/:id
|
||||
func (h *Handler) DeletePet(c *gin.Context) {
|
||||
if err := h.svc.DeletePet(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// Onboarding POST /api/onboarding
|
||||
func (h *Handler) Onboarding(c *gin.Context) {
|
||||
var req petReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
pet, err := h.svc.Onboarding(middleware.UserID(c), req.toInput())
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, pet)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// ListTasks GET /api/pets/:id/tasks?date=YYYY-MM-DD
|
||||
func (h *Handler) ListTasks(c *gin.Context) {
|
||||
var date *time.Time
|
||||
if q := c.Query("date"); q != "" {
|
||||
if t, err := time.Parse("2006-01-02", q); err == nil {
|
||||
date = &t
|
||||
}
|
||||
}
|
||||
tasks, err := h.svc.ListTasks(middleware.UserID(c), uintParam(c, "id"), date)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, tasks)
|
||||
}
|
||||
|
||||
// ToggleTask POST /api/tasks/:id/toggle
|
||||
func (h *Handler) ToggleTask(c *gin.Context) {
|
||||
task, err := h.svc.ToggleTask(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, task)
|
||||
}
|
||||
|
||||
// CompleteAllTasks POST /api/pets/:id/tasks/complete-all
|
||||
func (h *Handler) CompleteAllTasks(c *gin.Context) {
|
||||
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, tasks)
|
||||
}
|
||||
|
||||
// GetPlan GET /api/pets/:id/plan
|
||||
func (h *Handler) GetPlan(c *gin.Context) {
|
||||
plan, err := h.svc.GetPlan(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
|
||||
func (h *Handler) PlanCalendar(c *gin.Context) {
|
||||
now := time.Now()
|
||||
year, month := now.Year(), int(now.Month())
|
||||
if q := c.Query("month"); q != "" {
|
||||
if t, err := time.Parse("2006-01", q); err == nil {
|
||||
year, month = t.Year(), int(t.Month())
|
||||
}
|
||||
}
|
||||
res, err := h.svc.Calendar(middleware.UserID(c), uintParam(c, "id"), year, month)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
type aiPlanReq struct {
|
||||
Input string `json:"input"`
|
||||
}
|
||||
|
||||
// CreateAIPlan POST /api/pets/:id/ai-plan
|
||||
func (h *Handler) CreateAIPlan(c *gin.Context) {
|
||||
var req aiPlanReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), uintParam(c, "id"), req.Input)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// ApplyAIPlan POST /api/ai-plan/:id/apply
|
||||
func (h *Handler) ApplyAIPlan(c *gin.Context) {
|
||||
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, plan)
|
||||
}
|
||||
|
||||
// TogglePlanTask POST /api/plan-tasks/:id/toggle
|
||||
func (h *Handler) TogglePlanTask(c *gin.Context) {
|
||||
if err := h.svc.TogglePlanTask(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/datatypes"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type recordReq struct {
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
NumValue float64 `json:"num_value"`
|
||||
Category string `json:"category"`
|
||||
ImageURL string `json:"image_url"`
|
||||
Extra datatypes.JSON `json:"extra"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// ListRecords GET /api/pets/:id/records?type=
|
||||
func (h *Handler) ListRecords(c *gin.Context) {
|
||||
records, err := h.svc.ListRecords(middleware.UserID(c), uintParam(c, "id"), c.Query("type"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, records)
|
||||
}
|
||||
|
||||
// CreateRecord POST /api/pets/:id/records
|
||||
func (h *Handler) CreateRecord(c *gin.Context) {
|
||||
var req recordReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.RecordInput{
|
||||
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
|
||||
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, Extra: req.Extra,
|
||||
}
|
||||
if req.OccurredAt != "" {
|
||||
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
|
||||
in.OccurredAt = &t
|
||||
}
|
||||
}
|
||||
rec, err := h.svc.CreateRecord(middleware.UserID(c), uintParam(c, "id"), in)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, rec)
|
||||
}
|
||||
|
||||
// DeleteRecord DELETE /api/records/:id
|
||||
func (h *Handler) DeleteRecord(c *gin.Context) {
|
||||
if err := h.svc.DeleteRecord(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// WeightTrend GET /api/pets/:id/records/weight-trend
|
||||
func (h *Handler) WeightTrend(c *gin.Context) {
|
||||
points, err := h.svc.WeightTrend(middleware.UserID(c), uintParam(c, "id"), 7)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, points)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/internal/service"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
type reminderReq struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
NextDueDate string `json:"next_due_date"` // YYYY-MM-DD
|
||||
Frequency string `json:"frequency"`
|
||||
}
|
||||
|
||||
// ListReminders GET /api/pets/:id/reminders
|
||||
func (h *Handler) ListReminders(c *gin.Context) {
|
||||
reminders, err := h.svc.ListReminders(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, reminders)
|
||||
}
|
||||
|
||||
// CreateReminder POST /api/pets/:id/reminders
|
||||
func (h *Handler) CreateReminder(c *gin.Context) {
|
||||
var req reminderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
|
||||
if req.NextDueDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
|
||||
in.NextDueDate = &t
|
||||
}
|
||||
}
|
||||
r, err := h.svc.CreateReminder(middleware.UserID(c), uintParam(c, "id"), in)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, r)
|
||||
}
|
||||
|
||||
// UpdateReminder PUT /api/reminders/:id
|
||||
func (h *Handler) UpdateReminder(c *gin.Context) {
|
||||
var req reminderReq
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.FailParams(c, err.Error())
|
||||
return
|
||||
}
|
||||
fields := map[string]any{}
|
||||
if req.Title != "" {
|
||||
fields["title"] = req.Title
|
||||
}
|
||||
if req.Frequency != "" {
|
||||
fields["frequency"] = req.Frequency
|
||||
}
|
||||
if req.NextDueDate != "" {
|
||||
if t, err := time.Parse("2006-01-02", req.NextDueDate); err == nil {
|
||||
fields["next_due_date"] = t
|
||||
}
|
||||
}
|
||||
r, err := h.svc.UpdateReminder(middleware.UserID(c), uintParam(c, "id"), fields)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, r)
|
||||
}
|
||||
|
||||
// DeleteReminder DELETE /api/reminders/:id
|
||||
func (h *Handler) DeleteReminder(c *gin.Context) {
|
||||
if err := h.svc.DeleteReminder(middleware.UserID(c), uintParam(c, "id")); err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": true})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// WeeklyReport GET /api/pets/:id/report/weekly
|
||||
func (h *Handler) WeeklyReport(c *gin.Context) {
|
||||
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, rep)
|
||||
}
|
||||
|
||||
// Bill GET /api/pets/:id/bill?period=month
|
||||
func (h *Handler) Bill(c *gin.Context) {
|
||||
period := c.DefaultQuery("period", "month")
|
||||
bill, err := h.svc.GetBill(middleware.UserID(c), uintParam(c, "id"), period)
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, bill)
|
||||
}
|
||||
|
||||
// HealthSummary GET /api/pets/:id/health-summary
|
||||
func (h *Handler) HealthSummary(c *gin.Context) {
|
||||
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, sum)
|
||||
}
|
||||
|
||||
// Poster GET /api/pets/:id/poster
|
||||
func (h *Handler) Poster(c *gin.Context) {
|
||||
poster, err := h.svc.GetPoster(middleware.UserID(c), uintParam(c, "id"))
|
||||
if err != nil {
|
||||
respondErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, poster)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// Upload POST /api/upload multipart/form-data,字段名 file
|
||||
func (h *Handler) Upload(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.FailParams(c, "缺少上传文件 file")
|
||||
return
|
||||
}
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
ext := strings.ToLower(path.Ext(fileHeader.Filename))
|
||||
objectName := fmt.Sprintf("uploads/%d/%d%s", middleware.UserID(c), time.Now().UnixNano(), ext)
|
||||
contentType := fileHeader.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
|
||||
url, err := h.svc.Upload(objectName, src, fileHeader.Size, contentType)
|
||||
if err != nil {
|
||||
response.FailErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"url": url})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/pkg/errcode"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// gin.Context 中存放身份的 key
|
||||
const (
|
||||
CtxUserID = "userID"
|
||||
CtxAdminID = "adminID"
|
||||
CtxName = "name"
|
||||
)
|
||||
|
||||
func bearer(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimPrefix(h, "Bearer ")
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// AuthUser 小程序用户鉴权
|
||||
func AuthUser(jm *appjwt.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := jm.Parse(bearer(c))
|
||||
if err != nil || claims.Kind != appjwt.KindUser {
|
||||
response.Abort(c, errcode.ErrUnauthized, "")
|
||||
return
|
||||
}
|
||||
c.Set(CtxUserID, claims.ID)
|
||||
c.Set(CtxName, claims.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AuthAdmin 后台管理员鉴权
|
||||
func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims, err := jm.Parse(bearer(c))
|
||||
if err != nil || claims.Kind != appjwt.KindAdmin {
|
||||
response.Abort(c, errcode.ErrUnauthized, "")
|
||||
return
|
||||
}
|
||||
c.Set(CtxAdminID, claims.ID)
|
||||
c.Set(CtxName, claims.Name)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// UserID 从上下文取当前用户 ID
|
||||
func UserID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(CtxUserID); ok {
|
||||
if id, ok := v.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AdminID 从上下文取当前管理员 ID
|
||||
func AdminID(c *gin.Context) uint {
|
||||
if v, ok := c.Get(CtxAdminID); ok {
|
||||
if id, ok := v.(uint); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 开发期允许跨域(小程序无跨域限制,主要给后台 SPA dev server 用)
|
||||
func CORS() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if origin != "" {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
} else {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package model
|
||||
|
||||
// Admin 后台管理员
|
||||
type Admin struct {
|
||||
Base
|
||||
Username string `gorm:"size:64;uniqueIndex" json:"username"`
|
||||
PasswordHash string `gorm:"size:128" json:"-"`
|
||||
Role string `gorm:"size:32;default:admin" json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// AIMessage AI 聊天消息
|
||||
type AIMessage struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
PetID *uint `json:"pet_id"`
|
||||
Session string `gorm:"size:64;index" json:"session"`
|
||||
Role string `gorm:"size:8" json:"role"` // user / ai
|
||||
Text string `gorm:"type:text" json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package model
|
||||
|
||||
// Article 新手知识文章
|
||||
type Article struct {
|
||||
Base
|
||||
Icon string `gorm:"size:16" json:"icon"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Category string `gorm:"size:32" json:"category"`
|
||||
RelatedSheetType string `gorm:"size:32" json:"related_sheet_type"`
|
||||
Published bool `gorm:"default:true" json:"published"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Base 所有模型的公共字段
|
||||
type Base struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
|
||||
func AllModels() []any {
|
||||
return []any{
|
||||
&User{},
|
||||
&Pet{},
|
||||
&HealthRecord{},
|
||||
&DailyTask{},
|
||||
&Plan{},
|
||||
&PlanTask{},
|
||||
&Reminder{},
|
||||
&Post{},
|
||||
&Comment{},
|
||||
&PostLike{},
|
||||
&Article{},
|
||||
&AIMessage{},
|
||||
&ProMembership{},
|
||||
&Admin{},
|
||||
&DailyAdvice{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
// Comment 帖子评论
|
||||
type Comment struct {
|
||||
Base
|
||||
PostID uint `gorm:"index" json:"post_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||
Content string `gorm:"size:512" json:"content"`
|
||||
Status string `gorm:"size:16;default:published" json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package model
|
||||
|
||||
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
|
||||
type DailyAdvice struct {
|
||||
Base
|
||||
PetID uint `gorm:"uniqueIndex:idx_pet_day" json:"pet_id"`
|
||||
Day string `gorm:"size:10;uniqueIndex:idx_pet_day" json:"day"` // YYYY-MM-DD
|
||||
Text string `gorm:"type:text" json:"text"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// DailyTask 今日任务
|
||||
type DailyTask struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
TaskDate time.Time `gorm:"index" json:"task_date"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
Priority string `gorm:"size:16" json:"priority"` // "" / 重要
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 点击打开的弹层类型
|
||||
Done bool `json:"done"`
|
||||
CompletedAt *time.Time `json:"completed_at"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// 健康记录类型
|
||||
const (
|
||||
RecordWeight = "weight"
|
||||
RecordPoop = "poop"
|
||||
RecordFood = "food"
|
||||
RecordSymptom = "symptom"
|
||||
RecordMedicine = "medicine"
|
||||
RecordVaccine = "vaccine"
|
||||
RecordDeworm = "deworm"
|
||||
RecordCost = "cost"
|
||||
RecordPhoto = "photo"
|
||||
)
|
||||
|
||||
// HealthRecord 统一健康/时间轴记录表,type 区分 9 类。
|
||||
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
|
||||
type HealthRecord struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Type string `gorm:"size:16;index" json:"type"`
|
||||
Icon string `gorm:"size:16" json:"icon"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
NumValue float64 `gorm:"type:decimal(10,2)" json:"num_value"`
|
||||
Category string `gorm:"size:32" json:"category"`
|
||||
ImageURL string `gorm:"size:512" json:"image_url"`
|
||||
Extra datatypes.JSON `json:"extra"`
|
||||
OccurredAt time.Time `gorm:"index" json:"occurred_at"`
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// Pet 宠物,属于 User,支持多宠
|
||||
type Pet struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Name string `gorm:"size:64" json:"name"`
|
||||
Emoji string `gorm:"size:16" json:"emoji"`
|
||||
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
|
||||
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
|
||||
Birthday *time.Time `json:"birthday"`
|
||||
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
|
||||
Stage string `gorm:"size:32" json:"stage"` // 刚到家 0-30 天 / 幼年期 / 成年期 / 老年期
|
||||
Age string `gorm:"size:32" json:"age"`
|
||||
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
|
||||
Breed string `gorm:"size:64" json:"breed"` // 品种
|
||||
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
|
||||
Goals datatypes.JSON `json:"goals"` // onboarding 目标多选
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// 计划类型
|
||||
const (
|
||||
PlanThirtyDay = "thirty_day"
|
||||
PlanAI = "ai"
|
||||
)
|
||||
|
||||
// Plan 养宠计划(30 天路线图 / AI 计划)
|
||||
type Plan struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Kind string `gorm:"size:16;index" json:"kind"` // thirty_day / ai
|
||||
Stage string `gorm:"size:32" json:"stage"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
UserInput string `gorm:"size:512" json:"user_input"`
|
||||
Extracted datatypes.JSON `json:"extracted"` // AI 提取信息
|
||||
CompletionPct int `json:"completion_pct"`
|
||||
Status string `gorm:"size:16;default:active" json:"status"` // active / completed / archived
|
||||
Tasks []PlanTask `gorm:"foreignKey:PlanID" json:"tasks"`
|
||||
}
|
||||
|
||||
// PlanTask 计划明细项
|
||||
type PlanTask struct {
|
||||
Base
|
||||
PlanID uint `gorm:"index" json:"plan_id"`
|
||||
Day int `json:"day"`
|
||||
DayLabel string `gorm:"size:32" json:"day_label"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
Description string `gorm:"size:512" json:"description"`
|
||||
SheetType string `gorm:"size:32" json:"sheet_type"`
|
||||
Done bool `json:"done"`
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import "gorm.io/datatypes"
|
||||
|
||||
// 帖子状态
|
||||
const (
|
||||
PostPublished = "published"
|
||||
PostHidden = "hidden"
|
||||
PostDeleted = "deleted"
|
||||
)
|
||||
|
||||
// Post 社区帖子
|
||||
type Post struct {
|
||||
Base
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
PetID *uint `json:"pet_id"`
|
||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
||||
AuthorEmoji string `gorm:"size:16" json:"author_emoji"`
|
||||
Identity string `gorm:"size:16" json:"identity"` // petName / anonymous / official
|
||||
Content string `gorm:"type:text" json:"content"`
|
||||
Tags datatypes.JSON `json:"tags"`
|
||||
Images datatypes.JSON `json:"images"`
|
||||
LikeCount int `json:"like_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package model
|
||||
|
||||
// PostLike 帖子点赞,(post_id, user_id) 唯一
|
||||
type PostLike struct {
|
||||
Base
|
||||
PostID uint `gorm:"uniqueIndex:idx_post_user" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_post_user" json:"user_id"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Pro 会员状态
|
||||
const (
|
||||
ProActive = "active"
|
||||
ProExpired = "expired"
|
||||
ProNone = "none"
|
||||
)
|
||||
|
||||
// ProMembership 会员,1:1 于 User
|
||||
type ProMembership struct {
|
||||
Base
|
||||
UserID uint `gorm:"uniqueIndex" json:"user_id"`
|
||||
Status string `gorm:"size:16;default:none" json:"status"`
|
||||
PlanType string `gorm:"size:16" json:"plan_type"` // yearly / monthly
|
||||
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
|
||||
StartDate *time.Time `json:"start_date"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 提醒类型
|
||||
const (
|
||||
ReminderVaccine = "vaccine"
|
||||
ReminderDeworm = "deworm"
|
||||
ReminderWeight = "weight"
|
||||
ReminderMonthlyReport = "monthlyReport"
|
||||
)
|
||||
|
||||
// Reminder 提醒
|
||||
type Reminder struct {
|
||||
Base
|
||||
PetID uint `gorm:"index" json:"pet_id"`
|
||||
UserID uint `gorm:"index" json:"user_id"`
|
||||
Type string `gorm:"size:24;index" json:"type"`
|
||||
Title string `gorm:"size:128" json:"title"`
|
||||
NextDueDate *time.Time `json:"next_due_date"`
|
||||
Frequency string `gorm:"size:64" json:"frequency"`
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package model
|
||||
|
||||
// User 小程序用户(铲屎官)
|
||||
type User struct {
|
||||
Base
|
||||
OpenID string `gorm:"size:64;index" json:"openid"`
|
||||
Nickname string `gorm:"size:64" json:"nickname"`
|
||||
Avatar string `gorm:"size:512" json:"avatar"`
|
||||
Phone string `gorm:"size:32" json:"phone"`
|
||||
Onboarded bool `json:"onboarded"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/admin"
|
||||
"github.com/sundynix/pets-be/internal/handler"
|
||||
"github.com/sundynix/pets-be/internal/middleware"
|
||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
||||
"github.com/sundynix/pets-be/pkg/response"
|
||||
)
|
||||
|
||||
// New 组装 gin 引擎与所有路由
|
||||
func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
|
||||
gin.SetMode(mode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
||||
|
||||
// 健康检查
|
||||
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
|
||||
// 根路径跳转后台
|
||||
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
|
||||
|
||||
api := r.Group("/api")
|
||||
registerAuth(api, h)
|
||||
registerUserAPI(api, h, jm)
|
||||
registerAdminAPI(api, h, jm)
|
||||
|
||||
// 内嵌后台 SPA
|
||||
admin.Register(r)
|
||||
return r
|
||||
}
|
||||
|
||||
func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
|
||||
auth := api.Group("/auth")
|
||||
auth.POST("/login", h.Login)
|
||||
auth.POST("/wechat", h.WechatLogin)
|
||||
}
|
||||
|
||||
// 小程序用户接口(需 user JWT)
|
||||
func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
||||
g := api.Group("")
|
||||
g.Use(middleware.AuthUser(jm))
|
||||
|
||||
g.GET("/user/profile", h.Profile)
|
||||
g.PUT("/user/profile", h.UpdateProfile)
|
||||
g.GET("/user/summary", h.UserSummary)
|
||||
|
||||
g.GET("/pets", h.ListPets)
|
||||
g.POST("/pets", h.CreatePet)
|
||||
g.POST("/onboarding", h.Onboarding)
|
||||
g.GET("/pets/:id", h.GetPet)
|
||||
g.PUT("/pets/:id", h.UpdatePet)
|
||||
g.DELETE("/pets/:id", h.DeletePet)
|
||||
|
||||
g.GET("/pets/:id/records", h.ListRecords)
|
||||
g.POST("/pets/:id/records", h.CreateRecord)
|
||||
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
|
||||
g.DELETE("/records/:id", h.DeleteRecord)
|
||||
|
||||
g.GET("/pets/:id/tasks", h.ListTasks)
|
||||
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
|
||||
g.POST("/tasks/:id/toggle", h.ToggleTask)
|
||||
|
||||
g.GET("/pets/:id/home-summary", h.HomeSummary)
|
||||
|
||||
g.GET("/pets/:id/plan", h.GetPlan)
|
||||
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
|
||||
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
|
||||
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
|
||||
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
|
||||
|
||||
g.GET("/pets/:id/reminders", h.ListReminders)
|
||||
g.POST("/pets/:id/reminders", h.CreateReminder)
|
||||
g.PUT("/reminders/:id", h.UpdateReminder)
|
||||
g.DELETE("/reminders/:id", h.DeleteReminder)
|
||||
|
||||
g.GET("/pets/:id/report/weekly", h.WeeklyReport)
|
||||
g.GET("/pets/:id/bill", h.Bill)
|
||||
g.GET("/pets/:id/health-summary", h.HealthSummary)
|
||||
g.GET("/pets/:id/poster", h.Poster)
|
||||
|
||||
g.GET("/posts", h.ListPosts)
|
||||
g.POST("/posts", h.CreatePost)
|
||||
g.GET("/posts/:id", h.GetPost)
|
||||
g.POST("/posts/:id/like", h.LikePost)
|
||||
g.DELETE("/posts/:id/like", h.UnlikePost)
|
||||
g.GET("/posts/:id/comments", h.ListComments)
|
||||
g.POST("/posts/:id/comments", h.CreateComment)
|
||||
|
||||
g.GET("/articles", h.ListArticles)
|
||||
g.GET("/articles/:id", h.GetArticle)
|
||||
|
||||
g.GET("/pro", h.GetPro)
|
||||
g.POST("/pro/activate", h.ActivatePro)
|
||||
|
||||
g.POST("/ai/chat", h.AIChat)
|
||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
||||
g.POST("/upload", h.Upload)
|
||||
}
|
||||
|
||||
// 后台接口(登录开放,其余需 admin JWT)
|
||||
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
||||
api.POST("/admin/login", h.AdminLogin)
|
||||
|
||||
g := api.Group("/admin")
|
||||
g.Use(middleware.AuthAdmin(jm))
|
||||
|
||||
g.GET("/me", h.AdminMe)
|
||||
g.GET("/stats", h.AdminStats)
|
||||
|
||||
g.GET("/users", h.AdminListUsers)
|
||||
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
|
||||
|
||||
g.GET("/pets", h.AdminListPets)
|
||||
|
||||
g.GET("/posts", h.AdminListPosts)
|
||||
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
|
||||
|
||||
g.GET("/comments", h.AdminListComments)
|
||||
g.DELETE("/comments/:id", h.AdminDeleteComment)
|
||||
|
||||
g.GET("/articles", h.AdminListArticles)
|
||||
g.POST("/articles", h.AdminSaveArticle)
|
||||
g.DELETE("/articles/:id", h.AdminDeleteArticle)
|
||||
|
||||
g.GET("/memberships", h.AdminListPro)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// AdminLogin 校验管理员账号密码
|
||||
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
|
||||
var admin model.Admin
|
||||
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if admin.Disabled {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// GetAdmin 取管理员
|
||||
func (s *Service) GetAdmin(id uint) (*model.Admin, error) {
|
||||
var admin model.Admin
|
||||
if err := s.db.First(&admin, id).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// AdminStats 后台统计
|
||||
type AdminStats struct {
|
||||
Users int64 `json:"users"`
|
||||
Pets int64 `json:"pets"`
|
||||
Posts int64 `json:"posts"`
|
||||
Records int64 `json:"records"`
|
||||
}
|
||||
|
||||
// Stats Dashboard 计数
|
||||
func (s *Service) Stats() (*AdminStats, error) {
|
||||
var st AdminStats
|
||||
s.db.Model(&model.User{}).Count(&st.Users)
|
||||
s.db.Model(&model.Pet{}).Count(&st.Pets)
|
||||
s.db.Model(&model.Post{}).Count(&st.Posts)
|
||||
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
|
||||
return &st, nil
|
||||
}
|
||||
|
||||
// ListUsers 用户分页(keyword 匹配昵称)
|
||||
func (s *Service) ListUsers(keyword string, offset, limit int) ([]model.User, int64, error) {
|
||||
q := s.db.Model(&model.User{})
|
||||
if keyword != "" {
|
||||
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var users []model.User
|
||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
// SetUserDisabled 启用/禁用用户
|
||||
func (s *Service) SetUserDisabled(id uint, disabled bool) error {
|
||||
return s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error
|
||||
}
|
||||
|
||||
// ListPetsAdmin 宠物分页
|
||||
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Pet{}).Count(&total)
|
||||
var pets []model.Pet
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
|
||||
return pets, total, err
|
||||
}
|
||||
|
||||
// ListPostsAdmin 帖子分页(status 可选过滤)
|
||||
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
|
||||
q := s.db.Model(&model.Post{})
|
||||
if status != "" {
|
||||
q = q.Where("status = ?", status)
|
||||
}
|
||||
var total int64
|
||||
q.Count(&total)
|
||||
var posts []model.Post
|
||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
|
||||
return posts, total, err
|
||||
}
|
||||
|
||||
// SetPostStatus 审核帖子(published/hidden/deleted)
|
||||
func (s *Service) SetPostStatus(id uint, status string) error {
|
||||
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListCommentsAdmin 评论分页
|
||||
func (s *Service) ListCommentsAdmin(offset, limit int) ([]model.Comment, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Comment{}).Count(&total)
|
||||
var comments []model.Comment
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
|
||||
return comments, total, err
|
||||
}
|
||||
|
||||
// DeleteCommentAdmin 删除评论
|
||||
func (s *Service) DeleteCommentAdmin(id uint) error {
|
||||
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
||||
}
|
||||
|
||||
// ListArticlesAdmin 文章分页(含未发布)
|
||||
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.Article{}).Count(&total)
|
||||
var articles []model.Article
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
|
||||
return articles, total, err
|
||||
}
|
||||
|
||||
// SaveArticle 新增或更新文章(ID 为 0 则新增)
|
||||
func (s *Service) SaveArticle(a *model.Article) error {
|
||||
if a.ID == 0 {
|
||||
return s.db.Create(a).Error
|
||||
}
|
||||
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
|
||||
"icon": a.Icon,
|
||||
"title": a.Title,
|
||||
"description": a.Description,
|
||||
"content": a.Content,
|
||||
"category": a.Category,
|
||||
"related_sheet_type": a.RelatedSheetType,
|
||||
"published": a.Published,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// DeleteArticle 删除文章
|
||||
func (s *Service) DeleteArticle(id uint) error {
|
||||
res := s.db.Delete(&model.Article{}, id)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProAdmin 会员分页
|
||||
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
|
||||
var total int64
|
||||
s.db.Model(&model.ProMembership{}).Count(&total)
|
||||
var list []model.ProMembership
|
||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
||||
func (s *Service) AIChat(userID uint, petID *uint, session, text string) (string, error) {
|
||||
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
|
||||
if s.ai != nil && s.ai.Enabled() {
|
||||
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
||||
reply = r
|
||||
}
|
||||
}
|
||||
|
||||
msgs := []model.AIMessage{
|
||||
{UserID: userID, PetID: petID, Session: session, Role: "user", Text: text},
|
||||
{UserID: userID, PetID: petID, Session: session, Role: "ai", Text: reply},
|
||||
}
|
||||
if err := s.db.Create(&msgs).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
// Upload 代理到对象存储
|
||||
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
return s.storage.Upload(objectName, reader, size, contentType)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
|
||||
const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新手猫狗主人。要求:
|
||||
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
|
||||
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
|
||||
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
|
||||
4. 语气亲切、简洁,用中文,避免长篇大论。`
|
||||
|
||||
// petBrief 组装宠物档案 + 近期记录的上下文文本
|
||||
func (s *Service) petBrief(petID uint) string {
|
||||
var pet model.Pet
|
||||
if err := s.db.First(&pet, petID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "宠物档案:%s(%s,%s,%s,当前体重%s)。\n",
|
||||
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
|
||||
if len(records) > 0 {
|
||||
b.WriteString("近期记录:")
|
||||
items := make([]string, 0, len(records))
|
||||
for _, r := range records {
|
||||
items = append(items, r.Title)
|
||||
}
|
||||
b.WriteString(strings.Join(items, ";"))
|
||||
b.WriteString("。")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// llmChat 真实模型聊天回复
|
||||
func (s *Service) llmChat(petID *uint, text string) (string, error) {
|
||||
system := aiSafetyPrompt
|
||||
if petID != nil {
|
||||
if brief := s.petBrief(*petID); brief != "" {
|
||||
system += "\n\n" + brief
|
||||
}
|
||||
}
|
||||
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
|
||||
}
|
||||
|
||||
// SymptomInput 异常观察入参
|
||||
type SymptomInput struct {
|
||||
Symptoms []string
|
||||
Duration string
|
||||
Spirit string
|
||||
}
|
||||
|
||||
// SymptomResult 风险评估结果(对应前端 risk 弹层)
|
||||
type SymptomResult struct {
|
||||
RiskLevel string `json:"risk_level"` // 低/中/高
|
||||
Causes string `json:"causes"`
|
||||
Suggestion string `json:"suggestion"`
|
||||
SeekCare string `json:"seek_care"`
|
||||
}
|
||||
|
||||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
||||
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 规则化兜底
|
||||
fallback := &SymptomResult{
|
||||
RiskLevel: "中",
|
||||
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
|
||||
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
|
||||
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
|
||||
}
|
||||
if s.ai == nil || !s.ai.Enabled() {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
|
||||
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
|
||||
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
|
||||
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
|
||||
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return fallback, nil // 模型故障不阻断,回退
|
||||
}
|
||||
var res SymptomResult
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
|
||||
type aiPlanExtracted struct {
|
||||
Stage string `json:"stage"`
|
||||
Risk string `json:"risk"`
|
||||
Priority string `json:"priority"`
|
||||
Reminder string `json:"reminder"`
|
||||
Tasks []struct {
|
||||
DayLabel string `json:"day_label"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
} `json:"tasks"`
|
||||
}
|
||||
|
||||
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
|
||||
if s.ai == nil || !s.ai.Enabled() {
|
||||
return nil, false
|
||||
}
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
||||
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
|
||||
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
|
||||
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]},tasks 3-5 条。`
|
||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var ex aiPlanExtracted
|
||||
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return &ex, true
|
||||
}
|
||||
|
||||
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
|
||||
func extractJSON(s string) string {
|
||||
i := strings.IndexByte(s, '{')
|
||||
j := strings.LastIndexByte(s, '}')
|
||||
if i >= 0 && j > i {
|
||||
return s[i : j+1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ListArticles 已发布文章
|
||||
func (s *Service) ListArticles() ([]model.Article, error) {
|
||||
var articles []model.Article
|
||||
err := s.db.Where("published = ?", true).Order("id asc").Find(&articles).Error
|
||||
return articles, err
|
||||
}
|
||||
|
||||
// GetArticle 文章详情
|
||||
func (s *Service) GetArticle(id uint) (*model.Article, error) {
|
||||
var a model.Article
|
||||
if err := s.db.First(&a, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// MockLogin 开发态登录:按昵称找回或新建用户
|
||||
func (s *Service) MockLogin(nickname string) (*model.User, error) {
|
||||
if nickname == "" {
|
||||
nickname = "毛孩子用户"
|
||||
}
|
||||
var u model.User
|
||||
err := s.db.Where("nickname = ?", nickname).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
u = model.User{Nickname: nickname}
|
||||
if e := s.db.Create(&u).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// code2SessionResp 微信 jscode2session 返回
|
||||
type code2SessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// code2Session 调用微信换取 openid
|
||||
func (s *Service) code2Session(code string) (*code2SessionResp, error) {
|
||||
if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" {
|
||||
return nil, errors.New("微信登录未配置 app_id / app_secret")
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("appid", s.cfg.WeChat.AppID)
|
||||
q.Set("secret", s.cfg.WeChat.AppSecret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + q.Encode()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var out code2SessionResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ErrCode != 0 {
|
||||
return nil, fmt.Errorf("微信登录失败(%d): %s", out.ErrCode, out.ErrMsg)
|
||||
}
|
||||
if out.OpenID == "" {
|
||||
return nil, errors.New("微信未返回 openid")
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// WechatLogin 微信小程序登录:code → openid → 找回/新建用户
|
||||
func (s *Service) WechatLogin(code string) (*model.User, error) {
|
||||
sess, err := s.code2Session(code)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var u model.User
|
||||
err = s.db.Where("open_id = ?", sess.OpenID).First(&u).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
u = model.User{OpenID: sess.OpenID, Nickname: "微信用户"}
|
||||
if e := s.db.Create(&u).Error; e != nil {
|
||||
return nil, e
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUser 取用户
|
||||
func (s *Service) GetUser(userID uint) (*model.User, error) {
|
||||
var u model.User
|
||||
if err := s.db.First(&u, userID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// UpdateUser 更新用户资料(昵称/头像/手机号)
|
||||
func (s *Service) UpdateUser(userID uint, fields map[string]any) (*model.User, error) {
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetUser(userID)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
|
||||
func tabTag(tab string) string {
|
||||
switch tab {
|
||||
case "新手求助":
|
||||
return "求助"
|
||||
case "晒宠":
|
||||
return "晒宠"
|
||||
case "经验":
|
||||
return "经验"
|
||||
default: // 推荐 / 关注
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// ListPosts 帖子分页列表
|
||||
func (s *Service) ListPosts(tab string, offset, limit int) ([]model.Post, int64, error) {
|
||||
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
|
||||
if tag := tabTag(tab); tag != "" {
|
||||
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var posts []model.Post
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return posts, total, nil
|
||||
}
|
||||
|
||||
// GetPost 帖子详情
|
||||
func (s *Service) GetPost(postID uint) (*model.Post, error) {
|
||||
var p model.Post
|
||||
if err := s.db.First(&p, postID).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// PostInput 发帖入参
|
||||
type PostInput struct {
|
||||
PetID *uint
|
||||
Identity string
|
||||
Content string
|
||||
Tags datatypes.JSON
|
||||
Images datatypes.JSON
|
||||
}
|
||||
|
||||
// CreatePost 发帖
|
||||
func (s *Service) CreatePost(userID uint, in PostInput) (*model.Post, error) {
|
||||
authorName := "匿名宠友"
|
||||
authorEmoji := "🐾"
|
||||
switch in.Identity {
|
||||
case "official":
|
||||
authorName = "毛孩子计划官方"
|
||||
case "petName":
|
||||
if in.PetID != nil {
|
||||
var pet model.Pet
|
||||
if err := s.db.First(&pet, *in.PetID).Error; err == nil {
|
||||
authorName = pet.Name + "的铲屎官"
|
||||
authorEmoji = pet.Emoji
|
||||
}
|
||||
}
|
||||
}
|
||||
p := model.Post{
|
||||
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
||||
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
||||
Status: model.PostPublished,
|
||||
}
|
||||
if err := s.db.Create(&p).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// LikePost 点赞(幂等:已赞则不重复计数)
|
||||
func (s *Service) LikePost(userID, postID uint) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
like := model.PostLike{PostID: postID, UserID: userID}
|
||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).FirstOrCreate(&like)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 1 { // 新建才计数
|
||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
||||
UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.postLikeCount(postID)
|
||||
}
|
||||
|
||||
// UnlikePost 取消点赞
|
||||
func (s *Service) UnlikePost(userID, postID uint) (int, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 1 {
|
||||
return tx.Model(&model.Post{}).Where("id = ? AND like_count > 0", postID).
|
||||
UpdateColumn("like_count", gorm.Expr("like_count - 1")).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.postLikeCount(postID)
|
||||
}
|
||||
|
||||
func (s *Service) postLikeCount(postID uint) (int, error) {
|
||||
var p model.Post
|
||||
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return p.LikeCount, nil
|
||||
}
|
||||
|
||||
// ListComments 评论分页
|
||||
func (s *Service) ListComments(postID uint, offset, limit int) ([]model.Comment, int64, error) {
|
||||
q := s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var comments []model.Comment
|
||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return comments, total, nil
|
||||
}
|
||||
|
||||
// CreateComment 评论
|
||||
func (s *Service) CreateComment(userID, postID uint, content string) (*model.Comment, error) {
|
||||
if _, err := s.GetPost(postID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var user model.User
|
||||
name := "宠友"
|
||||
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
|
||||
name = user.Nickname
|
||||
}
|
||||
comment := model.Comment{PostID: postID, UserID: userID, AuthorName: name, Content: content, Status: "published"}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&comment).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &comment, nil
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// HomeInsight 首页洞察条一项:Bold 为大号加粗值,Text 为说明
|
||||
type HomeInsight struct {
|
||||
Bold string `json:"bold"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// WeekDay 首页日期条一格
|
||||
type WeekDay struct {
|
||||
Weekday string `json:"weekday"` // 一二三四五六日
|
||||
Day int `json:"day"`
|
||||
Active bool `json:"active"` // 是否今天
|
||||
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
|
||||
}
|
||||
|
||||
// HomeSummary 首页所需的全部动态数据
|
||||
type HomeSummary struct {
|
||||
Greeting string `json:"greeting"`
|
||||
Insights []HomeInsight `json:"insights"`
|
||||
Week []WeekDay `json:"week"`
|
||||
Advice string `json:"advice"`
|
||||
HealthPct int `json:"health_pct"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
}
|
||||
|
||||
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
|
||||
|
||||
// GetHomeSummary 计算首页汇总
|
||||
func (s *Service) GetHomeSummary(userID, petID uint) (*HomeSummary, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
res := &HomeSummary{
|
||||
Greeting: greeting(now, s.userNickname(userID)),
|
||||
HealthStatus: pet.HealthStatus,
|
||||
Insights: s.homeInsights(petID, now),
|
||||
Week: s.homeWeek(petID, now),
|
||||
Advice: s.dailyAdvice(pet),
|
||||
HealthPct: s.todayCompletionPct(petID, now),
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *Service) userNickname(userID uint) string {
|
||||
var u model.User
|
||||
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
|
||||
return u.Nickname
|
||||
}
|
||||
return "铲屎官"
|
||||
}
|
||||
|
||||
func greeting(t time.Time, name string) string {
|
||||
h := t.Hour()
|
||||
switch {
|
||||
case h < 11:
|
||||
return "早上好," + name
|
||||
case h < 14:
|
||||
return "中午好," + name
|
||||
case h < 18:
|
||||
return "下午好," + name
|
||||
default:
|
||||
return "晚上好," + name
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) homeInsights(petID uint, now time.Time) []HomeInsight {
|
||||
insights := make([]HomeInsight, 0, 3)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
|
||||
// 1) 最近一条未到期的提醒
|
||||
var rem model.Reminder
|
||||
if err := s.db.Where("pet_id = ? AND next_due_date >= ?", petID, today).
|
||||
Order("next_due_date asc").First(&rem).Error; err == nil && rem.NextDueDate != nil {
|
||||
days := int(math.Ceil(rem.NextDueDate.Sub(today).Hours() / 24))
|
||||
bold := fmt.Sprintf("%d天后", days)
|
||||
if days <= 0 {
|
||||
bold = "今天"
|
||||
}
|
||||
insights = append(insights, HomeInsight{Bold: bold, Text: rem.Title})
|
||||
}
|
||||
|
||||
// 2) 本周体重变化
|
||||
weekAgo := today.AddDate(0, 0, -7)
|
||||
var latest, earliest model.HealthRecord
|
||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at desc").First(&latest).Error == nil {
|
||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at asc").First(&earliest).Error == nil {
|
||||
d := latest.NumValue - earliest.NumValue
|
||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%+.1fkg", d), Text: "本周体重"})
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 连续记录天数
|
||||
if n := s.streakDays(petID, now); n > 0 {
|
||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%d天", n), Text: "连续记录"})
|
||||
}
|
||||
return insights
|
||||
}
|
||||
|
||||
// streakDays 从今天往前,连续有健康记录的天数
|
||||
func (s *Service) streakDays(petID uint, now time.Time) int {
|
||||
streak := 0
|
||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
for i := 0; i < 60; i++ {
|
||||
start := day.AddDate(0, 0, -i)
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var cnt int64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Count(&cnt)
|
||||
if cnt == 0 {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
}
|
||||
return streak
|
||||
}
|
||||
|
||||
func (s *Service) homeWeek(petID uint, now time.Time) []WeekDay {
|
||||
// 本周一为起点
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
|
||||
monday := today.AddDate(0, 0, -offset)
|
||||
|
||||
dotSet := s.datesWithActivity(petID, monday, monday.AddDate(0, 0, 7))
|
||||
|
||||
week := make([]WeekDay, 7)
|
||||
for i := 0; i < 7; i++ {
|
||||
d := monday.AddDate(0, 0, i)
|
||||
week[i] = WeekDay{
|
||||
Weekday: weekdayCN[int(d.Weekday())],
|
||||
Day: d.Day(),
|
||||
Active: d.Equal(today),
|
||||
HasDot: dotSet[d.Format("2006-01-02")],
|
||||
}
|
||||
}
|
||||
return week
|
||||
}
|
||||
|
||||
// datesWithActivity 区间内有任务/记录/提醒的日期集合
|
||||
func (s *Service) datesWithActivity(petID uint, start, end time.Time) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
var ts []time.Time
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
|
||||
for _, t := range ts {
|
||||
set[t.Format("2006-01-02")] = true
|
||||
}
|
||||
var rs []time.Time
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Pluck("occurred_at", &rs)
|
||||
for _, t := range rs {
|
||||
set[t.Format("2006-01-02")] = true
|
||||
}
|
||||
var reminders []model.Reminder
|
||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
|
||||
for _, r := range reminders {
|
||||
if r.NextDueDate != nil {
|
||||
set[r.NextDueDate.Format("2006-01-02")] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func (s *Service) todayCompletionPct(petID uint, now time.Time) int {
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
var total, done int64
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Count(&total)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ? AND done = ?", petID, start, end, true).Count(&done)
|
||||
return int(math.Round(float64(done) / float64(total) * 100))
|
||||
}
|
||||
|
||||
// dailyAdvice 今日建议:每宠每天缓存一条;启用模型则真调,否则规则化
|
||||
func (s *Service) dailyAdvice(pet *model.Pet) string {
|
||||
day := time.Now().Format("2006-01-02")
|
||||
var da model.DailyAdvice
|
||||
if err := s.db.Where("pet_id = ? AND day = ?", pet.ID, day).First(&da).Error; err == nil {
|
||||
return da.Text
|
||||
}
|
||||
|
||||
text := fmt.Sprintf("%s处于%s,建议今天重点观察体重、饮食和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。", pet.Name, pet.Stage)
|
||||
if s.ai != nil && s.ai.Enabled() {
|
||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
||||
"\n请用2-3句话给出今天的养宠重点提醒,亲切简洁,直接给建议不要寒暄。"
|
||||
if r, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: "今天的养宠建议"}}, ai.Options{Temperature: -1}); err == nil && r != "" {
|
||||
text = r
|
||||
}
|
||||
}
|
||||
_ = s.db.Create(&model.DailyAdvice{PetID: pet.ID, Day: day, Text: text}).Error
|
||||
return text
|
||||
}
|
||||
|
||||
// UserSummary 我的页所需计数
|
||||
type UserSummary struct {
|
||||
Pets int64 `json:"pets"`
|
||||
Records int64 `json:"records"`
|
||||
Reminders int64 `json:"reminders"`
|
||||
ProStatus string `json:"pro_status"`
|
||||
}
|
||||
|
||||
// GetUserSummary 汇总当前用户名下计数
|
||||
func (s *Service) GetUserSummary(userID uint) (*UserSummary, error) {
|
||||
var sum UserSummary
|
||||
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
|
||||
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
|
||||
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
|
||||
|
||||
sum.ProStatus = model.ProNone
|
||||
var m model.ProMembership
|
||||
if err := s.db.Where("user_id = ?", userID).First(&m).Error; err == nil {
|
||||
sum.ProStatus = m.Status
|
||||
}
|
||||
return &sum, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// PetInput 建/改宠物入参
|
||||
type PetInput struct {
|
||||
Name string
|
||||
Emoji string
|
||||
Type string
|
||||
Gender string
|
||||
Birthday *time.Time
|
||||
Weight string
|
||||
Stage string
|
||||
Age string
|
||||
Color string
|
||||
Breed string
|
||||
Goals datatypes.JSON
|
||||
}
|
||||
|
||||
func normalizeWeight(w string) string {
|
||||
w = strings.TrimSpace(w)
|
||||
if w == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.Contains(w, "kg") {
|
||||
return w
|
||||
}
|
||||
return w + "kg"
|
||||
}
|
||||
|
||||
// ListPets 用户的全部宠物
|
||||
func (s *Service) ListPets(userID uint) ([]model.Pet, error) {
|
||||
var pets []model.Pet
|
||||
err := s.db.Where("user_id = ?", userID).Order("id asc").Find(&pets).Error
|
||||
return pets, err
|
||||
}
|
||||
|
||||
// GetPet 取单只宠物(校验归属)
|
||||
func (s *Service) GetPet(userID, petID uint) (*model.Pet, error) {
|
||||
return s.ownedPet(userID, petID)
|
||||
}
|
||||
|
||||
// CreatePet 新增宠物并生成默认数据
|
||||
func (s *Service) CreatePet(userID uint, in PetInput) (*model.Pet, error) {
|
||||
pet := model.Pet{
|
||||
UserID: userID,
|
||||
Name: in.Name,
|
||||
Emoji: in.Emoji,
|
||||
Type: in.Type,
|
||||
Gender: in.Gender,
|
||||
Birthday: in.Birthday,
|
||||
Weight: normalizeWeight(in.Weight),
|
||||
Stage: in.Stage,
|
||||
Age: in.Age,
|
||||
Color: in.Color,
|
||||
Breed: in.Breed,
|
||||
HealthStatus: "正常",
|
||||
Goals: in.Goals,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&pet).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.seedPetDefaults(tx, &pet)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pet, nil
|
||||
}
|
||||
|
||||
// UpdatePet 更新宠物字段
|
||||
func (s *Service) UpdatePet(userID, petID uint, fields map[string]any) (*model.Pet, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w, ok := fields["weight"].(string); ok {
|
||||
fields["weight"] = normalizeWeight(w)
|
||||
}
|
||||
if err := s.db.Model(&model.Pet{}).Where("id = ?", petID).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ownedPet(userID, petID)
|
||||
}
|
||||
|
||||
// DeletePet 删除宠物并级联清除其记录/任务/计划/提醒/每日建议(不留孤儿数据)
|
||||
func (s *Service) DeletePet(userID, petID uint) error {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 先删计划明细(按 plan_id),再删计划
|
||||
var planIDs []uint
|
||||
tx.Model(&model.Plan{}).Where("pet_id = ?", petID).Pluck("id", &planIDs)
|
||||
if len(planIDs) > 0 {
|
||||
if err := tx.Where("plan_id IN ?", planIDs).Delete(&model.PlanTask{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, m := range []any{
|
||||
&model.HealthRecord{}, &model.DailyTask{}, &model.Reminder{},
|
||||
&model.Plan{}, &model.DailyAdvice{},
|
||||
} {
|
||||
if err := tx.Where("pet_id = ?", petID).Delete(m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("id = ? AND user_id = ?", petID, userID).Delete(&model.Pet{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Onboarding 建首宠 + 标记用户已引导
|
||||
func (s *Service) Onboarding(userID uint, in PetInput) (*model.Pet, error) {
|
||||
pet, err := s.CreatePet(userID, in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Update("onboarded", true).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pet, nil
|
||||
}
|
||||
|
||||
// seedPetDefaults 为新宠生成默认今日任务、提醒、30 天计划
|
||||
func (s *Service) seedPetDefaults(tx *gorm.DB, pet *model.Pet) error {
|
||||
today := time.Now()
|
||||
tasks := []model.DailyTask{
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "记录一次体重", Description: "幼年期建议每周至少记录 2 次", Priority: "重要"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "观察饮水和排便", Description: "换粮、应激都可能影响排便状态", SheetType: "poop"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, TaskDate: today, Title: "检查疫苗预约", Description: "第 2 针疫苗还有 5 天", SheetType: "vaccine"},
|
||||
}
|
||||
if err := tx.Create(&tasks).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vaccineDue := today.AddDate(0, 0, 18)
|
||||
dewormDue := today.AddDate(0, 0, 12)
|
||||
reminders := []model.Reminder{
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderVaccine, Title: "第 2 针疫苗", NextDueDate: &vaccineDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderDeworm, Title: "体内外驱虫", NextDueDate: &dewormDue},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderWeight, Title: "体重记录", Frequency: "每周二、周五"},
|
||||
{PetID: pet.ID, UserID: pet.UserID, Type: model.ReminderMonthlyReport, Title: "月度报告", Frequency: "每月 1 日"},
|
||||
}
|
||||
if err := tx.Create(&reminders).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
start := today
|
||||
end := today.AddDate(0, 0, 30)
|
||||
plan := model.Plan{
|
||||
PetID: pet.ID, UserID: pet.UserID, Kind: model.PlanThirtyDay, Stage: pet.Stage,
|
||||
StartDate: &start, EndDate: &end, CompletionPct: 40, Status: "active",
|
||||
Tasks: []model.PlanTask{
|
||||
{Day: 0, DayLabel: "今天", Title: "观察排便状态", Description: "记录颜色、形态、次数,发现软便可连续观察。", SheetType: "taskDetail"},
|
||||
{Day: 1, DayLabel: "明天", Title: "检查疫苗预约", Description: "距离下一针还有 5 天,提前确认医院和时间。", SheetType: "vaccine"},
|
||||
{Day: 7, DayLabel: "第 7 天", Title: "体重趋势检查", Description: "幼年期每周称重,观察是否稳定增长。", SheetType: "weight"},
|
||||
{Day: 14, DayLabel: "第 14 天", Title: "复盘饮食与便便", Description: "如果近期换粮,建议把换粮过程和异常记录合并查看。", SheetType: "taskDetail"},
|
||||
},
|
||||
}
|
||||
return tx.Create(&plan).Error
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// TogglePlanTask 切换计划明细完成状态(校验归属)
|
||||
func (s *Service) TogglePlanTask(userID, taskID uint) error {
|
||||
var pt model.PlanTask
|
||||
if err := s.db.First(&pt, taskID).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
var plan model.Plan
|
||||
if err := s.db.Where("id = ? AND user_id = ?", pt.PlanID, userID).First(&plan).Error; err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
return s.db.Model(&model.PlanTask{}).Where("id = ?", taskID).Update("done", !pt.Done).Error
|
||||
}
|
||||
|
||||
// GetPlan 取宠物的 30 天计划(含明细)
|
||||
func (s *Service) GetPlan(userID, petID uint) (*model.Plan, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var plan model.Plan
|
||||
err := s.db.Preload("Tasks").
|
||||
Where("pet_id = ? AND kind = ?", petID, model.PlanThirtyDay).
|
||||
Order("id desc").First(&plan).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 真实完成度:已完成明细 / 总明细
|
||||
if n := len(plan.Tasks); n > 0 {
|
||||
done := 0
|
||||
for _, t := range plan.Tasks {
|
||||
if t.Done {
|
||||
done++
|
||||
}
|
||||
}
|
||||
plan.CompletionPct = int(math.Round(float64(done) / float64(n) * 100))
|
||||
} else {
|
||||
plan.CompletionPct = 0
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// CalendarResult 日历视图
|
||||
type CalendarResult struct {
|
||||
Year int `json:"year"`
|
||||
Month int `json:"month"`
|
||||
TaskedDays []int `json:"tasked_days"`
|
||||
Today int `json:"today"`
|
||||
}
|
||||
|
||||
// Calendar 某月有任务/提醒的日期
|
||||
func (s *Service) Calendar(userID, petID uint, year, month int) (*CalendarResult, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loc := time.Now().Location()
|
||||
start := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, loc)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
daySet := map[int]struct{}{}
|
||||
|
||||
var taskDates []time.Time
|
||||
s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Pluck("task_date", &taskDates)
|
||||
for _, t := range taskDates {
|
||||
daySet[t.Day()] = struct{}{}
|
||||
}
|
||||
|
||||
var reminders []model.Reminder
|
||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
|
||||
for _, r := range reminders {
|
||||
if r.NextDueDate != nil {
|
||||
daySet[r.NextDueDate.Day()] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
days := make([]int, 0, len(daySet))
|
||||
for d := range daySet {
|
||||
days = append(days, d)
|
||||
}
|
||||
|
||||
today := 0
|
||||
now := time.Now()
|
||||
if now.Year() == year && int(now.Month()) == month {
|
||||
today = now.Day()
|
||||
}
|
||||
return &CalendarResult{Year: year, Month: month, TaskedDays: days, Today: today}, nil
|
||||
}
|
||||
|
||||
// CreateAIPlan 基于用户描述做规则化提取,生成待确认的 AI 计划
|
||||
func (s *Service) CreateAIPlan(userID, petID uint, input string) (*model.Plan, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 默认(规则化)提取与任务
|
||||
var extracted any = extractAIInfo(input, pet.Stage)
|
||||
tasks := []model.PlanTask{
|
||||
{DayLabel: "Day 1-2", Title: "观察食欲和便便", Description: "每日记录饮食、便便、精神状态。"},
|
||||
{DayLabel: "Day 3-5", Title: "记录换粮比例", Description: "逐步提高新粮比例,留意软便。"},
|
||||
{DayLabel: "Day 6-7", Title: "生成复盘建议", Description: "汇总一周状态,输出复盘。"},
|
||||
}
|
||||
|
||||
// 启用模型则用真实提取覆盖
|
||||
if ex, ok := s.aiPlanExtract(pet, input); ok {
|
||||
extracted = map[string]string{
|
||||
"stage": ex.Stage, "risk": ex.Risk, "priority": ex.Priority, "reminder": ex.Reminder,
|
||||
}
|
||||
tasks = tasks[:0]
|
||||
for _, t := range ex.Tasks {
|
||||
tasks = append(tasks, model.PlanTask{DayLabel: t.DayLabel, Title: t.Title, Description: t.Description})
|
||||
}
|
||||
}
|
||||
|
||||
b, _ := jsonMarshalAny(extracted)
|
||||
plan := model.Plan{
|
||||
PetID: petID, UserID: userID, Kind: model.PlanAI, Stage: pet.Stage,
|
||||
UserInput: input, Extracted: datatypes.JSON(b), Status: "draft", Tasks: tasks,
|
||||
}
|
||||
if err := s.db.Create(&plan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// ApplyAIPlan 确认并应用 AI 计划
|
||||
func (s *Service) ApplyAIPlan(userID, planID uint) (*model.Plan, error) {
|
||||
var plan model.Plan
|
||||
if err := s.db.Where("id = ? AND user_id = ?", planID, userID).First(&plan).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
end := now.AddDate(0, 0, 7)
|
||||
if err := s.db.Model(&plan).Updates(map[string]any{
|
||||
"status": "active", "start_date": now, "end_date": end,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Preload("Tasks").First(&plan, plan.ID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &plan, nil
|
||||
}
|
||||
|
||||
// extractAIInfo 规则化提取(后续可替换为大模型)
|
||||
func extractAIInfo(input, stage string) map[string]string {
|
||||
risk := "暂无明显风险"
|
||||
priority := "日常观察"
|
||||
reminder := "常规提醒"
|
||||
if strings.Contains(input, "换粮") || strings.Contains(input, "软便") {
|
||||
risk = "换粮软便"
|
||||
priority = "排便观察"
|
||||
}
|
||||
if strings.Contains(input, "疫苗") {
|
||||
reminder = "第二针疫苗"
|
||||
}
|
||||
if stage == "" {
|
||||
stage = "幼年期"
|
||||
}
|
||||
return map[string]string{
|
||||
"stage": stage + " / 疫苗期",
|
||||
"risk": risk,
|
||||
"priority": priority,
|
||||
"reminder": reminder,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ProFeatures Pro 权益列表
|
||||
var ProFeatures = []string{
|
||||
"365 天养宠计划",
|
||||
"多宠物管理",
|
||||
"月度成长报告",
|
||||
"PDF 健康档案",
|
||||
"年度养宠账单",
|
||||
}
|
||||
|
||||
// ProInfo Pro 状态 + 权益
|
||||
type ProInfo struct {
|
||||
Status string `json:"status"`
|
||||
PlanType string `json:"plan_type"`
|
||||
Price float64 `json:"price"`
|
||||
EndDate *time.Time `json:"end_date"`
|
||||
Features []string `json:"features"`
|
||||
}
|
||||
|
||||
// GetPro 取会员信息
|
||||
func (s *Service) GetPro(userID uint) (*ProInfo, error) {
|
||||
var m model.ProMembership
|
||||
err := s.db.Where("user_id = ?", userID).First(&m).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return &ProInfo{Status: model.ProNone, Features: ProFeatures}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
|
||||
}
|
||||
|
||||
// ActivatePro 开通年费会员
|
||||
func (s *Service) ActivatePro(userID uint) (*ProInfo, error) {
|
||||
now := time.Now()
|
||||
end := now.AddDate(1, 0, 0)
|
||||
var m model.ProMembership
|
||||
err := s.db.Where("user_id = ?", userID).First(&m).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
m = model.ProMembership{UserID: userID}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.Status = model.ProActive
|
||||
m.PlanType = "yearly"
|
||||
m.Price = 29.9
|
||||
m.StartDate = &now
|
||||
m.EndDate = &end
|
||||
if err := s.db.Save(&m).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProInfo{Status: m.Status, PlanType: m.PlanType, Price: m.Price, EndDate: m.EndDate, Features: ProFeatures}, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// RecordInput 健康记录入参
|
||||
type RecordInput struct {
|
||||
Type string
|
||||
Icon string
|
||||
Title string
|
||||
Description string
|
||||
NumValue float64
|
||||
Category string
|
||||
ImageURL string
|
||||
Extra datatypes.JSON
|
||||
OccurredAt *time.Time
|
||||
}
|
||||
|
||||
// ListRecords 列出宠物的健康记录(可按 type 过滤)
|
||||
func (s *Service) ListRecords(userID, petID uint, recordType string) ([]model.HealthRecord, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.db.Where("pet_id = ?", petID)
|
||||
if recordType != "" {
|
||||
q = q.Where("type = ?", recordType)
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
err := q.Order("occurred_at desc, id desc").Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
|
||||
// CreateRecord 新增健康记录;weight 类型同步更新宠物体重
|
||||
func (s *Service) CreateRecord(userID, petID uint, in RecordInput) (*model.HealthRecord, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
occurred := time.Now()
|
||||
if in.OccurredAt != nil {
|
||||
occurred = *in.OccurredAt
|
||||
}
|
||||
rec := model.HealthRecord{
|
||||
PetID: petID, UserID: userID, Type: in.Type, Icon: in.Icon,
|
||||
Title: in.Title, Description: in.Description, NumValue: in.NumValue,
|
||||
Category: in.Category, ImageURL: in.ImageURL, Extra: in.Extra, OccurredAt: occurred,
|
||||
}
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&rec).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Type == model.RecordWeight && in.NumValue > 0 {
|
||||
weight := fmt.Sprintf("%gkg", in.NumValue)
|
||||
if err := tx.Model(&model.Pet{}).Where("id = ?", pet.ID).Update("weight", weight).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// DeleteRecord 删除记录
|
||||
func (s *Service) DeleteRecord(userID, recordID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", recordID, userID).Delete(&model.HealthRecord{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WeightPoint 体重趋势点
|
||||
type WeightPoint struct {
|
||||
Value float64 `json:"value"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
}
|
||||
|
||||
// WeightTrend 最近 N 次体重(升序)
|
||||
func (s *Service) WeightTrend(userID, petID uint, limit int) ([]WeightPoint, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 7
|
||||
}
|
||||
var records []model.HealthRecord
|
||||
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.RecordWeight).
|
||||
Order("occurred_at desc").Limit(limit).Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
points := make([]WeightPoint, 0, len(records))
|
||||
for i := len(records) - 1; i >= 0; i-- {
|
||||
points = append(points, WeightPoint{Value: records[i].NumValue, OccurredAt: records[i].OccurredAt})
|
||||
}
|
||||
return points, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ReminderInput 提醒入参
|
||||
type ReminderInput struct {
|
||||
Type string
|
||||
Title string
|
||||
NextDueDate *time.Time
|
||||
Frequency string
|
||||
}
|
||||
|
||||
// ListReminders 宠物的提醒列表
|
||||
func (s *Service) ListReminders(userID, petID uint) ([]model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var reminders []model.Reminder
|
||||
err := s.db.Where("pet_id = ?", petID).Order("id asc").Find(&reminders).Error
|
||||
return reminders, err
|
||||
}
|
||||
|
||||
// CreateReminder 新增提醒
|
||||
func (s *Service) CreateReminder(userID, petID uint, in ReminderInput) (*model.Reminder, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := model.Reminder{
|
||||
PetID: petID, UserID: userID, Type: in.Type, Title: in.Title,
|
||||
NextDueDate: in.NextDueDate, Frequency: in.Frequency,
|
||||
}
|
||||
if err := s.db.Create(&r).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// UpdateReminder 更新提醒
|
||||
func (s *Service) UpdateReminder(userID, reminderID uint, fields map[string]any) (*model.Reminder, error) {
|
||||
var r model.Reminder
|
||||
if err := s.db.Where("id = ? AND user_id = ?", reminderID, userID).First(&r).Error; err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err := s.db.Model(&r).Updates(fields).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// DeleteReminder 删除提醒
|
||||
func (s *Service) DeleteReminder(userID, reminderID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", reminderID, userID).Delete(&model.Reminder{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// WeeklyReport 周报聚合
|
||||
type WeeklyReport struct {
|
||||
Summary string `json:"summary"`
|
||||
TasksCompleted int64 `json:"tasks_completed"`
|
||||
WeightGain float64 `json:"weight_gain"`
|
||||
HighRiskCount int64 `json:"high_risk_count"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
NextWeekFocus string `json:"next_week_focus"`
|
||||
}
|
||||
|
||||
// GetWeeklyReport 计算最近 7 天周报
|
||||
func (s *Service) GetWeeklyReport(userID, petID uint) (*WeeklyReport, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
weekAgo := time.Now().AddDate(0, 0, -7)
|
||||
|
||||
var tasksCompleted int64
|
||||
s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND done = ? AND updated_at >= ?", petID, true, weekAgo).
|
||||
Count(&tasksCompleted)
|
||||
|
||||
// 体重增长:最近 7 天最新 - 最早
|
||||
var latest, earliest model.HealthRecord
|
||||
gain := 0.0
|
||||
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at desc").First(&latest).Error; err == nil {
|
||||
if err := s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
||||
Order("occurred_at asc").First(&earliest).Error; err == nil {
|
||||
gain = latest.NumValue - earliest.NumValue
|
||||
}
|
||||
}
|
||||
|
||||
var highRisk int64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Where("pet_id = ? AND type = ? AND category = ? AND occurred_at >= ?", petID, model.RecordSymptom, "高", weekAgo).
|
||||
Count(&highRisk)
|
||||
|
||||
status := "稳定成长"
|
||||
if highRisk > 0 {
|
||||
status = "需要关注"
|
||||
}
|
||||
return &WeeklyReport{
|
||||
Summary: fmt.Sprintf("本周 %s 完成 %d 项任务,体重 %+.1fkg,无高风险异常记录。", pet.Name, tasksCompleted, gain),
|
||||
TasksCompleted: tasksCompleted,
|
||||
WeightGain: gain,
|
||||
HighRiskCount: highRisk,
|
||||
HealthStatus: status,
|
||||
NextWeekFocus: "第 2 针疫苗提醒、继续观察体重趋势、避免频繁更换食物。",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BillCategory 账单分类项
|
||||
type BillCategory struct {
|
||||
Category string `json:"category"`
|
||||
Amount float64 `json:"amount"`
|
||||
Percent int `json:"percent"`
|
||||
}
|
||||
|
||||
// Bill 账单聚合
|
||||
type Bill struct {
|
||||
Period string `json:"period"`
|
||||
Total float64 `json:"total"`
|
||||
MaxSingle float64 `json:"max_single"`
|
||||
Categories []BillCategory `json:"categories"`
|
||||
}
|
||||
|
||||
// GetBill 账单(period=month 取本月)
|
||||
func (s *Service) GetBill(userID, petID uint, period string) (*Bill, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
var start time.Time
|
||||
if period == "year" {
|
||||
start = time.Date(now.Year(), 1, 1, 0, 0, 0, 0, now.Location())
|
||||
} else {
|
||||
period = "month"
|
||||
start = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
type row struct {
|
||||
Category string
|
||||
Amount float64
|
||||
}
|
||||
var rows []row
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("category, sum(num_value) as amount").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Group("category").Scan(&rows)
|
||||
|
||||
var total, maxSingle float64
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("coalesce(sum(num_value),0)").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Scan(&total)
|
||||
s.db.Model(&model.HealthRecord{}).
|
||||
Select("coalesce(max(num_value),0)").
|
||||
Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordCost, start).
|
||||
Scan(&maxSingle)
|
||||
|
||||
cats := make([]BillCategory, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
pct := 0
|
||||
if total > 0 {
|
||||
pct = int(r.Amount / total * 100)
|
||||
}
|
||||
cats = append(cats, BillCategory{Category: r.Category, Amount: r.Amount, Percent: pct})
|
||||
}
|
||||
return &Bill{Period: period, Total: total, MaxSingle: maxSingle, Categories: cats}, nil
|
||||
}
|
||||
|
||||
// HealthSummary 健康摘要
|
||||
type HealthSummary struct {
|
||||
VaccineProgress string `json:"vaccine_progress"`
|
||||
DewormStatus string `json:"deworm_status"`
|
||||
WeightTrend string `json:"weight_trend"`
|
||||
AnomalyCount int64 `json:"anomaly_count"`
|
||||
}
|
||||
|
||||
// GetHealthSummary 健康摘要聚合
|
||||
func (s *Service) GetHealthSummary(userID, petID uint) (*HealthSummary, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var vaccineDone int64
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineDone)
|
||||
|
||||
dewormStatus := "暂无计划"
|
||||
var dewormReminder model.Reminder
|
||||
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dewormReminder).Error; err == nil && dewormReminder.NextDueDate != nil {
|
||||
dewormStatus = "下次 " + dewormReminder.NextDueDate.Format("1月2日")
|
||||
}
|
||||
|
||||
var anomalies int64
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordSymptom).Count(&anomalies)
|
||||
|
||||
return &HealthSummary{
|
||||
VaccineProgress: fmt.Sprintf("%d/3,即将到期", vaccineDone),
|
||||
DewormStatus: dewormStatus,
|
||||
WeightTrend: "稳定增长",
|
||||
AnomalyCount: anomalies,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Poster 成长海报数据
|
||||
type Poster struct {
|
||||
PetName string `json:"pet_name"`
|
||||
PetEmoji string `json:"pet_emoji"`
|
||||
Age string `json:"age"`
|
||||
Weight string `json:"weight"`
|
||||
Stage string `json:"stage"`
|
||||
TasksCompleted int64 `json:"tasks_completed"`
|
||||
WeightRecords int64 `json:"weight_records"`
|
||||
VaccineRecords int64 `json:"vaccine_records"`
|
||||
HighRiskCount int64 `json:"high_risk_count"`
|
||||
Headline string `json:"headline"`
|
||||
}
|
||||
|
||||
// GetPoster 生成海报聚合数据
|
||||
func (s *Service) GetPoster(userID, petID uint) (*Poster, error) {
|
||||
pet, err := s.ownedPet(userID, petID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tasksDone, weightRecs, vaccineRecs int64
|
||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND done = ?", petID, true).Count(&tasksDone)
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordWeight).Count(&weightRecs)
|
||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineRecs)
|
||||
|
||||
return &Poster{
|
||||
PetName: pet.Name, PetEmoji: pet.Emoji, Age: pet.Age, Weight: pet.Weight, Stage: pet.Stage,
|
||||
TasksCompleted: tasksDone, WeightRecords: weightRecs, VaccineRecords: vaccineRecs,
|
||||
HighRiskCount: 0, Headline: "稳定成长",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/ai"
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
"github.com/sundynix/pets-be/internal/storage"
|
||||
)
|
||||
|
||||
// ErrNotFound 资源不存在(handler 据此返回 40400)
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// Service 业务逻辑聚合,方法按领域分散在各文件
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
storage *storage.Storage
|
||||
cfg *config.Config
|
||||
ai *ai.Engine
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, st *storage.Storage, cfg *config.Config, engine *ai.Engine) *Service {
|
||||
return &Service{db: db, storage: st, cfg: cfg, ai: engine}
|
||||
}
|
||||
|
||||
// ownedPet 校验宠物归属当前用户并返回
|
||||
func (s *Service) ownedPet(userID, petID uint) (*model.Pet, error) {
|
||||
var pet model.Pet
|
||||
err := s.db.Where("id = ? AND user_id = ?", petID, userID).First(&pet).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pet, nil
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/model"
|
||||
)
|
||||
|
||||
// ListTasks 列出宠物任务(date 为空则取今天)
|
||||
func (s *Service) ListTasks(userID, petID uint, date *time.Time) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := s.db.Where("pet_id = ?", petID)
|
||||
if date != nil {
|
||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
q = q.Where("task_date >= ? AND task_date < ?", start, end)
|
||||
}
|
||||
var tasks []model.DailyTask
|
||||
err := q.Order("id asc").Find(&tasks).Error
|
||||
return tasks, err
|
||||
}
|
||||
|
||||
// ToggleTask 切换任务完成状态
|
||||
func (s *Service) ToggleTask(userID, taskID uint) (*model.DailyTask, error) {
|
||||
var task model.DailyTask
|
||||
if err := s.db.Where("id = ? AND user_id = ?", taskID, userID).First(&task).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
task.Done = !task.Done
|
||||
if task.Done {
|
||||
now := time.Now()
|
||||
task.CompletedAt = &now
|
||||
} else {
|
||||
task.CompletedAt = nil
|
||||
}
|
||||
if err := s.db.Save(&task).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &task, nil
|
||||
}
|
||||
|
||||
// CompleteAllTasks 完成宠物今日全部任务
|
||||
func (s *Service) CompleteAllTasks(userID, petID uint) ([]model.DailyTask, error) {
|
||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
end := start.AddDate(0, 0, 1)
|
||||
if err := s.db.Model(&model.DailyTask{}).
|
||||
Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).
|
||||
Updates(map[string]any{"done": true, "completed_at": now}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.ListTasks(userID, petID, &now)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package service
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// jsonMarshalAny 便捷序列化(用于写入 datatypes.JSON 列)
|
||||
func jsonMarshalAny(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
|
||||
"github.com/sundynix/pets-be/internal/config"
|
||||
)
|
||||
|
||||
// Storage MinIO 对象存储封装
|
||||
type Storage struct {
|
||||
client *minio.Client
|
||||
bucket string
|
||||
publicBaseURL string
|
||||
}
|
||||
|
||||
// New 初始化 MinIO 客户端,并确保 bucket 存在且可公开下载
|
||||
func New(cfg config.MinIOConfig) (*Storage, error) {
|
||||
client, err := minio.New(cfg.Endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("minio new: %w", err)
|
||||
}
|
||||
|
||||
s := &Storage{
|
||||
client: client,
|
||||
bucket: cfg.Bucket,
|
||||
publicBaseURL: strings.TrimRight(cfg.PublicBaseURL, "/"),
|
||||
}
|
||||
if err := s.ensureBucket(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Storage) ensureBucket() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
exists, err := s.client.BucketExists(ctx, s.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bucket exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
|
||||
return fmt.Errorf("make bucket: %w", err)
|
||||
}
|
||||
}
|
||||
// dev:设置公开只读策略,便于直接以 URL 访问对象
|
||||
policy := fmt.Sprintf(`{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [{
|
||||
"Effect": "Allow",
|
||||
"Principal": {"AWS": ["*"]},
|
||||
"Action": ["s3:GetObject"],
|
||||
"Resource": ["arn:aws:s3:::%s/*"]
|
||||
}]
|
||||
}`, s.bucket)
|
||||
if err := s.client.SetBucketPolicy(ctx, s.bucket, policy); err != nil {
|
||||
return fmt.Errorf("set bucket policy: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload 上传对象,返回可公开访问的 URL
|
||||
func (s *Storage) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.client.PutObject(ctx, s.bucket, objectName, reader, size, minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("put object: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("%s/%s/%s", s.publicBaseURL, s.bucket, objectName), nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package errcode
|
||||
|
||||
// 业务错误码。0 成功,其余为业务/系统错误。
|
||||
const (
|
||||
Success = 0
|
||||
ErrParams = 40000 // 参数错误
|
||||
ErrUnauthized = 40100 // 未认证 / token 失效
|
||||
ErrForbidden = 40300 // 无权限
|
||||
ErrNotFound = 40400 // 资源不存在
|
||||
ErrConflict = 40900 // 冲突(如重复)
|
||||
ErrInternal = 50000 // 服务器内部错误
|
||||
)
|
||||
|
||||
// Message 错误码默认文案
|
||||
func Message(code int) string {
|
||||
switch code {
|
||||
case Success:
|
||||
return "ok"
|
||||
case ErrParams:
|
||||
return "参数错误"
|
||||
case ErrUnauthized:
|
||||
return "未登录或登录已失效"
|
||||
case ErrForbidden:
|
||||
return "无权限"
|
||||
case ErrNotFound:
|
||||
return "资源不存在"
|
||||
case ErrConflict:
|
||||
return "资源冲突"
|
||||
case ErrInternal:
|
||||
return "服务器内部错误"
|
||||
default:
|
||||
return "未知错误"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package jwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// Kind 区分令牌主体:小程序用户 / 后台管理员
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindUser Kind = "user"
|
||||
KindAdmin Kind = "admin"
|
||||
)
|
||||
|
||||
// Claims 自定义声明,user 与 admin 复用同一结构,用 Kind 区分
|
||||
type Claims struct {
|
||||
ID uint `json:"id"`
|
||||
Kind Kind `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role,omitempty"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Manager 签发/校验令牌
|
||||
type Manager struct {
|
||||
secret []byte
|
||||
expireHours int
|
||||
}
|
||||
|
||||
func NewManager(secret string, expireHours int) *Manager {
|
||||
if expireHours <= 0 {
|
||||
expireHours = 168
|
||||
}
|
||||
return &Manager{secret: []byte(secret), expireHours: expireHours}
|
||||
}
|
||||
|
||||
// Generate 签发令牌
|
||||
func (m *Manager) Generate(id uint, kind Kind, name, role string) (string, error) {
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Name: name,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(m.expireHours) * time.Hour)),
|
||||
Issuer: "pets-be",
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(m.secret)
|
||||
}
|
||||
|
||||
// Parse 校验并解析令牌
|
||||
func (m *Manager) Parse(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return m.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package response
|
||||
|
||||
// PageQuery 公共分页请求参数(列表接口统一内嵌复用)
|
||||
// 用法:type XxxListReq struct { response.PageQuery; Keyword string `form:"keyword"` }
|
||||
type PageQuery struct {
|
||||
Page int `form:"page" json:"page"`
|
||||
PageSize int `form:"page_size" json:"page_size"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultPage = 1
|
||||
defaultPageSize = 10
|
||||
maxPageSize = 100
|
||||
)
|
||||
|
||||
// Normalize 修正非法/越界的分页参数,返回可安全使用的值
|
||||
func (p *PageQuery) Normalize() {
|
||||
if p.Page <= 0 {
|
||||
p.Page = defaultPage
|
||||
}
|
||||
if p.PageSize <= 0 {
|
||||
p.PageSize = defaultPageSize
|
||||
}
|
||||
if p.PageSize > maxPageSize {
|
||||
p.PageSize = maxPageSize
|
||||
}
|
||||
}
|
||||
|
||||
// Offset 供 GORM .Offset() 使用
|
||||
func (p PageQuery) Offset() int {
|
||||
page, size := p.Page, p.PageSize
|
||||
if page <= 0 {
|
||||
page = defaultPage
|
||||
}
|
||||
if size <= 0 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
return (page - 1) * size
|
||||
}
|
||||
|
||||
// Limit 供 GORM .Limit() 使用
|
||||
func (p PageQuery) Limit() int {
|
||||
if p.PageSize <= 0 {
|
||||
return defaultPageSize
|
||||
}
|
||||
if p.PageSize > maxPageSize {
|
||||
return maxPageSize
|
||||
}
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
// PageResult 公共分页响应结构
|
||||
type PageResult struct {
|
||||
List any `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// NewPage 组装分页响应;list 传空时序列化为 []
|
||||
func NewPage(list any, total int64, p PageQuery) PageResult {
|
||||
page, size := p.Page, p.PageSize
|
||||
if page <= 0 {
|
||||
page = defaultPage
|
||||
}
|
||||
if size <= 0 {
|
||||
size = defaultPageSize
|
||||
}
|
||||
return PageResult{List: list, Total: total, Page: page, PageSize: size}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/pets-be/pkg/errcode"
|
||||
)
|
||||
|
||||
// Body 统一响应体:{ code, message, data }
|
||||
type Body struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
// OK 成功响应(data 可为任意结构,含分页 PageResult)
|
||||
func OK(c *gin.Context, data any) {
|
||||
c.JSON(http.StatusOK, Body{
|
||||
Code: errcode.Success,
|
||||
Message: errcode.Message(errcode.Success),
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
// OKMsg 带自定义文案的成功响应
|
||||
func OKMsg(c *gin.Context, message string, data any) {
|
||||
c.JSON(http.StatusOK, Body{Code: errcode.Success, Message: message, Data: data})
|
||||
}
|
||||
|
||||
// Fail 业务失败:HTTP 恒为 200,用 code 区分(前端统一按 code 判断)
|
||||
func Fail(c *gin.Context, code int, message string) {
|
||||
if message == "" {
|
||||
message = errcode.Message(code)
|
||||
}
|
||||
c.JSON(http.StatusOK, Body{Code: code, Message: message, Data: nil})
|
||||
}
|
||||
|
||||
// FailParams 参数错误快捷方法
|
||||
func FailParams(c *gin.Context, message string) {
|
||||
Fail(c, errcode.ErrParams, message)
|
||||
}
|
||||
|
||||
// FailErr 内部错误快捷方法(携带 error 文案)
|
||||
func FailErr(c *gin.Context, err error) {
|
||||
msg := errcode.Message(errcode.ErrInternal)
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
Fail(c, errcode.ErrInternal, msg)
|
||||
}
|
||||
|
||||
// Abort 在中间件中失败并中断(如鉴权失败)
|
||||
func Abort(c *gin.Context, code int, message string) {
|
||||
if message == "" {
|
||||
message = errcode.Message(code)
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusOK, Body{Code: code, Message: message, Data: nil})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the Oxlint configuration
|
||||
|
||||
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.18",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"axios": "^1.18.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.23.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"autoprefixer": "^10.5.2",
|
||||
"oxlint": "^1.71.0",
|
||||
"postcss": "^8.5.16",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,184 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||
import type { JSX } from 'react'
|
||||
import { getToken } from '@/lib/api'
|
||||
import Layout from '@/components/Layout'
|
||||
import Login from '@/pages/Login'
|
||||
import Dashboard from '@/pages/Dashboard'
|
||||
import Users from '@/pages/Users'
|
||||
import Pets from '@/pages/Pets'
|
||||
import Posts from '@/pages/Posts'
|
||||
import Comments from '@/pages/Comments'
|
||||
import Articles from '@/pages/Articles'
|
||||
import Members from '@/pages/Members'
|
||||
|
||||
function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
return getToken() ? children : <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="pets" element={<Pets />} />
|
||||
<Route path="posts" element={<Posts />} />
|
||||
<Route path="comments" element={<Comments />} />
|
||||
<Route path="articles" element={<Articles />} />
|
||||
<Route path="members" element={<Members />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,59 @@
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
|
||||
import { LayoutDashboard, Users, PawPrint, MessageSquare, MessagesSquare, FileText, Crown, LogOut } from 'lucide-react'
|
||||
import { clearToken } from '@/lib/api'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const nav = [
|
||||
{ to: '/', label: '概览', icon: LayoutDashboard, end: true },
|
||||
{ to: '/users', label: '用户管理', icon: Users },
|
||||
{ to: '/pets', label: '宠物管理', icon: PawPrint },
|
||||
{ to: '/posts', label: '帖子审核', icon: MessageSquare },
|
||||
{ to: '/comments', label: '评论管理', icon: MessagesSquare },
|
||||
{ to: '/articles', label: '文章管理', icon: FileText },
|
||||
{ to: '/members', label: '会员管理', icon: Crown },
|
||||
]
|
||||
|
||||
export default function Layout() {
|
||||
const navigate = useNavigate()
|
||||
function logout() {
|
||||
clearToken()
|
||||
navigate('/login')
|
||||
}
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="w-56 shrink-0 border-r bg-card p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 px-2 py-3 mb-4">
|
||||
<span className="text-2xl">🐾</span>
|
||||
<span className="font-bold">毛孩子后台</span>
|
||||
</div>
|
||||
<nav className="flex flex-col gap-1">
|
||||
{nav.map((n) => (
|
||||
<NavLink
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
end={n.end}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive ? 'bg-primary/15 text-primary' : 'text-muted-foreground hover:bg-accent',
|
||||
)
|
||||
}
|
||||
>
|
||||
<n.icon className="h-4 w-4" />
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-auto">
|
||||
<Button variant="ghost" className="w-full justify-start text-muted-foreground" onClick={logout}>
|
||||
<LogOut className="h-4 w-4" /> 退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 p-8 overflow-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function Pager({
|
||||
page,
|
||||
totalPages,
|
||||
total,
|
||||
onChange,
|
||||
}: {
|
||||
page: number
|
||||
totalPages: number
|
||||
total: number
|
||||
onChange: (p: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between pt-4 text-sm text-muted-foreground">
|
||||
<span>共 {total} 条</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => onChange(page + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import type * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary/15 text-primary',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
destructive: 'border-transparent bg-destructive/15 text-destructive',
|
||||
outline: 'text-foreground',
|
||||
success: 'border-transparent bg-emerald-100 text-emerald-700',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as React from 'react'
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-card hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow-sm', className)} {...props} />
|
||||
),
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
),
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />,
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardContent }
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out" />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-card p-6 shadow-lg rounded-xl',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 opacity-70 hover:opacity-100">
|
||||
<X className="h-4 w-4" />
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
))
|
||||
DialogContent.displayName = 'DialogContent'
|
||||
|
||||
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h2 className={cn('text-lg font-semibold', className)} {...props} />
|
||||
}
|
||||
|
||||
export { Dialog, DialogTrigger, DialogClose, DialogContent, DialogHeader, DialogTitle }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-card px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from 'react'
|
||||
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root ref={ref} className={cn('text-sm font-medium leading-none', className)} {...props} />
|
||||
))
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,46 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
),
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => <thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />,
|
||||
)
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<tr ref={ref} className={cn('border-b transition-colors hover:bg-muted/50', className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<th ref={ref} className={cn('h-10 px-3 text-left align-middle font-medium text-muted-foreground', className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn('p-3 align-middle', className)} {...props} />
|
||||
),
|
||||
)
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 40 33% 98%;
|
||||
--foreground: 24 10% 15%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 24 10% 15%;
|
||||
--primary: 33 90% 62%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 40 30% 94%;
|
||||
--secondary-foreground: 24 10% 25%;
|
||||
--muted: 40 20% 94%;
|
||||
--muted-foreground: 25 8% 45%;
|
||||
--accent: 40 40% 92%;
|
||||
--accent-foreground: 24 10% 20%;
|
||||
--destructive: 5 78% 57%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 33 25% 88%;
|
||||
--input: 33 25% 88%;
|
||||
--ring: 33 90% 62%;
|
||||
--radius: 0.6rem;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC',
|
||||
'Microsoft YaHei', sans-serif;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const TOKEN_KEY = 'pets_admin_token'
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY) || ''
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem(TOKEN_KEY, t)
|
||||
}
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
const http = axios.create({ baseURL: '/api' })
|
||||
|
||||
http.interceptors.request.use((cfg) => {
|
||||
const t = getToken()
|
||||
if (t) cfg.headers.Authorization = `Bearer ${t}`
|
||||
return cfg
|
||||
})
|
||||
|
||||
// 统一响应 {code,message,data}:成功返回 data,失败抛错
|
||||
http.interceptors.response.use(
|
||||
(resp) => {
|
||||
const body = resp.data
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
if (body.code === 0) return body.data
|
||||
if (body.code === 40100) {
|
||||
clearToken()
|
||||
if (location.pathname !== '/admin/login') location.href = '/admin/login'
|
||||
}
|
||||
return Promise.reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
return body
|
||||
},
|
||||
(err) => Promise.reject(err),
|
||||
)
|
||||
|
||||
export interface Page<T> {
|
||||
list: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
http.post<any, { token: string; admin: any }>('/admin/login', { username, password }),
|
||||
me: () => http.get<any, any>('/admin/me'),
|
||||
stats: () => http.get<any, { users: number; pets: number; posts: number; records: number }>('/admin/stats'),
|
||||
|
||||
users: (params: any) => http.get<any, Page<any>>('/admin/users', { params }),
|
||||
setUserDisabled: (id: number, disabled: boolean) =>
|
||||
http.put(`/admin/users/${id}/disabled`, { disabled }),
|
||||
|
||||
pets: (params: any) => http.get<any, Page<any>>('/admin/pets', { params }),
|
||||
|
||||
posts: (params: any) => http.get<any, Page<any>>('/admin/posts', { params }),
|
||||
setPostStatus: (id: number, status: string) => http.put(`/admin/posts/${id}/status`, { status }),
|
||||
|
||||
comments: (params: any) => http.get<any, Page<any>>('/admin/comments', { params }),
|
||||
deleteComment: (id: number) => http.delete(`/admin/comments/${id}`),
|
||||
|
||||
articles: (params: any) => http.get<any, Page<any>>('/admin/articles', { params }),
|
||||
saveArticle: (a: any) => http.post('/admin/articles', a),
|
||||
deleteArticle: (id: number) => http.delete(`/admin/articles/${id}`),
|
||||
|
||||
memberships: (params: any) => http.get<any, Page<any>>('/admin/memberships', { params }),
|
||||
}
|
||||
|
||||
export default http
|
||||