Compare commits
2
Commits
51ead8f411
...
ed2095950d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed2095950d | ||
|
|
bfcf8788a4 |
+1
-3
@@ -9,6 +9,7 @@ pets-be/bin/
|
|||||||
*.exe
|
*.exe
|
||||||
*.out
|
*.out
|
||||||
|
|
||||||
|
./pets-be/.idea
|
||||||
# Node
|
# Node
|
||||||
pets-be/web/admin/node_modules/
|
pets-be/web/admin/node_modules/
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -17,8 +18,5 @@ yarn-error.log*
|
|||||||
# Logs / runtime
|
# Logs / runtime
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Secrets — real config stays local; copy from config.example.yaml
|
|
||||||
pets-be/configs/config.yaml
|
|
||||||
|
|
||||||
# 服务器环境变量(含密钥)
|
# 服务器环境变量(含密钥)
|
||||||
deploy/.env
|
deploy/.env
|
||||||
|
|||||||
Generated
+10
@@ -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
|
||||||
Generated
+10
@@ -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>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/sundynix-pets.iml" filepath="$PROJECT_DIR$/.idea/sundynix-pets.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -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>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+12
-60
@@ -1,67 +1,19 @@
|
|||||||
# 肉垫计划 · 服务器环境变量
|
POSTGRES_HOST=192.168.100.127
|
||||||
# 用法:cp .env.example .env && vi .env
|
POSTGRES_PORT=5432
|
||||||
# .env 只放在服务器 /home/workspace/sundynix-pet 下,不进 git,CI 也不会覆盖它。
|
POSTGRES_USER=root
|
||||||
|
POSTGRES_PASSWORD=root
|
||||||
|
POSTGRES_DATABASE=sundynix-pets
|
||||||
|
POSTGRES_SSL_MODE=disable
|
||||||
|
|
||||||
# ── 外部 MySQL ──
|
MINIO_ENDPOINT=https://s3.sundynix.cn
|
||||||
# 注意:容器里的 127.0.0.1 是容器自己。若 MySQL 装在宿主机上,
|
|
||||||
# 填 host.docker.internal 并在 compose 里加 extra_hosts,或直接填内网 IP。
|
|
||||||
MYSQL_HOST=192.168.100.127
|
|
||||||
MYSQL_PORT=3307
|
|
||||||
MYSQL_USER=root
|
|
||||||
MYSQL_PASSWORD=root
|
|
||||||
MYSQL_DATABASE=sundynix_pet
|
|
||||||
|
|
||||||
# ── 外部 MinIO(对象存储 / 图片) ──
|
|
||||||
# ⚠️ 这是多个服务共用的 MinIO 实例(sundynix-agent / sundynix-plant 等也在用):
|
|
||||||
# 1) BUCKET 必须填本项目自己的桶,填错别的服务的桶会污染人家数据;
|
|
||||||
# 2) 强烈建议不要用 root 凭据,另开一个只能访问 sundynix-pet 的账号:
|
|
||||||
# mc admin user add <alias> pets-svc <强密码>
|
|
||||||
# mc admin policy create <alias> pets-only pets-only.json # 只授权 sundynix-pet
|
|
||||||
# mc admin policy attach <alias> pets-only --user pets-svc
|
|
||||||
# 3) 桶已存在时程序不会修改其访问策略;图片直链需要该桶为 public-read,
|
|
||||||
# 这一项由运维一次性配好。
|
|
||||||
# ENDPOINT 是「写入」用的 MinIO API 地址:内网直连、不带协议、不走 nginx。
|
|
||||||
# 公网等价地址是 162.14.122.200:3407(本机调试时可用,跨公网慢)。
|
|
||||||
MINIO_ENDPOINT=192.168.100.126:9000
|
|
||||||
MINIO_USE_SSL=false
|
|
||||||
MINIO_ACCESS_KEY=sundynix
|
MINIO_ACCESS_KEY=sundynix
|
||||||
MINIO_SECRET_KEY=sundynix
|
MINIO_SECRET_KEY=sundynix
|
||||||
MINIO_BUCKET=sundynix-pet
|
MINIO_BUCKET=sundynix-pet
|
||||||
# 图片对外地址,小程序直接加载它。正式版必须 https。
|
MINIO_USE_SSL=true
|
||||||
MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn
|
|
||||||
|
|
||||||
# ── 登录鉴权 ──
|
JWT_SECRET=
|
||||||
JWT_SECRET=改成随机长字符串
|
WECHAT_APP_ID=wx52dfc635739a9c19
|
||||||
# access token 有效期(小时)。短一点更安全,过期由前端拿 refresh token 静默续期
|
WECHAT_APP_SECRET=
|
||||||
JWT_EXPIRE_HOURS=2
|
|
||||||
# 刷新令牌有效期:小程序 30 天、后台 7 天。到期才需要重新登录
|
|
||||||
JWT_REFRESH_EXPIRE_HOURS=720
|
|
||||||
JWT_ADMIN_REFRESH_EXPIRE_HOURS=168
|
|
||||||
# 生产必须 false,否则任何人都能用昵称直接登录
|
|
||||||
AUTH_DEV_LOGIN=false
|
|
||||||
|
|
||||||
# ── 微信小程序 ──
|
|
||||||
WECHAT_APP_ID=wx开头的AppID
|
|
||||||
WECHAT_APP_SECRET=小程序密钥
|
|
||||||
# 内容安全图片异步检测的结果回调校验 Token。
|
|
||||||
# 要和「小程序后台 → 开发管理 → 消息推送」里填的 Token 完全一致(英文数字,3-32 位)。
|
|
||||||
# 消息推送 URL 填 https://pet.sundynix.cn/api/wx/sec-callback,加密方式选「明文模式」。
|
|
||||||
# 没配消息推送前可留空(社区内容会走后台手动审核)。
|
|
||||||
WECHAT_MSG_TOKEN=petsSecCheck2026
|
|
||||||
|
|
||||||
# ── 管理后台(/admin) ──
|
|
||||||
ADMIN_USERNAME=sundynix
|
ADMIN_USERNAME=sundynix
|
||||||
ADMIN_PASSWORD=改成强密码
|
ADMIN_PASSWORD=sundynix
|
||||||
|
|
||||||
# ── AI(provider 中立,OpenAI 兼容协议) ──
|
|
||||||
AI_ENABLED=true
|
|
||||||
AI_PROVIDER=openai
|
|
||||||
AI_BASE_URL=https://api.deepseek.com
|
|
||||||
AI_API_KEY=sk-你的key
|
|
||||||
AI_MODEL=deepseek-chat
|
|
||||||
AI_TEMPERATURE=0.6
|
|
||||||
AI_MAX_TOKENS=1024
|
|
||||||
AI_TIMEOUT_SEC=30
|
|
||||||
|
|
||||||
# ── 镜像 tag(一般不用改,CI 推的是 latest) ──
|
|
||||||
TAG=latest
|
TAG=latest
|
||||||
|
|||||||
+10
-49
@@ -1,15 +1,3 @@
|
|||||||
# 肉垫计划 · 应用部署(只有一个容器)
|
|
||||||
#
|
|
||||||
# 用法(服务器 /home/workspace/sundynix-pet 下):
|
|
||||||
# cp .env.example .env && vi .env # 填 MySQL / MinIO / 微信 / AI 的真实值
|
|
||||||
# docker compose up -d --no-build # 镜像由 CI 推送并 docker load,这里不构建
|
|
||||||
#
|
|
||||||
# 说明:
|
|
||||||
# - 一个容器同时提供 /api(小程序接口)和 /admin(管理后台,go:embed 内嵌),
|
|
||||||
# 容器内监听 9090,映射到宿主机 4000。
|
|
||||||
# - MySQL、MinIO 都用外部现成的,不在这里起容器;地址和凭据全部走 .env。
|
|
||||||
# - 配置一律通过 PETS_ 前缀的环境变量注入,容器内没有 config.yaml。
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: sundynix/pets-be:${TAG:-latest}
|
image: sundynix/pets-be:${TAG:-latest}
|
||||||
@@ -19,52 +7,25 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PETS_SERVER_PORT: "9090"
|
PETS_SERVER_PORT: "9090"
|
||||||
PETS_SERVER_MODE: release
|
PETS_SERVER_MODE: release
|
||||||
|
PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127}
|
||||||
# ── 外部 MySQL ──
|
PETS_POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||||
PETS_MYSQL_HOST: ${MYSQL_HOST:?}
|
PETS_POSTGRES_USER: ${POSTGRES_USER:-root}
|
||||||
PETS_MYSQL_PORT: ${MYSQL_PORT:-3306}
|
PETS_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
|
||||||
PETS_MYSQL_USER: ${MYSQL_USER:?}
|
PETS_POSTGRES_DATABASE: ${POSTGRES_DATABASE:-sundynix-pets}
|
||||||
PETS_MYSQL_PASSWORD: ${MYSQL_PASSWORD:?}
|
PETS_POSTGRES_SSL_MODE: ${POSTGRES_SSL_MODE:-disable}
|
||||||
PETS_MYSQL_DATABASE: ${MYSQL_DATABASE:-pets}
|
PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:-https://s3.sundynix.cn}
|
||||||
PETS_MYSQL_CHARSET: utf8mb4
|
|
||||||
|
|
||||||
# ── 外部 MinIO ──
|
|
||||||
PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:?}
|
|
||||||
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
|
||||||
PETS_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
|
PETS_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
|
||||||
PETS_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
|
PETS_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
|
||||||
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-pets}
|
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
|
||||||
# 图片对外访问地址,小程序直接加载;正式版必须 https
|
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
|
||||||
PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?}
|
|
||||||
|
|
||||||
PETS_JWT_SECRET: ${JWT_SECRET:?}
|
PETS_JWT_SECRET: ${JWT_SECRET:?}
|
||||||
# access token 2 小时;过期由前端拿 refresh token 静默续期
|
|
||||||
PETS_JWT_EXPIRE_HOURS: ${JWT_EXPIRE_HOURS:-2}
|
|
||||||
PETS_JWT_REFRESH_EXPIRE_HOURS: ${JWT_REFRESH_EXPIRE_HOURS:-720}
|
|
||||||
PETS_JWT_ADMIN_REFRESH_EXPIRE_HOURS: ${JWT_ADMIN_REFRESH_EXPIRE_HOURS:-168}
|
|
||||||
|
|
||||||
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
|
||||||
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
|
||||||
# 内容安全图片异步检测的结果回调校验 Token(消息推送服务器配置里的 Token)。
|
PETS_AUTH_DEV_LOGIN: "false"
|
||||||
# 可选:留空则回调不校验签名;配了才和微信后台的 Token 对上
|
|
||||||
PETS_WECHAT_MSG_TOKEN: ${WECHAT_MSG_TOKEN:-}
|
|
||||||
# 生产必须 false,否则任何人都能用昵称直接登录
|
|
||||||
PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false}
|
|
||||||
|
|
||||||
PETS_ADMIN_USERNAME: ${ADMIN_USERNAME:-sundynix}
|
PETS_ADMIN_USERNAME: ${ADMIN_USERNAME:-sundynix}
|
||||||
PETS_ADMIN_PASSWORD: ${ADMIN_PASSWORD:?}
|
PETS_ADMIN_PASSWORD: ${ADMIN_PASSWORD:?}
|
||||||
|
|
||||||
PETS_AI_ENABLED: ${AI_ENABLED:-true}
|
|
||||||
PETS_AI_PROVIDER: ${AI_PROVIDER:-openai}
|
|
||||||
PETS_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com}
|
|
||||||
PETS_AI_API_KEY: ${AI_API_KEY:-}
|
|
||||||
PETS_AI_MODEL: ${AI_MODEL:-deepseek-chat}
|
|
||||||
PETS_AI_TEMPERATURE: ${AI_TEMPERATURE:-0.6}
|
|
||||||
PETS_AI_MAX_TOKENS: ${AI_MAX_TOKENS:-1024}
|
|
||||||
PETS_AI_TIMEOUT_SEC: ${AI_TIMEOUT_SEC:-30}
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/api/ping"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/api/ping"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
start_period: 20s
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#f5f3ed" />
|
||||||
|
<link rel="icon" type="image/png" href="pets-fe/assets/logo-corgi-v2.png" />
|
||||||
|
<title>肉垫计划 · 宠物生活原型</title>
|
||||||
|
<style>
|
||||||
|
:root{--ink:#183b36;--muted:#83928d;--green:#174e42;--green2:#28735d;--mint:#dcece2;--cream:#f5f3ed;--card:#fff;--line:#e9eee9;--orange:#ecaa69;--shadow:0 18px 50px rgba(22,64,54,.09)}
|
||||||
|
*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--cream);color:var(--ink);font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif}button,input,select{font:inherit}button{cursor:pointer}button:focus-visible,input:focus-visible,select:focus-visible{outline:3px solid #a2d4b3;outline-offset:2px}
|
||||||
|
.desktop{min-height:100vh;display:flex;align-items:center;justify-content:center;gap:90px;padding:40px}.intro{width:330px}.logo{display:flex;align-items:center;gap:11px;font-size:24px;font-weight:900;letter-spacing:-1px}.logo-mark{width:42px;height:42px;display:grid;place-items:center;background:var(--green);border-radius:15px;color:white;font-size:25px;transform:rotate(-8deg)}.eyebrow{font-size:12px;letter-spacing:3px;font-weight:800;color:var(--green2);margin:50px 0 14px}.intro h1{font-size:54px;line-height:1.16;letter-spacing:-3px;margin:0}.intro h1 em{font-style:normal;color:var(--green2)}.intro p{color:#687d74;font-size:16px;line-height:1.9;margin:22px 0}.intro-tags{display:flex;gap:8px;flex-wrap:wrap}.intro-tags span{background:#e8ebe3;color:#4b6e60;border-radius:999px;padding:8px 13px;font-size:12px;font-weight:700}.intro-note{margin-top:65px;font-size:12px;color:#a0aaa2;line-height:1.7}
|
||||||
|
.phone{width:430px;height:min(890px,calc(100vh - 42px));min-height:680px;background:#f8faf7;border:9px solid #1d302e;border-radius:45px;box-shadow:0 40px 90px rgba(17,52,42,.23);overflow:hidden;position:relative;flex:none}.phone:before{content:"";position:absolute;z-index:30;top:9px;left:50%;transform:translateX(-50%);width:103px;height:25px;background:#1d302e;border-radius:0 0 17px 17px}.app{height:100%;display:flex;flex-direction:column;position:relative}.screen{flex:1;overflow-y:auto;scrollbar-width:none;padding-bottom:24px}.screen::-webkit-scrollbar{display:none}.screen-head{padding:43px 25px 14px}.topline{display:flex;justify-content:space-between;align-items:center}.date-kicker{font-size:12px;color:var(--muted);letter-spacing:.4px;font-weight:700}.screen-title{font-size:29px;letter-spacing:-1.1px;margin:5px 0 0;font-weight:850}.icon-btn{width:40px;height:40px;border:0;border-radius:14px;background:#fff;color:var(--ink);display:grid;place-items:center;box-shadow:0 3px 15px #163b3612}.icon-btn svg,.nav-item svg,.small-icon svg,.action-icon svg,.outline-icon svg{width:21px;height:21px;stroke:currentColor;fill:none;stroke-width:1.85;stroke-linecap:round;stroke-linejoin:round}.avatar-mini{width:42px;height:42px;border:3px solid white;border-radius:50%;background:#f5cf9e;display:grid;place-items:center;font-size:25px;box-shadow:0 3px 14px #2444361b}
|
||||||
|
.content{padding:0 20px}.card{background:#fff;border-radius:23px;box-shadow:0 3px 22px rgba(27,63,47,.035)}.hero{position:relative;overflow:hidden;background:#174e42;color:#fff;padding:22px 22px 20px;min-height:194px}.hero:before,.hero:after{content:"";position:absolute;border:1px solid #ffffff2b;border-radius:50%;width:240px;height:240px;right:-63px;top:-87px}.hero:after{width:184px;height:184px;right:-35px;top:-55px}.hero-top{position:relative;z-index:1;display:flex;justify-content:space-between}.hero-label{font-size:12px;color:#c4ddcb;font-weight:700}.hero h2{font-size:29px;letter-spacing:-1px;margin:8px 0 5px}.hero p{font-size:12px;color:#c6ddd0;margin:0}.hero-pet{width:107px;height:107px;background:#f1c584;border-radius:35px;display:grid;place-items:center;font-size:72px;transform:rotate(7deg);margin-right:4px;box-shadow:inset 0 -10px 0 #ffffff30}.hero-pet span{transform:rotate(-7deg)}.pet-picker{border:0;background:#ffffff26;color:#fff;border-radius:100px;padding:8px 12px;font-size:12px;font-weight:700;margin-top:13px}.hero-bottom{position:relative;z-index:1;display:flex;align-items:center;gap:8px;margin-top:9px;color:#e8f2e9;font-size:12px}.hero-dot{height:7px;width:7px;border-radius:50%;background:#8fdbac}
|
||||||
|
.section-heading{display:flex;align-items:end;justify-content:space-between;margin:24px 2px 13px}.section-heading h3{font-size:19px;margin:0;letter-spacing:-.4px}.section-heading button{border:0;background:none;color:var(--green2);font-size:12px;font-weight:800;padding:4px}.walk-card{display:flex;align-items:center;gap:15px;padding:16px}.walk-illustration{width:98px;height:100px;flex:none;border-radius:18px;background:#e1eedc;position:relative;overflow:hidden;display:grid;place-items:center;font-size:48px}.walk-illustration:before{content:"";position:absolute;width:110px;height:40px;border-radius:50%;background:#bcd9ac;bottom:-18px;left:-5px}.walk-illustration span{position:relative}.walk-info{flex:1}.walk-info h4{margin:0 0 7px;font-size:16px}.walk-info p{font-size:12px;line-height:1.6;color:var(--muted);margin:0 0 11px}.primary{border:0;background:var(--green);color:white;border-radius:12px;padding:11px 16px;font-size:12px;font-weight:800;box-shadow:0 8px 18px #174e4222}.primary:active,.soft-button:active,.nav-item:active{transform:scale(.97)}.stats{display:flex;gap:9px;margin-top:11px}.stat{flex:1;padding:15px 13px}.stat small{display:block;color:var(--muted);font-size:11px;font-weight:700}.stat strong{display:block;font-size:22px;margin-top:4px;letter-spacing:-.7px}.stat strong i{font-size:11px;font-style:normal;color:#94a29a;font-weight:600;margin-left:3px}.stat:nth-child(2){background:#f5e9db}.stat:nth-child(3){background:#e8eee9}
|
||||||
|
.task-row{display:flex;align-items:center;gap:13px;padding:13px 4px;border-bottom:1px solid var(--line)}.task-row:last-child{border:0}.check{height:23px;width:23px;border:2px solid #ccdcd1;border-radius:8px;background:#fff;color:white;flex:none;padding:0;display:grid;place-items:center}.task-row.done .check{background:var(--green2);border-color:var(--green2)}.task-row.done .task-name{text-decoration:line-through;color:#a8b4ad}.task-icon{height:41px;width:41px;display:grid;place-items:center;border-radius:13px;background:#f1f5ec;font-size:22px}.task-copy{flex:1}.task-name{font-weight:750;font-size:13px}.task-sub{color:#9aa9a0;font-size:11px;margin-top:3px}.task-time{font-size:11px;color:#a1aea5}.tasks-card{padding:5px 15px}.progress{display:flex;align-items:center;gap:8px;color:var(--muted);font-size:11px;margin:0 2px 12px}.progress-track{flex:1;height:6px;background:#e4eae5;border-radius:20px;overflow:hidden}.progress-track span{display:block;height:100%;background:#79b894;border-radius:20px;transition:width .25s}
|
||||||
|
.meet-mini{display:flex;gap:12px;padding:14px}.meet-photo{width:88px;min-height:88px;border-radius:16px;background:linear-gradient(150deg,#c9e0c0,#85b08a);display:grid;place-items:center;font-size:43px;flex:none}.meet-title{font-size:13px;font-weight:800;margin:4px 0 8px}.meet-meta{font-size:11px;color:#91a096;line-height:1.6}.pill{display:inline-block;background:#f0f6ed;color:#4a8b62;border-radius:8px;font-size:10px;font-weight:800;padding:4px 7px;margin-top:9px}
|
||||||
|
.tabbar{height:76px;flex:none;background:#fff;box-shadow:0 -5px 30px #294c3b0a;display:flex;align-items:flex-start;padding:11px 12px max(10px,env(safe-area-inset-bottom));z-index:20}.nav-item{border:0;background:none;flex:1;color:#a1aca3;display:flex;align-items:center;flex-direction:column;gap:4px;font-size:10px;font-weight:700;padding:0}.nav-item.active{color:var(--green)}.nav-item.active svg{stroke-width:2.5}.nav-item svg{height:24px;width:24px}.nav-item.walk-nav{position:relative}.walk-nav .nav-bubble{width:50px;height:50px;border-radius:17px;background:var(--green);color:white;display:grid;place-items:center;margin-top:-25px;box-shadow:0 9px 17px #174e4238}.walk-nav svg{width:25px;height:25px}.walk-nav span:last-child{margin-top:4px}.walk-nav.active .nav-bubble{background:#e6a66b}
|
||||||
|
.map-wrap{height:365px;position:relative;background:#e8efe7;overflow:hidden}.map-wrap svg{width:100%;height:100%;display:block}.map-overlay{position:absolute;left:18px;right:18px;top:16px;display:flex;justify-content:space-between;align-items:start}.map-badge{background:#fff;padding:10px 12px;border-radius:13px;box-shadow:0 4px 18px #204a321c;font-size:11px;font-weight:750}.map-badge i{display:inline-block;width:7px;height:7px;background:#70b788;border-radius:50%;margin-right:5px}.map-control{background:#fff;border:0;width:40px;height:40px;border-radius:13px;box-shadow:0 4px 18px #204a321c;font-size:19px;color:var(--green)}.walk-panel{position:relative;margin:-25px 16px 0;padding:19px 18px 18px;z-index:2}.walk-panel h3{margin:0;font-size:20px}.walk-panel .subtitle{font-size:12px;color:var(--muted);margin:7px 0 19px}.walk-numbers{display:flex;margin:0 0 18px}.walk-numbers div{flex:1;border-right:1px solid var(--line);padding-left:12px}.walk-numbers div:first-child{padding-left:0}.walk-numbers div:last-child{border:0}.walk-numbers strong{display:block;font-size:24px;letter-spacing:-1px}.walk-numbers small{display:block;color:#9cab9e;font-size:10px;margin-top:3px}.walk-actions{display:flex;gap:9px}.walk-actions button{flex:1;height:45px;font-size:13px}.soft-button{background:#e9f2ea;color:var(--green);border:0;border-radius:12px;font-weight:800}.share-line{display:flex;justify-content:space-between;align-items:center;padding:15px 4px 0;font-size:12px;font-weight:750}.share-line small{display:block;font-size:10px;color:var(--muted);font-weight:500;margin-top:4px}.switch{width:42px;height:25px;border:0;border-radius:100px;background:#d8e2da;padding:3px;transition:background .2s}.switch:before{content:"";display:block;width:19px;height:19px;background:#fff;border-radius:50%;transition:transform .2s}.switch.on{background:#2d896b}.switch.on:before{transform:translateX(17px)}.tip-card{background:#e9f1e6;color:#527661;padding:12px 14px;border-radius:14px;font-size:11px;line-height:1.6;margin-top:13px}
|
||||||
|
.filters{display:flex;gap:8px;overflow-x:auto;padding:2px 0 14px;scrollbar-width:none}.filter{border:1px solid #e0e8e0;background:#fff;color:#6f8778;white-space:nowrap;padding:9px 13px;border-radius:10px;font-size:12px;font-weight:750}.filter.active{background:var(--green);color:white;border-color:var(--green)}.meet-card{padding:13px;margin-bottom:11px}.meet-banner{height:117px;border-radius:16px;position:relative;overflow:hidden;background:linear-gradient(140deg,#c3d9bd,#8bb490)}.meet-banner.peach{background:linear-gradient(140deg,#f1d7b8,#d6b591)}.meet-banner.blue{background:linear-gradient(140deg,#c1dcd9,#8fb8b1)}.meet-banner:before{content:"";position:absolute;width:240px;height:120px;border-radius:50%;background:#ffffff3d;left:-25px;bottom:-80px}.meet-banner:after{content:"";position:absolute;width:180px;height:90px;border-radius:50%;background:#ffffff4a;right:-30px;bottom:-55px}.meet-banner .scene{font-size:57px;position:absolute;right:27px;bottom:7px;z-index:1}.meet-banner .tag{position:absolute;left:12px;top:12px;background:#fff;color:var(--green);padding:6px 9px;border-radius:8px;font-size:10px;font-weight:800}.meet-card h4{margin:14px 2px 6px;font-size:16px}.meet-card p{font-size:11px;color:#94a39a;margin:0 2px 12px}.meet-footer{display:flex;justify-content:space-between;align-items:center}.participants{display:flex;align-items:center;font-size:11px;color:#94a39a}.face{width:25px;height:25px;border-radius:50%;border:2px solid white;background:#eed2a0;display:grid;place-items:center;font-size:16px;margin-left:-5px}.face:first-child{margin-left:0}.participants span:last-child{margin-left:7px}.join-btn{border:0;border-radius:10px;background:#e5f0e6;color:var(--green);padding:9px 14px;font-size:11px;font-weight:800}.join-btn.joined{background:#f2eee5;color:#a78d66}
|
||||||
|
.day-strip{display:flex;justify-content:space-between;margin:7px 0 21px}.day{border:0;background:transparent;color:#a3b0a6;display:flex;align-items:center;flex-direction:column;gap:9px;font-size:10px;font-weight:750;width:42px}.day strong{width:37px;height:37px;display:grid;place-items:center;border-radius:12px;font-size:13px;color:#597064}.day.active strong{background:var(--green);color:white}.day.active{color:var(--green)}.task-group-title{display:flex;align-items:center;justify-content:space-between;margin:21px 1px 9px}.task-group-title strong{font-size:14px}.task-group-title span{font-size:11px;color:#9ba99f}.task-card{padding:2px 14px}.task-card .task-row{padding:15px 2px}.add-dashed{width:100%;background:none;border:1.5px dashed #bed1c0;color:#6c9875;border-radius:14px;padding:14px;font-size:12px;font-weight:800;margin-top:12px}.pet-list-card{padding:8px}.pet-list-row{display:flex;align-items:center;gap:13px;padding:11px;border-radius:15px;cursor:pointer}.pet-list-row.active{background:#edf5ed}.pet-avatar{width:57px;height:57px;display:grid;place-items:center;border-radius:18px;background:#f4d19d;font-size:37px;flex:none}.pet-list-row:nth-child(2) .pet-avatar{background:#d8e5dc}.pet-list-row h4{margin:0 0 5px;font-size:14px}.pet-list-row p{margin:0;color:var(--muted);font-size:11px}.pet-list-row .arrow{margin-left:auto;color:#9aaba0}.profile-card{padding:18px}.profile-top{display:flex;gap:16px;align-items:center}.profile-big{width:76px;height:76px;border-radius:24px;background:#f4d19d;display:grid;place-items:center;font-size:51px}.profile-name{font-size:22px;font-weight:850;margin:0 0 5px}.profile-desc{font-size:12px;color:var(--muted)}.profile-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-top:18px}.profile-field{border-radius:12px;background:#f5f8f4;padding:11px 9px}.profile-field small{color:#96a69a;display:block;font-size:10px}.profile-field strong{font-size:12px;display:block;margin-top:5px}.profile-note{margin-top:14px;background:#f7f3e9;padding:12px;border-radius:12px;color:#816f57;font-size:11px;line-height:1.6}.outline-button{background:white;border:1px solid #cbded0;color:var(--green);border-radius:12px;padding:12px;font-size:12px;font-weight:800;width:100%;margin-top:13px}
|
||||||
|
.modal-backdrop{position:absolute;inset:0;background:#15392e70;z-index:40;display:flex;align-items:end;justify-content:center}.modal{width:100%;max-height:82%;overflow:auto;background:#fff;border-radius:26px 26px 0 0;padding:20px 24px 32px;box-shadow:0 -10px 40px #132d2a20}.modal-handle{width:38px;height:4px;background:#d7e3da;border-radius:3px;margin:-5px auto 18px}.modal-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}.modal-head h3{font-size:20px;margin:0}.close{border:0;background:#f0f5f0;color:#496558;border-radius:10px;width:30px;height:30px;font-size:19px}.field{display:block;margin:13px 0}.field span{display:block;color:#687e70;font-weight:750;font-size:12px;margin-bottom:7px}.field input,.field select{width:100%;border:1px solid #dce8dd;background:#f9fbf8;border-radius:11px;padding:12px;color:var(--ink);font-size:14px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.modal .primary{width:100%;height:46px;margin-top:12px}.choice-row{display:flex;gap:8px}.choice{border:1px solid #dce8dd;background:#fff;border-radius:11px;padding:10px 14px;font-size:12px;color:#6d8074}.choice.active{background:#e8f3ea;color:var(--green);border-color:#9acaaa;font-weight:800}.toast{position:absolute;bottom:92px;left:50%;transform:translateX(-50%);z-index:70;background:#1e4639;color:white;border-radius:12px;padding:11px 16px;font-size:12px;white-space:nowrap;box-shadow:0 8px 20px #183b3640;animation:toastin .2s ease}@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}.empty{padding:30px;text-align:center;color:var(--muted);font-size:12px}.small-note{color:#9aa99d;font-size:11px;line-height:1.6}
|
||||||
|
@media(max-width:900px){.desktop{padding:0;display:block}.intro{display:none}.phone{width:100%;height:100dvh;min-height:0;border:0;border-radius:0;box-shadow:none}.phone:before{display:none}.screen-head{padding-top:max(28px,env(safe-area-inset-top))}.tabbar{height:calc(68px + env(safe-area-inset-bottom));padding-top:8px}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="desktop">
|
||||||
|
<aside class="intro"><div class="logo"><img class="brand-logo" src="pets-fe/assets/logo-corgi-v2.png" alt="肉垫计划头像" /> 肉垫计划</div><div class="eyebrow">FIVE CORGIS, ONE HAPPY HOME</div><h1>陪它们走过<br><em>每一天。</em></h1><p>把遛狗、约伴和日常照护,放在一个轻松好用的地方。五只柯基也能一起出门,各有自己的生活档案。</p><div class="intro-tags"><span>🐕 遛狗轨迹</span><span>🫶 同城约遛</span><span>💧 每日照护</span><span>📋 宠物档案</span></div><div class="intro-note">交互原型 · 浏览器本地保存体验数据<br>点击右侧手机中的导航和按钮体验</div></aside>
|
||||||
|
<main class="phone"><div class="app"><div id="screen" class="screen"></div><nav id="tabbar" class="tabbar"></nav><div id="modalRoot"></div><div id="toastRoot"></div></div></main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const icons={home:'<path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>',walk:'<path d="M12 3c1.3 0 2.3 1 2.3 2.3S13.3 7.6 12 7.6 9.7 6.6 9.7 5.3 10.7 3 12 3ZM7 7c1 0 1.8.8 1.8 1.8S8 10.6 7 10.6 5.2 9.8 5.2 8.8 6 7 7 7Zm10 0c1 0 1.8.8 1.8 1.8S18 10.6 17 10.6s-1.8-.8-1.8-1.8S16 7 17 7ZM5 12.1c1 0 1.7.8 1.7 1.8S6 15.7 5 15.7s-1.7-.8-1.7-1.8S4 12.1 5 12.1Zm14 0c1 0 1.7.8 1.7 1.8s-.7 1.8-1.7 1.8-1.7-.8-1.7-1.8.7-1.8 1.7-1.8ZM12 10.3c2.2 0 5.8 4.3 5.8 6.9 0 1.6-1.2 2.8-2.7 2.8-1.2 0-2-.8-3.1-.8s-1.9.8-3.1.8c-1.5 0-2.7-1.2-2.7-2.8 0-2.6 3.6-6.9 5.8-6.9Z"/>',meet:'<path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm8 0a3 3 0 1 0 0-6M2 20v-2a6 6 0 0 1 12 0v2H2Zm14-8a5 5 0 0 1 6 5v3h-5"/>',tasks:'<rect x="4" y="4" width="16" height="16" rx="3"/><path d="m8 12 2.5 2.5L16 9"/>',pets:'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.6-7 8-7s8 3 8 7"/>',bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9ZM10 21h4"/>',pin:'<path d="M20 10c0 5-8 11-8 11S4 15 4 10a8 8 0 1 1 16 0Z"/><circle cx="12" cy="10" r="2.5"/>',chev:'<path d="m9 18 6-6-6-6"/>',plus:'<path d="M12 5v14M5 12h14"/>',share:'<path d="M12 16V3m0 0L7 8m5-5 5 5M4 14v6h16v-6"/>',check:'<path d="m5 12 4 4L19 6"/>',pause:'<rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/>',play:'<path d="m7 4 13 8-13 8z"/>',edit:'<path d="M12 20h9M16 4l4 4L9 19H5v-4L16 4Z"/>'};
|
||||||
|
function icon(name){return `<svg viewBox="0 0 24 24" aria-hidden="true">${icons[name]||icons.home}</svg>`}
|
||||||
|
function esc(s){return String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
||||||
|
const todayKey=()=>new Date().toISOString().slice(0,10);const defaultState={pets:[{id:1,name:'布丁',emoji:'🐕',type:'狗狗',breed:'柯基',gender:'男孩',age:'2岁3个月',weight:'12.6 kg',birthday:'2024-06-15',note:'亲人、爱玩球,对鸡肉过敏'},{id:2,name:'奶盖',emoji:'🐈',type:'猫猫',breed:'英短',gender:'女孩',age:'1岁8个月',weight:'4.2 kg',birthday:'2025-01-18',note:'喜欢晒太阳,晚上要梳毛'}],activePet:1,tasks:[{id:1,petId:1,name:'早餐喂食',icon:'🍚',time:'08:00',category:'饮食',done:false},{id:2,petId:1,name:'更换饮水',icon:'💧',time:'10:00',category:'饮水',done:false},{id:3,petId:1,name:'晚间散步',icon:'🦮',time:'18:30',category:'活动',done:false},{id:4,petId:1,name:'睡前梳毛',icon:'🪮',time:'21:00',category:'护理',done:false},{id:5,petId:2,name:'早餐喂食',icon:'🍚',time:'08:30',category:'饮食',done:false},{id:6,petId:2,name:'更换饮水',icon:'💧',time:'10:00',category:'饮水',done:false}],meetups:[{id:1,title:'周末草坪放电局',place:'世纪公园 · 3号门',time:'周六 09:30',distance:'1.2 km',people:4,emoji:'🐶🌿',theme:'green',tag:'小型犬友好',joined:false},{id:2,title:'日落河边慢慢走',place:'滨江步道 · 亲水平台',time:'周六 17:20',distance:'2.4 km',people:3,emoji:'🐕🌅',theme:'peach',tag:'轻松慢走',joined:false},{id:3,title:'晨间社交散步',place:'中央绿地 · 南入口',time:'周日 08:00',distance:'3.6 km',people:6,emoji:'🐩🌳',theme:'blue',tag:'新朋友',joined:false}],walks:12,totalKm:18.6,share:false,walkStatus:'idle',walkSeconds:0,walkDistance:0};
|
||||||
|
let state;try{state={...defaultState,...JSON.parse(localStorage.getItem('pawly-prototype')||'{}')}}catch{state=structuredClone(defaultState)}let tab='home',modal='',filter='全部',taskDay=0,walkTicker=null,toastTimer=null;
|
||||||
|
const save=()=>localStorage.setItem('pawly-prototype',JSON.stringify(state));const pet=()=>state.pets.find(p=>p.id===state.activePet)||state.pets[0];let petTasks=()=>state.tasks.filter(t=>t.petId===state.activePet);let counts=()=>({done:petTasks().filter(t=>t.done).length,total:petTasks().length});
|
||||||
|
function toast(msg){document.getElementById('toastRoot').innerHTML=`<div class="toast">${esc(msg)}</div>`;clearTimeout(toastTimer);toastTimer=setTimeout(()=>document.getElementById('toastRoot').innerHTML='',2600)}
|
||||||
|
function header(kicker,title,action){return `<header class="screen-head"><div class="topline"><div><div class="date-kicker">${kicker}</div><h1 class="screen-title">${title}</h1></div>${action||`<button class="icon-btn" onclick="showModal('notifications')" aria-label="通知">${icon('bell')}</button>`}</div></header>`}
|
||||||
|
function section(title,action='',fn=''){return `<div class="section-heading"><h3>${title}</h3>${action?`<button onclick="${fn}">${action} ›</button>`:''}</div>`}
|
||||||
|
function renderHome(){let p=pet(),c=counts();return `${header('你好,今天也一起好好生活','早安,铲屎官 ☀️',`<button class="avatar-mini" onclick="setTab('pets')" aria-label="宠物档案">${p.emoji}</button>`)}<div class="content"><div class="hero card"><div class="hero-top"><div><div class="hero-label">今日的主角</div><h2>${esc(p.name)},出发吧!</h2><p>${esc(p.breed)} · ${esc(p.age)}</p><button class="pet-picker" onclick="showModal('switchPet')">切换宠物 ⌄</button></div><div class="hero-pet"><span>${p.emoji}</span></div></div><div class="hero-bottom"><i class="hero-dot"></i>陪伴它的每一天,都是好日子</div></div>${section('带它出去走走','遛狗记录',"setTab('walk')")}<div class="card walk-card"><div class="walk-illustration"><span>🦮</span></div><div class="walk-info"><h4>今天的风刚刚好</h4><p>带${esc(p.name)}出门转转,记录每一步的小快乐。</p><button class="primary" onclick="setTab('walk')">开始遛狗 →</button></div></div><div class="stats"><div class="stat card"><small>本月遛狗</small><strong>${state.walks}<i>次</i></strong></div><div class="stat card"><small>累计距离</small><strong>${Number(state.totalKm).toFixed(1)}<i>km</i></strong></div><div class="stat card"><small>今日任务</small><strong>${c.done}/${c.total}</strong></div></div>${section('今天要做的事','全部任务',"setTab('tasks')")}<div class="progress"><div class="progress-track"><span style="width:${c.total?c.done/c.total*100:0}%"></span></div>${c.done}/${c.total} 已完成</div><div class="card tasks-card">${petTasks().slice(0,4).map(taskHTML).join('')||'<div class="empty">还没有任务,去添加第一项吧</div>'}</div>${section('附近有约','看看更多',"setTab('meet')")}<div class="card meet-mini" onclick="setTab('meet')" style="cursor:pointer"><div class="meet-photo">🐶</div><div><div class="meet-title">周末草坪放电局</div><div class="meet-meta">📍世纪公园 · 3号门<br>周六 09:30 · 距你 1.2 km</div><span class="pill">已有 4 位小伙伴</span></div></div></div>`}
|
||||||
|
function taskHTML(t){return `<div class="task-row ${t.done?'done':''}"><button class="check" onclick="toggleTask(${t.id})" aria-label="${t.done?'取消完成':'完成'}${esc(t.name)}">${t.done?icon('check'):''}</button><div class="task-icon">${t.icon}</div><div class="task-copy"><div class="task-name">${esc(t.name)}</div><div class="task-sub">${esc(pet().name)} · ${esc(t.category)}</div></div><div class="task-time">${esc(t.time)}</div></div>`}
|
||||||
|
const mapSVG=`<svg viewBox="0 0 420 365" preserveAspectRatio="xMidYMid slice" aria-label="公园路线示意地图"><rect width="420" height="365" fill="#e9efe7"/><path d="M-20 80 Q85 108 140 58 T300 26 T440 65M-20 265 Q108 243 192 286 T440 254" stroke="#fdfcf6" stroke-width="32" fill="none"/><path d="M64-10 Q105 101 73 189 T112 388M324-10 Q289 100 338 182 T304 390" stroke="#fdfcf6" stroke-width="22" fill="none"/><path d="M-10 186Q110 173 160 152T435 182" stroke="#fdfcf6" stroke-width="15" fill="none"/><path d="M-10 80 Q85 108 140 58 T300 26 T440 65M-20 265 Q108 243 192 286 T440 254" stroke="#d3dfd1" stroke-width="1.5" fill="none"/><path d="M170 62Q242 36 283 77T284 169Q233 192 184 165T170 62" fill="#cfe2c7"/><path d="M-4 302Q47 295 89 318T160 365H-4" fill="#c6dec3"/><path d="M334 292Q396 265 430 291V365H350" fill="#c6dec3"/><path d="M133 54c14-7 27-8 34-6M175 218c23-8 40-8 54-5" stroke="#9bc4a3" stroke-width="5" stroke-linecap="round"/><path d="M140 244 Q162 206 205 209 T274 176 Q301 155 294 113" stroke="#2c9570" stroke-width="6" stroke-linecap="round" stroke-dasharray="2 1" fill="none"/><circle cx="141" cy="244" r="15" fill="#fff"/><circle cx="141" cy="244" r="8" fill="#2c9570"/><circle cx="294" cy="113" r="21" fill="#fff"/><text x="294" y="120" text-anchor="middle" font-size="20">🐕</text><text x="183" y="114" font-size="12" fill="#83a48b" font-weight="bold">中心草坪</text><text x="25" y="143" font-size="11" fill="#94a69b">银杏大道</text><text x="282" y="323" font-size="11" fill="#94a69b">咖啡角</text></svg>`;
|
||||||
|
function timeDisplay(){let m=Math.floor(state.walkSeconds/60),s=state.walkSeconds%60;return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`}
|
||||||
|
function renderWalk(){let running=state.walkStatus==='running',paused=state.walkStatus==='paused';return `${header('和毛孩子一起探索城市','一起去遛狗',`<button class="icon-btn" onclick="showModal('walkHistory')" aria-label="遛狗记录">${icon('walk')}</button>`)}<div class="map-wrap">${mapSVG}<div class="map-overlay"><div class="map-badge"><i></i>${running?'正在记录路线':paused?'路线已暂停':'附近适合散步'}</div><button class="map-control" onclick="toast('已定位到附近公园(原型示意地图)')" aria-label="定位">⌖</button></div></div><div class="card walk-panel"><h3>${running?'正在和'+esc(pet().name)+'散步':paused?'休息一下,等你回来':'出门走走吧'}</h3><p class="subtitle">${running?'每一步都值得记录':'打开位置分享,让朋友知道你们在哪里'}</p><div class="walk-numbers"><div><strong id="walkTime">${timeDisplay()}</strong><small>时长</small></div><div><strong id="walkDistance">${state.walkDistance.toFixed(2)}</strong><small>公里</small></div><div><strong id="walkPace">${state.walkSeconds?Math.max(4,Math.round(state.walkSeconds/60/Math.max(state.walkDistance,.01))):'--'}</strong><small>分钟 / 公里</small></div></div><div class="walk-actions">${state.walkStatus==='idle'?`<button class="primary" onclick="startWalk()">${icon('play')} 开始遛狗</button>`:`<button class="soft-button" onclick="pauseWalk()">${icon(running?'pause':'play')} ${running?'暂停':'继续'}</button><button class="primary" onclick="finishWalk()">结束并保存</button>`}</div><div class="share-line"><div>分享实时位置<small>${state.share?'已开启 · 仅在本次遛狗中分享':'让约遛的朋友更容易找到你'}</small></div><button class="switch ${state.share?'on':''}" onclick="toggleShare()" aria-label="切换位置分享" aria-pressed="${state.share}"></button></div><div class="tip-card">${state.share?'🟢 位置分享已开启。你可以随时关闭,结束遛狗后会自动停止。':'🔒 位置仅在你主动开启时分享。这个原型使用示意地图,不会读取真实定位。'}</div></div><div class="content">${section('附近好去处')}<div class="card meet-mini" onclick="setTab('meet')" style="cursor:pointer"><div class="meet-photo">🌳</div><div><div class="meet-title">世纪公园 · 中心草坪</div><div class="meet-meta">开阔草地 · 适合小型犬<br>步行约 12 分钟</div><span class="pill">发现附近约遛活动 →</span></div></div></div>`}
|
||||||
|
function renderMeet(){let ms=state.meetups.filter(m=>filter==='全部'||(filter==='附近'&&parseFloat(m.distance)<=2.5)||(filter==='周末'&&m.time.includes('周'))||(filter==='我参加的'&&m.joined));return `${header('和新朋友一起出门','约个伴,一起遛',`<button class="icon-btn" onclick="showModal('newMeet')" aria-label="发起约遛">${icon('plus')}</button>`)}<div class="content"><div class="filters">${['全部','附近','周末','我参加的'].map(f=>`<button class="filter ${filter===f?'active':''}" onclick="setFilter('${f}')">${f}</button>`).join('')}</div>${ms.map(m=>`<article class="card meet-card"><div class="meet-banner ${m.theme}"><span class="tag">${esc(m.tag)}</span><span class="scene">${m.emoji}</span></div><h4>${esc(m.title)}</h4><p>📍 ${esc(m.place)} · ${esc(m.distance)}</p><div class="meet-footer"><div class="participants"><span class="face">🐶</span><span class="face">🐕</span><span class="face">🐩</span><span>${esc(m.time)} · ${m.people} 人</span></div><button class="join-btn ${m.joined?'joined':''}" onclick="joinMeet(${m.id})">${m.joined?'已参加 ✓':'加入约遛'}</button></div></article>`).join('')||'<div class="card empty">这里暂时没有活动,发起一场吧 🐾</div>'}<button class="add-dashed" onclick="showModal('newMeet')">+ 发起一场约遛</button><p class="small-note">约遛活动为原型演示数据,加入后会保存在此浏览器中。</p></div>`}
|
||||||
|
function renderTasks(){let c=counts(), days=['日','一','二','三','四','五','六'],base=new Date();let arr=Array.from({length:7},(_,i)=>{let d=new Date(base);d.setDate(d.getDate()+i);return d});return `${header('细心照顾每一个小日子','今日照护计划',`<button class="icon-btn" onclick="showModal('newTask')" aria-label="添加任务">${icon('plus')}</button>`)}<div class="content"><div class="day-strip">${arr.map((d,i)=>`<button class="day ${taskDay===i?'active':''}" onclick="setTaskDay(${i})"><span>周${days[d.getDay()]}</span><strong>${d.getDate()}</strong></button>`).join('')}</div><div class="card" style="padding:17px 18px;background:#e2f0e5"><div style="display:flex;align-items:center;justify-content:space-between"><div><div style="font-size:11px;color:#71947a;font-weight:750">${esc(pet().name)}的照护进度</div><div style="font-size:24px;font-weight:850;margin-top:4px">${c.done}/${c.total} <span style="font-size:12px;color:#6a9775">项已完成</span></div></div><div style="font-size:39px">${c.done===c.total&&c.total?'🎉':'🌱'}</div></div><div class="progress-track" style="margin-top:15px;background:#cae1d0"><span style="width:${c.total?c.done/c.total*100:0}%"></span></div></div>${taskDay?'<div class="tip-card">这是未来日期的计划预览。原型中的完成状态按当前清单演示。</div>':''}<div class="task-group-title"><strong>日常任务</strong><span>${c.total} 项任务</span></div><div class="card task-card">${petTasks().map(taskHTML).join('')||'<div class="empty">还没有任务,添加一项吧</div>'}</div><button class="add-dashed" onclick="showModal('newTask')">+ 添加任务</button></div>`}
|
||||||
|
function renderPets(){let p=pet();return `${header('每只毛孩子都独一无二','我的毛孩子',`<button class="icon-btn" onclick="showModal('newPet')" aria-label="添加宠物">${icon('plus')}</button>`)}<div class="content"><div class="card pet-list-card">${state.pets.map(x=>`<div class="pet-list-row ${x.id===p.id?'active':''}" onclick="switchPet(${x.id})"><div class="pet-avatar">${x.emoji}</div><div><h4>${esc(x.name)} ${x.id===p.id?'· 当前':''}</h4><p>${esc(x.breed)} · ${esc(x.age)}</p></div><div class="arrow">${icon('chev')}</div></div>`).join('')}</div>${section('宠物档案')}<div class="card profile-card"><div class="profile-top"><div class="profile-big">${p.emoji}</div><div><div class="profile-name">${esc(p.name)}</div><div class="profile-desc">${esc(p.breed)} · ${esc(p.gender)}</div></div></div><div class="profile-grid"><div class="profile-field"><small>年龄</small><strong>${esc(p.age)}</strong></div><div class="profile-field"><small>体重</small><strong>${esc(p.weight)}</strong></div><div class="profile-field"><small>生日</small><strong>${esc(p.birthday||'未填写')}</strong></div></div><div class="profile-note">🐾 ${esc(p.note||'还没有备注,记录它的小习惯吧。')}</div><button class="outline-button" onclick="showModal('editPet')">${icon('edit')} 编辑档案</button></div>${section('一起生活')}<div class="stats"><div class="stat card"><small>遛狗次数</small><strong>${state.walks}</strong></div><div class="stat card"><small>累计路程</small><strong>${Number(state.totalKm).toFixed(1)}<i>km</i></strong></div></div><button class="add-dashed" onclick="showModal('newPet')">+ 添加另一只毛孩子</button></div>`}
|
||||||
|
function renderNav(){let items=[['home','首页','home'],['meet','约遛','meet'],['walk','遛狗','walk'],['tasks','任务','tasks'],['pets','宠物','pets']];document.getElementById('tabbar').innerHTML=items.map(([id,label,ic])=>`<button class="nav-item ${tab===id?'active':''} ${id==='walk'?'walk-nav':''}" onclick="setTab('${id}')" aria-label="${label}">${id==='walk'?`<span class="nav-bubble">${icon(ic)}</span>`:icon(ic)}<span>${label}</span></button>`).join('')}
|
||||||
|
function render(){document.getElementById('screen').innerHTML=({home:renderHome,walk:renderWalk,meet:renderMeet,tasks:renderTasks,pets:renderPets})[tab]();renderNav();renderModal()}
|
||||||
|
function setTab(t){tab=t;document.getElementById('screen').scrollTop=0;render()}function setFilter(f){filter=f;render()}function setTaskDay(n){taskDay=n;render()}function toggleTask(id){let t=state.tasks.find(x=>x.id===id);if(t){t.done=!t.done;save();render();toast(t.done?'任务完成,做得好!':'已取消完成')}}function switchPet(id){state.activePet=id;save();modal='';render();toast('已切换到 '+pet().name)}
|
||||||
|
function tick(){if(state.walkStatus!=='running')return;state.walkSeconds++;state.walkDistance=+(state.walkSeconds*0.0018).toFixed(2);let tm=document.getElementById('walkTime'),ds=document.getElementById('walkDistance'),pc=document.getElementById('walkPace');if(tm)tm.textContent=timeDisplay();if(ds)ds.textContent=state.walkDistance.toFixed(2);if(pc)pc.textContent=Math.max(4,Math.round(state.walkSeconds/60/Math.max(state.walkDistance,.01)));if(state.walkSeconds%5===0)save()}
|
||||||
|
function startWalk(){state.walkStatus='running';state.walkSeconds=0;state.walkDistance=0;clearInterval(walkTicker);walkTicker=setInterval(tick,1000);save();render();toast('开始记录遛狗路线')}
|
||||||
|
function pauseWalk(){if(state.walkStatus==='running'){state.walkStatus='paused';clearInterval(walkTicker)}else{state.walkStatus='running';walkTicker=setInterval(tick,1000)}save();render()}
|
||||||
|
function finishWalk(){if(state.walkStatus==='idle')return;clearInterval(walkTicker);let mins=Math.max(1,Math.ceil(state.walkSeconds/60)),dist=state.walkDistance;state.walks++;state.totalKm=+(Number(state.totalKm)+dist).toFixed(2);state.walkStatus='idle';state.walkSeconds=0;state.walkDistance=0;state.share=false;save();render();toast(`已保存:${mins} 分钟 · ${dist.toFixed(2)} km`)}
|
||||||
|
function toggleShare(){state.share=!state.share;save();render();toast(state.share?'位置分享已开启':'位置分享已关闭')}
|
||||||
|
function joinMeet(id){let m=state.meetups.find(x=>x.id===id);if(!m)return;m.joined=!m.joined;m.people+=m.joined?1:-1;save();render();toast(m.joined?'加入成功,记得准时出发!':'已退出约遛')}
|
||||||
|
function showModal(type){modal=type;renderModal()}function closeModal(){modal='';renderModal()}
|
||||||
|
function renderModal(){const root=document.getElementById('modalRoot');if(!modal){root.innerHTML='';return}let p=pet(),title='',body='';if(modal==='switchPet'){title='选择毛孩子';body=`<div class="pet-list-card">${state.pets.map(x=>`<div class="pet-list-row ${x.id===p.id?'active':''}" onclick="switchPet(${x.id})"><div class="pet-avatar">${x.emoji}</div><div><h4>${esc(x.name)}</h4><p>${esc(x.breed)} · ${esc(x.age)}</p></div></div>`).join('')}</div><button class="add-dashed" onclick="showModal('newPet')">+ 添加宠物</button>`}else if(modal==='newPet'||modal==='editPet'){let edit=modal==='editPet';title=edit?'编辑宠物档案':'添加毛孩子';body=`<form onsubmit="savePet(event,${edit})"><label class="field"><span>名字</span><input name="name" required maxlength="20" value="${edit?esc(p.name):''}" placeholder="它叫什么?"></label><div class="form-grid"><label class="field"><span>宠物类型</span><select name="type"><option ${edit&&p.type==='狗狗'?'selected':''}>狗狗</option><option ${edit&&p.type==='猫猫'?'selected':''}>猫猫</option><option ${edit&&p.type==='其他'?'selected':''}>其他</option></select></label><label class="field"><span>性别</span><select name="gender"><option ${edit&&p.gender==='男孩'?'selected':''}>男孩</option><option ${edit&&p.gender==='女孩'?'selected':''}>女孩</option><option ${edit&&p.gender==='不确定'?'selected':''}>不确定</option></select></label></div><div class="form-grid"><label class="field"><span>品种</span><input name="breed" maxlength="25" value="${edit?esc(p.breed):''}" placeholder="如:柯基"></label><label class="field"><span>体重</span><input name="weight" maxlength="12" value="${edit?esc(p.weight):''}" placeholder="如:12 kg"></label></div><div class="form-grid"><label class="field"><span>年龄</span><input name="age" maxlength="20" value="${edit?esc(p.age):''}" placeholder="如:2岁"></label><label class="field"><span>生日</span><input name="birthday" type="date" value="${edit?esc(p.birthday):''}"></label></div><label class="field"><span>小习惯 / 注意事项</span><input name="note" maxlength="100" value="${edit?esc(p.note):''}" placeholder="记录它独特的小习惯"></label><button class="primary" type="submit">${edit?'保存档案':'添加宠物'}</button></form>`}else if(modal==='newTask'){title='添加日常任务';body=`<form onsubmit="addTask(event)"><label class="field"><span>任务名称</span><input name="name" required maxlength="30" placeholder="例如:晚餐喂食"></label><div class="form-grid"><label class="field"><span>分类</span><select name="category"><option>饮食</option><option>饮水</option><option>活动</option><option>护理</option><option>其他</option></select></label><label class="field"><span>提醒时间</span><input name="time" type="time" value="18:00" required></label></div><div class="tip-card">任务会添加到当前宠物「${esc(p.name)}」的每日清单。</div><button class="primary" type="submit">添加任务</button></form>`}else if(modal==='newMeet'){title='发起约遛';body=`<form onsubmit="addMeet(event)"><label class="field"><span>活动名称</span><input name="title" required maxlength="30" placeholder="例如:周末公园散步"></label><label class="field"><span>集合地点</span><input name="place" required maxlength="40" placeholder="例如:世纪公园 3 号门"></label><div class="form-grid"><label class="field"><span>时间</span><input name="time" required maxlength="25" placeholder="周六 09:30"></label><label class="field"><span>距你多远</span><input name="distance" required maxlength="12" placeholder="1.2 km"></label></div><label class="field"><span>活动标签</span><input name="tag" maxlength="20" placeholder="例如:小型犬友好"></label><button class="primary" type="submit">发布约遛</button></form>`}else if(modal==='notifications'){title='消息提醒';body='<div class="empty">今天还没有新消息。带毛孩子出门,或参加一场约遛吧 🐾</div>'}else if(modal==='walkHistory'){title='遛狗记录';body=`<div class="profile-grid"><div class="profile-field"><small>累计次数</small><strong>${state.walks} 次</strong></div><div class="profile-field"><small>累计里程</small><strong>${Number(state.totalKm).toFixed(1)} km</strong></div><div class="profile-field"><small>最近状态</small><strong>${state.walkStatus==='idle'?'已完成':'进行中'}</strong></div></div><p class="small-note">原型目前只展示汇总数字;正式版本可在这里查看每次路线与时长。</p>`}root.innerHTML=`<div class="modal-backdrop" onclick="if(event.target===this)closeModal()"><div class="modal"><div class="modal-handle"></div><div class="modal-head"><h3>${title}</h3><button class="close" onclick="closeModal()" aria-label="关闭">×</button></div>${body}</div></div>`}
|
||||||
|
function savePet(e,edit){e.preventDefault();let x=Object.fromEntries(new FormData(e.target));let emoji=x.type==='猫猫'?'🐈':x.type==='狗狗'?'🐕':'🐾';if(edit){Object.assign(pet(),x,{emoji})}else{let id=Date.now();state.pets.push({id,...x,emoji});state.activePet=id}save();modal='';setTab('pets');toast(edit?'宠物档案已更新':'新伙伴加入啦!')}
|
||||||
|
function addTask(e){e.preventDefault();let x=Object.fromEntries(new FormData(e.target));let emoji={饮食:'🍚',饮水:'💧',活动:'🦮',护理:'🪮',其他:'✨'}[x.category];state.tasks.push({id:Date.now(),petId:state.activePet,name:x.name,category:x.category,time:x.time,icon:emoji,done:false});save();modal='';setTab('tasks');toast('任务已添加')}
|
||||||
|
function addMeet(e){e.preventDefault();let x=Object.fromEntries(new FormData(e.target));state.meetups.unshift({id:Date.now(),title:x.title,place:x.place,time:x.time,distance:x.distance,tag:x.tag||'新活动',people:1,emoji:'🐕🌿',theme:'green',joined:true});filter='全部';save();modal='';setTab('meet');toast('约遛已发布')}
|
||||||
|
if(state.walkStatus==='running')walkTicker=setInterval(tick,1000);render();
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.detail-link{border:0;background:none;padding:0;text-align:left;color:inherit;font-weight:inherit;font-size:inherit;cursor:pointer}.detail-link:hover{color:var(--green2)}
|
||||||
|
.task-edit{border:0;background:#f3f7f2;color:#789281;border-radius:8px;width:27px;height:27px;display:grid;place-items:center;padding:0;margin-left:5px}.task-edit svg{width:15px;height:15px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.timeline-card{padding:14px 16px}.walk-record{display:flex;align-items:center;gap:13px;padding:11px 0;border-bottom:1px solid var(--line)}.walk-record:last-child{border-bottom:0}.record-badge{width:44px;height:44px;background:#eaf3e8;border-radius:14px;display:grid;place-items:center;font-size:22px;flex:none}.record-main{flex:1}.record-main strong{display:block;font-size:12px}.record-main small{display:block;color:#9ba99e;font-size:11px;margin-top:4px}.record-distance{font-size:14px;font-weight:850;color:var(--green)}
|
||||||
|
.detail-hero{border-radius:18px;background:linear-gradient(135deg,#dcebdd,#add2b1);height:130px;display:grid;place-items:center;font-size:58px;margin-bottom:16px}.detail-tags{display:flex;gap:7px;flex-wrap:wrap;margin:12px 0 16px}.detail-tags span{background:#ebf3e9;color:#5d8b6a;font-size:11px;font-weight:750;border-radius:8px;padding:7px 9px}.detail-row{display:flex;gap:12px;padding:11px 0;border-bottom:1px solid var(--line);font-size:12px}.detail-row b{min-width:62px;color:#94a49a}.detail-row span{line-height:1.5}.detail-copy{font-size:12px;color:#6e8375;line-height:1.8;margin:16px 0}.detail-attendees{background:#f5f8f3;border-radius:14px;padding:13px 14px;font-size:11px;color:#6c8272}.detail-attendees strong{display:block;color:var(--ink);font-size:12px;margin-bottom:6px}.danger-button{width:100%;border:0;background:#fff0ec;color:#bf6559;border-radius:12px;padding:12px;font-weight:800;font-size:12px;margin-top:10px}.two-actions{display:flex;gap:8px}.two-actions>*{flex:1}.section-caption{font-size:11px;color:var(--muted);margin:-5px 2px 11px}.health-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px}.health-item{background:#fff;padding:15px;border-radius:17px;box-shadow:0 3px 22px rgba(27,63,47,.035)}.health-item small{display:block;color:#97a79c;font-size:10px}.health-item strong{display:block;margin-top:6px;font-size:12px;line-height:1.45}.history-empty{background:#fff;border-radius:16px;padding:20px;color:#92a398;text-align:center;font-size:11px}
|
||||||
|
/* 所有通过 icon() 生成的图标都必须有明确尺寸,防止 SVG 默认 300×150 撑坏按钮。 */
|
||||||
|
button svg,.pet-list-row .arrow svg{width:20px;height:20px;flex:none;display:block;fill:none;stroke:currentColor;stroke-width:1.85;stroke-linecap:round;stroke-linejoin:round}
|
||||||
|
.walk-actions button,.outline-button{display:flex;align-items:center;justify-content:center;gap:7px}
|
||||||
|
.walk-actions button svg,.outline-button svg{width:18px;height:18px}
|
||||||
|
.pet-list-row .arrow{display:grid;place-items:center;flex:none;width:20px;height:20px}
|
||||||
|
.walk-party{padding:13px 0 16px;border-bottom:1px solid var(--line);margin-bottom:16px}.walk-party-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px}.walk-party-head strong{font-size:12px}.walk-party-head small{font-size:10px;color:var(--muted)}.walk-party-list{display:flex;flex-wrap:wrap;gap:7px}.walk-dog{display:flex;align-items:center;gap:5px;border:1.5px solid #e2ece4;background:#f8faf7;color:#84988a;border-radius:11px;padding:7px 10px;font-size:11px;font-weight:800}.walk-dog.selected{background:#e7f3e8;border-color:#86be9b;color:var(--green)}.walk-dog:disabled{opacity:.85;cursor:default}.walk-dog b{font-size:18px;line-height:1}.walk-dog span{font-size:11px}.walk-party-note{font-size:10px;color:#9aab9c;margin-top:8px;line-height:1.5}
|
||||||
|
.home-walk-card{display:block;padding:15px}.home-walk-intro{display:flex;align-items:center;gap:13px}.home-walk-intro .walk-illustration{width:70px;height:70px;font-size:35px}.home-walk-intro h4{font-size:15px;margin:0 0 5px}.home-walk-intro p{font-size:11px;line-height:1.5;color:var(--muted);margin:0}.home-walk-pets{display:flex;flex-wrap:wrap;gap:7px;margin:14px 0 12px}.home-walk-pets .walk-dog{padding:7px 9px}.home-walk-pets .add-dog{border-style:dashed;background:white;color:var(--green2)}.home-walk-start{width:100%;height:42px;display:flex;align-items:center;justify-content:center;gap:7px;font-size:12px}.home-walk-start svg{width:16px;height:16px}
|
||||||
|
.walk-choice-top{padding:15px 16px;margin-bottom:12px;border:2px solid #c8e0ce}.walk-choice-top .walk-party{border:0;padding:0;margin:0}.walk-choice-top .walk-party-head strong{font-size:14px}.walk-choice-top .walk-party-note{margin-top:7px}.walk-choice-top .walk-dog{min-height:38px}.walk-choice-top:before{content:"可多选";display:inline-block;background:#e8f4e9;color:#478668;border-radius:7px;padding:4px 7px;font-size:10px;font-weight:850;margin-bottom:8px}.map-wrap{height:320px}
|
||||||
|
.brand-logo{width:44px;height:44px;border-radius:14px;object-fit:cover;box-shadow:0 7px 18px #174e4230}.hero{min-height:296px}.hero:after{left:10px;right:10px;top:auto;bottom:0;width:calc(100% - 20px);height:152px;border:0;border-radius:0;background:url('pets-fe/assets/corgi-family-five.png') center bottom/contain no-repeat;z-index:0}.hero-pet{display:none}.hero-bottom{margin-top:11px}.home-walk-card .walk-illustration{background:#e1eedc url('pets-fe/assets/corgi-family-five.png') 0% top/500% auto no-repeat;font-size:0}.home-walk-card .walk-illustration:before{display:none}.home-walk-card .walk-illustration span{display:none}.corgi-crop{display:inline-block;flex:none;background:#f1dfbe url('pets-fe/assets/corgi-family-five.png') var(--corgi-x,0%) top/500% auto no-repeat}.walk-dog b .corgi-crop{width:25px;height:25px;border-radius:8px}.pet-avatar.corgi-crop{width:57px;height:57px;border-radius:18px}.profile-big.corgi-crop{width:76px;height:76px;border-radius:24px}.avatar-mini .corgi-crop{width:36px;height:36px;border-radius:50%}.avatar-mini{overflow:hidden;padding:0}.pet-picker{position:relative;z-index:2}
|
||||||
|
.cat-hero{min-height:194px}.cat-hero:after{background:none;border:1px solid #ffffff2b;border-radius:50%;width:184px;height:184px;right:-35px;left:auto;top:-55px;bottom:auto}.cat-hero .hero-pet{display:grid}
|
||||||
|
</style>
|
||||||
|
<script>
|
||||||
|
// 第二轮原型:日期化任务、活动详情、遛狗记录与更完整的宠物档案。
|
||||||
|
const localDay = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
|
||||||
|
const dayAt = offset => {const d=new Date();d.setHours(12,0,0,0);d.setDate(d.getDate()+offset);return d};
|
||||||
|
const dayKey = offset => localDay(dayAt(offset));
|
||||||
|
state.taskCompletions ||= {};
|
||||||
|
state.walkHistory ||= [];
|
||||||
|
for(const t of state.tasks){if(t.done && !state.taskCompletions[`${t.id}:${dayKey(0)}`])state.taskCompletions[`${t.id}:${dayKey(0)}`]=true;delete t.done;t.repeat ||= '每天'}
|
||||||
|
for(const p of state.pets){p.allergy ??= p.id===1?'鸡肉':'无';p.care ??= p.id===1?'出门前检查牵引绳,回来擦脚':'每周梳毛 2 次';p.vaccine ??= '待记录'}
|
||||||
|
for(const m of state.meetups){m.maxPeople ||= 8;m.description ||= '一起轻松散步,让毛孩子交新朋友。请自带牵引绳和饮水,集合后按大家的节奏出发。'}
|
||||||
|
save();
|
||||||
|
|
||||||
|
function dueTasks(offset){const weekday=dayAt(offset).getDay();return state.tasks.filter(t=>t.petId===state.activePet && (t.repeat==='每天'||!t.repeat||(t.repeat==='工作日'&&weekday>0&&weekday<6)||(t.repeat==='周末'&&(weekday===0||weekday===6)))).sort((a,b)=>a.time.localeCompare(b.time))}
|
||||||
|
function doneFor(t,offset){return !!state.taskCompletions[`${t.id}:${dayKey(offset)}`]}
|
||||||
|
function taskStats(offset){const a=dueTasks(offset);return {done:a.filter(t=>doneFor(t,offset)).length,total:a.length}}
|
||||||
|
petTasks=()=>dueTasks(tab==='tasks'?taskDay:0);
|
||||||
|
counts=()=>taskStats(0);
|
||||||
|
taskHTML=function(t){const offset=tab==='tasks'?taskDay:0,done=doneFor(t,offset);return `<div class="task-row ${done?'done':''}"><button class="check" onclick="toggleTask(${t.id})" aria-label="${done?'取消完成':'完成'}${esc(t.name)}">${done?icon('check'):''}</button><div class="task-icon">${t.icon}</div><div class="task-copy"><div class="task-name">${esc(t.name)}</div><div class="task-sub">${esc(pet().name)} · ${esc(t.category)} · ${esc(t.repeat||'每天')}</div></div><div class="task-time">${esc(t.time)}</div>${tab==='tasks'?`<button class="task-edit" onclick="showModal('editTask:${t.id}')" aria-label="编辑${esc(t.name)}">${icon('edit')}</button>`:''}</div>`};
|
||||||
|
toggleTask=function(id){const t=state.tasks.find(x=>x.id===id);if(!t)return;const offset=tab==='tasks'?taskDay:0,key=`${id}:${dayKey(offset)}`;state.taskCompletions[key]=!state.taskCompletions[key];save();render();toast(state.taskCompletions[key]?'任务完成,做得好!':'已取消完成')};
|
||||||
|
renderTasks=function(){const c=taskStats(taskDay),days=['日','一','二','三','四','五','六'],arr=Array.from({length:7},(_,i)=>dayAt(i));return `${header('细心照顾每一个小日子','日常照护计划',`<button class="icon-btn" onclick="showModal('newTask')" aria-label="添加任务">${icon('plus')}</button>`)}<div class="content"><div class="day-strip">${arr.map((d,i)=>`<button class="day ${taskDay===i?'active':''}" onclick="setTaskDay(${i})"><span>${i===0?'今天':`周${days[d.getDay()]}`}</span><strong>${d.getDate()}</strong></button>`).join('')}</div><div class="card" style="padding:17px 18px;background:#e2f0e5"><div style="display:flex;align-items:center;justify-content:space-between"><div><div style="font-size:11px;color:#71947a;font-weight:750">${esc(pet().name)} · ${taskDay===0?'今天':`${dayAt(taskDay).getMonth()+1}月${dayAt(taskDay).getDate()}日`}的照护</div><div style="font-size:24px;font-weight:850;margin-top:4px">${c.done}/${c.total} <span style="font-size:12px;color:#6a9775">项已完成</span></div></div><div style="font-size:39px">${c.done===c.total&&c.total?'🎉':'🌱'}</div></div><div class="progress-track" style="margin-top:15px;background:#cae1d0"><span style="width:${c.total?c.done/c.total*100:0}%"></span></div></div><div class="task-group-title"><strong>${taskDay?'当日计划':'今天要做的事'}</strong><span>${c.total} 项 · 点击右侧可编辑</span></div><div class="card task-card">${dueTasks(taskDay).map(taskHTML).join('')||'<div class="empty">这天没有安排任务,给毛孩子放个假吧 ☁️</div>'}</div><button class="add-dashed" onclick="showModal('newTask')">+ 添加任务</button><p class="small-note">每天、工作日、周末三种重复规则;每一天的完成状态分别保存。</p></div>`};
|
||||||
|
|
||||||
|
const firstWalkRender=renderWalk;
|
||||||
|
renderWalk=function(){const latest=state.walkHistory.filter(w=>w.petId===state.activePet).slice(0,2);return firstWalkRender()+`<div class="content">${section('最近的脚印','全部记录',"showModal('walkHistory')")}${latest.length?`<div class="card timeline-card">${latest.map(w=>`<div class="walk-record"><div class="record-badge">🐾</div><div class="record-main"><strong>${esc(w.petName)}的散步</strong><small>${esc(w.date)} · ${Math.max(1,Math.ceil(w.seconds/60))} 分钟</small></div><div class="record-distance">${Number(w.distance).toFixed(2)} km</div></div>`).join('')}</div>`:'<div class="history-empty">完成一次遛狗,脚印就会出现在这里 🐾</div>'}</div>`};
|
||||||
|
finishWalk=function(){if(state.walkStatus==='idle')return;clearInterval(walkTicker);const w={id:Date.now(),petId:state.activePet,petName:pet().name,date:new Date().toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}),seconds:state.walkSeconds,distance:state.walkDistance};state.walkHistory.unshift(w);state.walks++;state.totalKm=+(Number(state.totalKm)+w.distance).toFixed(2);state.walkStatus='idle';state.walkSeconds=0;state.walkDistance=0;state.share=false;save();render();toast(`已保存:${Math.max(1,Math.ceil(w.seconds/60))} 分钟 · ${w.distance.toFixed(2)} km`)};
|
||||||
|
toggleShare=function(){if(state.walkStatus==='idle'){toast('开始遛狗后才能分享位置');return}state.share=!state.share;save();render();toast(state.share?'位置分享已开启':'位置分享已关闭')};
|
||||||
|
|
||||||
|
renderMeet=function(){const ms=state.meetups.filter(m=>filter==='全部'||(filter==='附近'&&parseFloat(m.distance)<=2.5)||(filter==='周末'&&m.time.includes('周'))||(filter==='我参加的'&&m.joined));return `${header('和新朋友一起出门','约个伴,一起遛',`<button class="icon-btn" onclick="showModal('newMeet')" aria-label="发起约遛">${icon('plus')}</button>`)}<div class="content"><div class="filters">${['全部','附近','周末','我参加的'].map(f=>`<button class="filter ${filter===f?'active':''}" onclick="setFilter('${f}')">${f}</button>`).join('')}</div>${ms.map(m=>`<article class="card meet-card"><div class="meet-banner ${m.theme}" onclick="showModal('meetDetail:${m.id}')" style="cursor:pointer"><span class="tag">${esc(m.tag)}</span><span class="scene">${m.emoji}</span></div><h4><button class="detail-link" onclick="showModal('meetDetail:${m.id}')">${esc(m.title)} ›</button></h4><p>📍 ${esc(m.place)} · ${esc(m.distance)}</p><div class="meet-footer"><div class="participants"><span class="face">🐶</span><span class="face">🐕</span><span class="face">🐩</span><span>${esc(m.time)} · ${m.people}/${m.maxPeople} 人</span></div><button class="join-btn ${m.joined?'joined':''}" onclick="showModal('meetDetail:${m.id}')">${m.joined?'查看活动':'查看详情'}</button></div></article>`).join('')||'<div class="card empty">这里暂时没有活动,发起一场吧 🐾</div>'}<button class="add-dashed" onclick="showModal('newMeet')">+ 发起一场约遛</button><p class="small-note">活动和距离为原型演示数据;正式版本需接入地图与真实活动信息。</p></div>`};
|
||||||
|
joinMeet=function(id){const m=state.meetups.find(x=>x.id===id);if(!m)return;if(!m.joined&&m.people>=m.maxPeople){toast('这场活动已经满员');return}m.joined=!m.joined;m.people+=m.joined?1:-1;save();render();toast(m.joined?`已为${pet().name}报名,记得准时出发!`:'已退出约遛')};
|
||||||
|
|
||||||
|
renderPets=function(){const p=pet(),ownWalks=state.walkHistory.filter(w=>walkIds(w).includes(p.id));return `${header('每只毛孩子都独一无二','我的毛孩子',`<button class="icon-btn" onclick="showModal('newPet')" aria-label="添加宠物">${icon('plus')}</button>`)}<div class="content"><div class="card pet-list-card">${state.pets.map(x=>`<div class="pet-list-row ${x.id===p.id?'active':''}" onclick="switchPet(${x.id})"><div class="pet-avatar">${x.emoji}</div><div><h4>${esc(x.name)} ${x.id===p.id?'· 当前':''}</h4><p>${esc(x.breed||x.type)} · ${esc(x.age||'年龄待填写')}</p></div><div class="arrow">${icon('chev')}</div></div>`).join('')}</div>${section('基本档案')}<div class="card profile-card"><div class="profile-top"><div class="profile-big">${p.emoji}</div><div><div class="profile-name">${esc(p.name)}</div><div class="profile-desc">${esc(p.breed||p.type)} · ${esc(p.gender)}</div></div></div><div class="profile-grid"><div class="profile-field"><small>年龄</small><strong>${esc(p.age||'待填写')}</strong></div><div class="profile-field"><small>体重</small><strong>${esc(p.weight||'待填写')}</strong></div><div class="profile-field"><small>生日</small><strong>${esc(p.birthday||'待填写')}</strong></div></div><div class="profile-note">🐾 ${esc(p.note||'还没有备注,记录它的小习惯吧。')}</div><button class="outline-button" onclick="showModal('editPet')">${icon('edit')} 编辑档案</button></div>${section('健康与照护')}<div class="health-grid"><div class="health-item"><small>过敏 / 忌口</small><strong>${esc(p.allergy||'暂无记录')}</strong></div><div class="health-item"><small>疫苗情况</small><strong>${esc(p.vaccine||'暂无记录')}</strong></div></div><div class="card profile-note" style="margin-top:8px;background:#fff;color:#688170">💚 照护提醒:${esc(p.care||'暂无特别提醒')}</div>${section('一起生活')}<div class="stats"><div class="stat card"><small>已记录散步</small><strong>${ownWalks.length}<i>次</i></strong></div><div class="stat card"><small>记录里程</small><strong>${ownWalks.reduce((sum,w)=>sum+Number(w.distance),0).toFixed(2)}<i>km</i></strong></div></div><button class="add-dashed" onclick="showModal('newPet')">+ 添加另一只毛孩子</button></div>`};
|
||||||
|
|
||||||
|
const firstModalRender=renderModal;
|
||||||
|
renderModal=function(){const root=document.getElementById('modalRoot');if(!modal){root.innerHTML='';return}let title='',body='',p=pet();if(modal.startsWith('meetDetail:')){const m=state.meetups.find(x=>x.id===Number(modal.split(':')[1]));if(!m){closeModal();return}title='约遛详情';body=`<div class="detail-hero">${m.emoji}</div><h3 style="margin:0;font-size:19px">${esc(m.title)}</h3><div class="detail-tags"><span>${esc(m.tag)}</span><span>${m.people}/${m.maxPeople} 位伙伴</span><span>距你 ${esc(m.distance)}</span></div><div class="detail-row"><b>集合时间</b><span>${esc(m.time)}</span></div><div class="detail-row"><b>集合地点</b><span>${esc(m.place)}</span></div><div class="detail-row"><b>带谁参加</b><span>${esc(p.name)} · ${esc(p.breed||p.type)} <a href="#" onclick="event.preventDefault();showModal('switchPet')" style="color:var(--green2)">切换</a></span></div><p class="detail-copy">${esc(m.description)}</p><div class="detail-attendees"><strong>出发前的小提醒</strong>带好牵引绳、拾便袋和饮水;毛孩子不舒服时,请及时退出活动。</div><button class="primary" onclick="joinMeet(${m.id})">${m.joined?'退出这场约遛':m.people>=m.maxPeople?'活动已满员':'带'+esc(p.name)+'加入约遛'}</button>`}
|
||||||
|
else if(modal==='walkHistory'){title='遛狗记录';const records=state.walkHistory.filter(w=>w.petId===state.activePet);body=`<div class="profile-grid"><div class="profile-field"><small>已记录</small><strong>${records.length} 次</strong></div><div class="profile-field"><small>记录里程</small><strong>${records.reduce((sum,w)=>sum+Number(w.distance),0).toFixed(2)} km</strong></div><div class="profile-field"><small>正在进行</small><strong>${state.walkStatus==='idle'?'没有':'1 次'}</strong></div></div><div class="task-group-title"><strong>${esc(p.name)}的脚印</strong></div>${records.length?`<div class="timeline-card">${records.map(w=>`<div class="walk-record"><div class="record-badge">🐾</div><div class="record-main"><strong>${esc(w.date)}</strong><small>${Math.max(1,Math.ceil(w.seconds/60))} 分钟</small></div><div class="record-distance">${Number(w.distance).toFixed(2)} km</div></div>`).join('')}</div>`:'<div class="history-empty">还没有记录,完成第一次散步吧 🐾</div>'}`}
|
||||||
|
else if(modal==='newTask'||modal.startsWith('editTask:')){const edit=modal.startsWith('editTask:'),t=edit?state.tasks.find(x=>x.id===Number(modal.split(':')[1])):null;if(edit&&!t){closeModal();return}title=edit?'编辑日常任务':'添加日常任务';body=`<form onsubmit="saveTask(event,${edit?t.id:'null'})"><label class="field"><span>任务名称</span><input name="name" required maxlength="30" value="${edit?esc(t.name):''}" placeholder="例如:晚餐喂食"></label><div class="form-grid"><label class="field"><span>分类</span><select name="category">${['饮食','饮水','活动','护理','其他'].map(v=>`<option ${edit&&t.category===v?'selected':''}>${v}</option>`).join('')}</select></label><label class="field"><span>提醒时间</span><input name="time" type="time" value="${edit?esc(t.time):'18:00'}" required></label></div><label class="field"><span>重复规则</span><select name="repeat">${['每天','工作日','周末'].map(v=>`<option ${edit&&t.repeat===v?'selected':''}>${v}</option>`).join('')}</select></label><div class="tip-card">这项任务属于「${esc(p.name)}」。修改重复规则后,已完成的日期仍会保留。</div><button class="primary" type="submit">${edit?'保存修改':'添加任务'}</button></form>${edit?`<button class="danger-button" onclick="showModal('deleteTask:${t.id}')">删除这项任务</button>`:''}`}
|
||||||
|
else if(modal.startsWith('deleteTask:')){const id=Number(modal.split(':')[1]),t=state.tasks.find(x=>x.id===id);title='删除任务';body=`<p class="detail-copy">确定删除「${esc(t?.name||'这项任务')}」吗?它会从后续计划中移除。</p><div class="two-actions"><button class="soft-button" onclick="closeModal()">取消</button><button class="danger-button" style="margin:0" onclick="deleteTask(${id})">确认删除</button></div>`}
|
||||||
|
else if(modal==='newPet'||modal==='editPet'){const edit=modal==='editPet';title=edit?'编辑宠物档案':'添加毛孩子';body=`<form onsubmit="savePet(event,${edit})"><label class="field"><span>名字</span><input name="name" required maxlength="20" value="${edit?esc(p.name):''}" placeholder="它叫什么?"></label><div class="form-grid"><label class="field"><span>宠物类型</span><select name="type">${['狗狗','猫猫','其他'].map(v=>`<option ${edit&&p.type===v?'selected':''}>${v}</option>`).join('')}</select></label><label class="field"><span>性别</span><select name="gender">${['男孩','女孩','不确定'].map(v=>`<option ${edit&&p.gender===v?'selected':''}>${v}</option>`).join('')}</select></label></div><div class="form-grid"><label class="field"><span>品种</span><input name="breed" maxlength="25" value="${edit?esc(p.breed):''}" placeholder="如:柯基"></label><label class="field"><span>体重</span><input name="weight" maxlength="12" value="${edit?esc(p.weight):''}" placeholder="如:12 kg"></label></div><div class="form-grid"><label class="field"><span>年龄</span><input name="age" maxlength="20" value="${edit?esc(p.age):''}" placeholder="如:2岁"></label><label class="field"><span>生日</span><input name="birthday" type="date" value="${edit?esc(p.birthday):''}"></label></div><label class="field"><span>性格和习惯</span><input name="note" maxlength="100" value="${edit?esc(p.note):''}" placeholder="它喜欢什么?"></label><div class="form-grid"><label class="field"><span>过敏 / 忌口</span><input name="allergy" maxlength="50" value="${edit?esc(p.allergy):''}" placeholder="如:鸡肉"></label><label class="field"><span>疫苗情况</span><input name="vaccine" maxlength="50" value="${edit?esc(p.vaccine):''}" placeholder="如:已接种"></label></div><label class="field"><span>照护提醒</span><input name="care" maxlength="100" value="${edit?esc(p.care):''}" placeholder="如:每天擦脚"></label><button class="primary" type="submit">${edit?'保存档案':'添加宠物'}</button></form>`}
|
||||||
|
else if(modal==='newMeet'){title='发起约遛';body=`<form onsubmit="addMeet(event)"><label class="field"><span>活动名称</span><input name="title" required maxlength="30" placeholder="例如:周末公园散步"></label><label class="field"><span>集合地点</span><input name="place" required maxlength="40" placeholder="例如:世纪公园 3 号门"></label><div class="form-grid"><label class="field"><span>集合时间</span><input name="time" required maxlength="25" placeholder="周六 09:30"></label><label class="field"><span>距离展示</span><input name="distance" required maxlength="12" placeholder="1.2 km"></label></div><div class="form-grid"><label class="field"><span>人数上限</span><input name="maxPeople" type="number" min="2" max="30" value="8" required></label><label class="field"><span>活动标签</span><input name="tag" maxlength="20" placeholder="小型犬友好"></label></div><label class="field"><span>活动说明</span><input name="description" maxlength="120" placeholder="路线、节奏和需要准备的东西"></label><button class="primary" type="submit">发布约遛</button></form><p class="small-note">原型中的活动仅保存在本地浏览器,不会发布给其他人。</p>`}
|
||||||
|
else {firstModalRender();return}
|
||||||
|
root.innerHTML=`<div class="modal-backdrop" onclick="if(event.target===this)closeModal()"><div class="modal"><div class="modal-handle"></div><div class="modal-head"><h3>${title}</h3><button class="close" onclick="closeModal()" aria-label="关闭">×</button></div>${body}</div></div>`};
|
||||||
|
function saveTask(e,id){e.preventDefault();const x=Object.fromEntries(new FormData(e.target)),emojis={饮食:'🍚',饮水:'💧',活动:'🦮',护理:'🪮',其他:'✨'};if(id){Object.assign(state.tasks.find(t=>t.id===id),x,{icon:emojis[x.category]})}else{state.tasks.push({id:Date.now(),petId:state.activePet,...x,icon:emojis[x.category]})}save();modal='';setTab('tasks');toast(id?'任务已更新':'任务已添加')}
|
||||||
|
function deleteTask(id){state.tasks=state.tasks.filter(t=>t.id!==id);for(const key of Object.keys(state.taskCompletions))if(key.startsWith(`${id}:`))delete state.taskCompletions[key];save();modal='';render();toast('任务已删除')}
|
||||||
|
addMeet=function(e){e.preventDefault();const x=Object.fromEntries(new FormData(e.target));const m={id:Date.now(),title:x.title,place:x.place,time:x.time,distance:x.distance,tag:x.tag||'新活动',description:x.description||'轻松散步,一起交朋友。',maxPeople:Number(x.maxPeople),people:1,emoji:'🐕🌿',theme:'green',joined:true};state.meetups.unshift(m);filter='全部';save();modal=`meetDetail:${m.id}`;setTab('meet');toast('约遛已创建,仅供本地预览')};
|
||||||
|
// 一次出行可带多只狗;当前查看的宠物档案不决定遛狗参与者。
|
||||||
|
state.walkPets=Array.isArray(state.walkPets)?state.walkPets:state.pets.filter(p=>p.type==='狗狗').map(p=>p.id);
|
||||||
|
const allDogs=()=>state.pets.filter(p=>p.type==='狗狗');
|
||||||
|
const walkSelectionIds=()=>state.walkStatus==='idle'?state.walkPets:(state.walkActivePetIds||state.walkPets);
|
||||||
|
const selectedDogs=()=>walkSelectionIds().map(id=>state.pets.find(p=>p.id===id)).filter(Boolean);
|
||||||
|
const walkIds=w=>Array.isArray(w.petIds)?w.petIds:(w.petId==null?[]:[w.petId]);
|
||||||
|
const walkNames=w=>Array.isArray(w.petNames)?w.petNames.join('、'):(w.petName||walkIds(w).map(id=>state.pets.find(p=>p.id===id)?.name||'毛孩子').join('、'));
|
||||||
|
function toggleWalkDog(id){if(state.walkStatus!=='idle')return;if(!allDogs().some(p=>p.id===id))return;state.walkPets=state.walkPets.includes(id)?state.walkPets.filter(x=>x!==id):[...state.walkPets,id];save();render()}
|
||||||
|
renderWalk=function(){const running=state.walkStatus==='running',active=state.walkStatus!=='idle',dogs=allDogs(),names=selectedDogs().map(p=>p.name).join('、'),latest=state.walkHistory.slice(0,2);return `${header('和毛孩子一起探索城市','一起去遛狗',`<button class="icon-btn" onclick="showModal('walkHistory')" aria-label="遛狗记录">${icon('walk')}</button>`)}<div class="map-wrap">${mapSVG}<div class="map-overlay"><div class="map-badge"><i></i>${running?'正在记录路线':active?'路线已暂停':'附近适合散步'}</div><button class="map-control" onclick="toast('已定位到附近公园(原型示意地图)')" aria-label="定位">⌖</button></div></div><div class="card walk-panel"><h3>${running?`和${esc(names)}在路上!`:active?'休息一下,等你们回来':'一起出门走走吧'}</h3><p class="subtitle">${active?'这一段路,属于所有同行的狗狗':'出发前选好这次同行的狗狗,可以多选'}</p><div class="walk-party"><div class="walk-party-head"><strong>这次带谁?</strong><small>已选 ${selectedDogs().length}/${dogs.length} 只狗狗</small></div><div class="walk-party-list">${dogs.map(d=>`<button class="walk-dog ${walkSelectionIds().includes(d.id)?'selected':''}" onclick="toggleWalkDog(${d.id})" ${active?'disabled':''} aria-pressed="${walkSelectionIds().includes(d.id)}"><b>${d.emoji}</b>${esc(d.name)}<span>${walkSelectionIds().includes(d.id)?'✓':'+'}</span></button>`).join('')||'<div class="walk-party-note">先到宠物档案添加一只狗狗吧。</div>'}</div><div class="walk-party-note">${active?'散步中已锁定同行成员,结束后可以重新选择':'一次散步只记一条路线,记录会关联所有同行狗狗。'}</div></div><div class="walk-numbers"><div><strong id="walkTime">${timeDisplay()}</strong><small>时长</small></div><div><strong id="walkDistance">${state.walkDistance.toFixed(2)}</strong><small>公里</small></div><div><strong id="walkPace">${state.walkDistance?Math.max(4,Math.round(state.walkSeconds/60/state.walkDistance)):'--'}</strong><small>分钟 / 公里</small></div></div><div class="walk-actions">${active?`<button class="soft-button" onclick="pauseWalk()">${icon(running?'pause':'play')} ${running?'暂停':'继续'}</button><button class="primary" onclick="finishWalk()">结束并保存</button>`:`<button class="primary" onclick="startWalk()">${icon('play')} 带${selectedDogs().length||''}只狗狗出发</button>`}</div><div class="share-line"><div>分享实时位置<small>${state.share?'已开启 · 结束后自动关闭':'仅在遛狗时可主动开启'}</small></div><button class="switch ${state.share?'on':''}" onclick="toggleShare()" aria-label="切换位置分享" aria-pressed="${state.share}"></button></div><div class="tip-card">${state.share?'🟢 位置分享已开启,结束遛狗后会自动停止。':'🔒 原型使用示意地图,不读取真实位置。'}</div></div><div class="content">${section('最近的脚印','全部记录',"showModal('walkHistory')")}${latest.length?`<div class="card timeline-card">${latest.map(w=>`<div class="walk-record"><div class="record-badge">🐾</div><div class="record-main"><strong>${esc(walkNames(w))}一起散步</strong><small>${esc(w.date)} · ${Math.max(1,Math.ceil(w.seconds/60))} 分钟</small></div><div class="record-distance">${Number(w.distance).toFixed(2)} km</div></div>`).join('')}</div>`:'<div class="history-empty">完成一次遛狗,脚印就会出现在这里 🐾</div>'}${section('附近好去处')}<div class="card meet-mini" onclick="setTab('meet')" style="cursor:pointer"><div class="meet-photo">🌳</div><div><div class="meet-title">世纪公园 · 中心草坪</div><div class="meet-meta">开阔草地 · 适合小型犬<br>步行约 12 分钟</div><span class="pill">发现附近约遛活动 →</span></div></div></div>`};
|
||||||
|
startWalk=function(){if(!selectedDogs().length){toast('请先选择至少一只狗狗');return}state.walkActivePetIds=[...state.walkPets];state.walkStatus='running';state.walkSeconds=0;state.walkDistance=0;state.share=false;clearInterval(walkTicker);walkTicker=setInterval(tick,1000);save();render();toast(`${selectedDogs().map(p=>p.name).join('、')}出发啦!`)};
|
||||||
|
finishWalk=function(){if(state.walkStatus==='idle')return;clearInterval(walkTicker);const dogs=selectedDogs(),w={id:Date.now(),petIds:dogs.map(p=>p.id),petNames:dogs.map(p=>p.name),date:new Date().toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}),seconds:state.walkSeconds,distance:state.walkDistance};state.walkHistory.unshift(w);state.walks++;state.totalKm=+(Number(state.totalKm)+w.distance).toFixed(2);state.walkStatus='idle';state.walkActivePetIds=[];state.walkSeconds=0;state.walkDistance=0;state.share=false;save();render();toast(`已给 ${dogs.length} 只狗狗保存同一次散步`)};
|
||||||
|
const modalWithDetails=renderModal;
|
||||||
|
renderModal=function(){if(modal!=='walkHistory'){modalWithDetails();return}const records=state.walkHistory;const body=`<div class="profile-grid"><div class="profile-field"><small>已记录</small><strong>${records.length} 次</strong></div><div class="profile-field"><small>记录里程</small><strong>${records.reduce((sum,w)=>sum+Number(w.distance),0).toFixed(2)} km</strong></div><div class="profile-field"><small>正在进行</small><strong>${state.walkStatus==='idle'?'没有':'1 次'}</strong></div></div><div class="task-group-title"><strong>一起走过的路</strong></div>${records.length?`<div class="timeline-card">${records.map(w=>`<div class="walk-record"><div class="record-badge">🐾</div><div class="record-main"><strong>${esc(walkNames(w))}</strong><small>${esc(w.date)} · ${Math.max(1,Math.ceil(w.seconds/60))} 分钟</small></div><div class="record-distance">${Number(w.distance).toFixed(2)} km</div></div>`).join('')}</div>`:'<div class="history-empty">还没有记录,带狗狗们出门走走吧 🐾</div>'}`;document.getElementById('modalRoot').innerHTML=`<div class="modal-backdrop" onclick="if(event.target===this)closeModal()"><div class="modal"><div class="modal-handle"></div><div class="modal-head"><h3>遛狗记录</h3><button class="close" onclick="closeModal()" aria-label="关闭">×</button></div>${body}</div></div>`};
|
||||||
|
const savePetBeforeWalkSelection=savePet;
|
||||||
|
savePet=function(e,edit){const beforeType=edit?pet().type:null;savePetBeforeWalkSelection(e,edit);if(pet().type==='狗狗'&&(!edit||beforeType!=='狗狗')&&!state.walkPets.includes(pet().id))state.walkPets.push(pet().id);if(state.walkStatus==='idle')state.walkPets=state.walkPets.filter(id=>allDogs().some(p=>p.id===id));save();render()};
|
||||||
|
const homeBeforeWalkChoice=renderHome;
|
||||||
|
renderHome=function(){const p=pet(),dogs=allDogs(),selected=selectedDogs();let html=homeBeforeWalkChoice();html=html.replace('带它出去走走','带狗狗们出去走走');if(p.type!=='狗狗')html=html.replace(`<h2>${esc(p.name)},出发吧!</h2>`,`<h2>${esc(p.name)},今天也开心!</h2>`);const start=html.indexOf('<div class="card walk-card">'),end=html.indexOf('<div class="stats">',start);if(start<0||end<0)return html;const card=`<div class="card home-walk-card"><div class="home-walk-intro"><div class="walk-illustration"><span>🦮</span></div><div><h4>这次带谁一起遛?</h4><p>可以同时选多只狗,一次出门只记一条路线。</p></div></div><div class="home-walk-pets">${dogs.map(d=>`<button class="walk-dog ${state.walkPets.includes(d.id)?'selected':''}" onclick="toggleWalkDog(${d.id})" aria-pressed="${state.walkPets.includes(d.id)}"><b>${d.emoji}</b>${esc(d.name)}<span>${state.walkPets.includes(d.id)?'✓':'+'}</span></button>`).join('')}<button class="walk-dog add-dog" onclick="showModal('newPet')">+ 添加狗狗</button></div><button class="primary home-walk-start" onclick="beginWalkFromHome()">${icon('walk')} 带 ${selected.length} 只狗狗出发</button></div>`;return html.slice(0,start)+card+html.slice(end)};
|
||||||
|
function beginWalkFromHome(){if(state.walkStatus!=='idle'){setTab('walk');return}if(!selectedDogs().length){toast('请先选一只狗狗,或添加新狗狗');return}setTab('walk');startWalk()}
|
||||||
|
const savePetBeforeHomeReturn=savePet;
|
||||||
|
savePet=function(e,edit){const fromHome=tab==='home';savePetBeforeHomeReturn(e,edit);if(fromHome&&!edit)setTab('home')};
|
||||||
|
const walkWithLowerChoice=renderWalk;
|
||||||
|
renderWalk=function(){let html=walkWithLowerChoice(),start=html.indexOf('<div class="walk-party">'),end=html.indexOf('<div class="walk-numbers">',start);if(start<0||end<0)return html;const chooser=html.slice(start,end);html=html.slice(0,start)+html.slice(end);return html.replace('<div class="map-wrap">',`<div class="content"><div class="card walk-choice-top">${chooser}</div></div><div class="map-wrap">`)};
|
||||||
|
const homeWithWalkChoice=renderHome;
|
||||||
|
renderHome=function(){let html=homeWithWalkChoice();if(state.walkStatus!=='idle')html=html.replace(`带 ${selectedDogs().length} 只狗狗出发`,'查看正在进行的遛狗');return html};
|
||||||
|
// 同一张五柯基插画按位置裁出头像,保持首页、选择器和档案形象一致。
|
||||||
|
function corgiIndex(p){const dogs=state.pets.filter(x=>x.type==='狗狗');return Math.max(0,dogs.findIndex(x=>x.id===p.id))%5}
|
||||||
|
function corgiThumb(p,extra=''){return `<span class="corgi-crop ${extra}" style="--corgi-x:${corgiIndex(p)*25}%" aria-label="${esc(p.name)}的柯基头像"></span>`}
|
||||||
|
function decorateWalkChips(html){return html.replace(/(<button class="walk-dog[^\"]*" onclick="toggleWalkDog\((\d+)\)"[^>]*><b>)[^<]*(<\/b>)/g,(all,left,id,right)=>{const dog=state.pets.find(p=>p.id===Number(id));return dog?left+corgiThumb(dog)+right:all})}
|
||||||
|
const homeBeforeCorgis=renderHome;
|
||||||
|
renderHome=function(){let html=decorateWalkChips(homeBeforeCorgis());if(pet().type==='狗狗')html=html.replace(/(<button class="avatar-mini"[^>]*>)[^<]*(<\/button>)/,(all,left,right)=>left+corgiThumb(pet())+right);else html=html.replace('class="hero card"','class="hero card cat-hero"');return html};
|
||||||
|
const walkBeforeCorgis=renderWalk;
|
||||||
|
renderWalk=function(){return decorateWalkChips(walkBeforeCorgis())};
|
||||||
|
const petsBeforeCorgis=renderPets;
|
||||||
|
renderPets=function(){let index=0,html=petsBeforeCorgis();html=html.replace(/<div class="pet-avatar">[^<]*<\/div>/g,all=>{const p=state.pets[index++];return p?.type==='狗狗'?`<div class="pet-avatar corgi-crop" style="--corgi-x:${corgiIndex(p)*25}%" aria-label="${esc(p.name)}的柯基头像"></div>`:all});if(pet().type==='狗狗')html=html.replace(/<div class="profile-big">[^<]*<\/div>/,`<div class="profile-big corgi-crop" style="--corgi-x:${corgiIndex(pet())*25}%" aria-label="${esc(pet().name)}的柯基头像"></div>`);return html};
|
||||||
|
const modalBeforeCorgis=renderModal;
|
||||||
|
renderModal=function(){modalBeforeCorgis();if(modal!=='switchPet')return;let index=0,root=document.getElementById('modalRoot');root.innerHTML=root.innerHTML.replace(/<div class="pet-avatar">[^<]*<\/div>/g,all=>{const p=state.pets[index++];return p?.type==='狗狗'?`<div class="pet-avatar corgi-crop" style="--corgi-x:${corgiIndex(p)*25}%"></div>`:all})};
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-9
@@ -1,15 +1,8 @@
|
|||||||
.PHONY: env-up env-down admin-install admin-build admin-dev build run tidy
|
.PHONY: 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:
|
admin-install:
|
||||||
cd web/admin && npm install
|
cd web/admin && npm ci
|
||||||
|
|
||||||
admin-build:
|
admin-build:
|
||||||
cd web/admin && npm run build
|
cd web/admin && npm run build
|
||||||
|
|||||||
+26
-55
@@ -1,64 +1,35 @@
|
|||||||
# 毛孩子计划 · 后端 (pets-be)
|
# 肉垫计划
|
||||||
|
|
||||||
Gin + GORM + MySQL + MinIO 的宠物小程序后端,内嵌 React(Vite + Tailwind + shadcn/ui) 后台管理。
|
核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/admin/`。
|
||||||
|
|
||||||
## 技术栈与约定
|
|
||||||
- 模块路径 `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` 后台前端源码
|
|
||||||
|
|
||||||
## 接口
|
- `cmd/server`:服务入口
|
||||||
- 小程序 `/api/*`(用户 JWT):auth / user / pets / onboarding / records / tasks / plan / reminders / report / bill / community / articles / pro / ai / upload
|
- `internal/config`:配置
|
||||||
- 后台 `/api/admin/*`(管理员 JWT):login / stats / users / pets / posts / comments / articles / memberships
|
- `internal/model`:数据库模型
|
||||||
|
- `internal/database`:PostgreSQL 连接和迁移
|
||||||
|
- `internal/storage`:MinIO 文件存储
|
||||||
|
- `internal/httpapi`:用户与管理端 API
|
||||||
|
- `web/admin`:管理端源码
|
||||||
|
- `internal/admin/dist`:嵌入的管理端构建产物
|
||||||
|
|
||||||
## 登录鉴权
|
## 本地启动
|
||||||
默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。
|
|
||||||
接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。
|
|
||||||
|
|
||||||
### access token + refresh token
|
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。配置文件不进入 Git。
|
||||||
登录接口返回 `token`(access) 与 `refresh_token` 两个令牌:
|
2. `cd web/admin && npm ci && npm run build`。
|
||||||
|
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
|
||||||
|
4. 执行 `go run ./cmd/server`。
|
||||||
|
|
||||||
- **access token**:JWT,默认 **2 小时**(`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。
|
默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/admin/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。
|
||||||
- **refresh token**:随机串,落库 `sundynix_refresh_tokens`(**只存 sha256**)。小程序 30 天、后台 7 天。
|
|
||||||
- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。
|
|
||||||
- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。
|
|
||||||
|
|
||||||
安全约定:
|
配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。
|
||||||
- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。
|
|
||||||
- 主动退出、被连坐吊销的令牌不享受宽限期。
|
|
||||||
- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。
|
|
||||||
|
|
||||||
前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。
|
## 数据
|
||||||
|
|
||||||
|
GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例如 `sundynix_user`。主键均为 PostgreSQL `uuid`,Go 字段为 `string`。文件内容存 MinIO;`sundynix_file` 存 bucket、object_key、md5、url 和元信息。头像与约遛封面只通过 `*_file_id` 关联文件表。上传会先按 MD5 查重。
|
||||||
|
|
||||||
|
现有 MySQL 数据未自动迁移。新的 PostgreSQL 表在服务启动时自动创建。正式切换旧数据前,应单独设计数据迁移映射并备份。
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var databaseName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
||||||
|
|
||||||
|
// dbinit creates the configured PostgreSQL database once. It does not change other databases.
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
name := cfg.Postgres.Database
|
||||||
|
if !databaseName.MatchString(name) {
|
||||||
|
log.Fatal("invalid database name")
|
||||||
|
}
|
||||||
|
maintenance := cfg.Postgres
|
||||||
|
maintenance.Database = "postgres"
|
||||||
|
db, err := gorm.Open(postgres.Open(maintenance.DSN()), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Raw("SELECT count(*) FROM pg_database WHERE datname = ?", name).Scan(&count).Error; err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
log.Printf("database %s already exists", name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", name)).Error; err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Printf("created database %s", name)
|
||||||
|
}
|
||||||
+18
-34
@@ -2,56 +2,40 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/pets-be/internal/admin"
|
||||||
"github.com/sundynix/pets-be/internal/ai"
|
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
"github.com/sundynix/pets-be/internal/config"
|
||||||
"github.com/sundynix/pets-be/internal/database"
|
"github.com/sundynix/pets-be/internal/database"
|
||||||
"github.com/sundynix/pets-be/internal/handler"
|
"github.com/sundynix/pets-be/internal/httpapi"
|
||||||
"github.com/sundynix/pets-be/internal/router"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/internal/storage"
|
"github.com/sundynix/pets-be/internal/storage"
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("load config: %v", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
db, err := database.Open(cfg.Postgres, cfg.Server.Mode == "release")
|
||||||
db, err := database.New(cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("connect mysql: %v", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.Migrate(db); err != nil {
|
if err := database.Migrate(db); err != nil {
|
||||||
log.Fatalf("migrate: %v", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := database.Seed(db, cfg); err != nil {
|
|
||||||
log.Fatalf("seed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
st, err := storage.New(cfg.MinIO)
|
st, err := storage.New(cfg.MinIO)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("connect minio: %v", err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
api := httpapi.New(db, st, cfg)
|
||||||
engine := ai.New(cfg.AI)
|
if err := api.SeedAdmin(); err != nil {
|
||||||
log.Printf("AI provider: %s", engine.ProviderName())
|
log.Fatal(err)
|
||||||
|
|
||||||
svc := service.New(db, st, cfg, engine)
|
|
||||||
if err := svc.SeedCommunityBot(); err != nil {
|
|
||||||
log.Fatalf("seed community bot: %v", err)
|
|
||||||
}
|
}
|
||||||
svc.StartCommunityBot()
|
gin.SetMode(cfg.Server.Mode)
|
||||||
jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
r := gin.New()
|
||||||
h := handler.New(svc, jm, cfg)
|
api.Register(r)
|
||||||
|
admin.Register(r)
|
||||||
r := router.New(h, jm, cfg.Server.Mode)
|
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||||
log.Printf("pets-be listening on %s (admin: http://localhost%s/admin)", addr, addr)
|
log.Printf("肉垫计划 listening on %s", addr)
|
||||||
if err := r.Run(addr); err != nil {
|
log.Fatal(r.Run(addr))
|
||||||
log.Fatalf("server run: %v", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,33 @@
|
|||||||
server:
|
server:
|
||||||
port: 9090
|
port: 9090
|
||||||
mode: debug # debug | release
|
mode: release
|
||||||
|
|
||||||
mysql:
|
postgres:
|
||||||
host: 127.0.0.1
|
host: 192.168.100.127
|
||||||
port: 3306
|
port: 5432
|
||||||
user: root
|
user: root
|
||||||
password: root
|
password: root
|
||||||
database: pets
|
database: sundynix-pets
|
||||||
charset: utf8mb4
|
ssl_mode: disable
|
||||||
|
|
||||||
minio:
|
minio:
|
||||||
endpoint: 127.0.0.1:9000
|
endpoint: https://s3.sundynix.cn
|
||||||
access_key: sundynix
|
access_key: sundynix
|
||||||
secret_key: sundynix
|
secret_key: sundynix
|
||||||
bucket: pets
|
bucket: sundynix-pet
|
||||||
use_ssl: false
|
use_ssl: true
|
||||||
public_base_url: http://127.0.0.1:9000 # 拼接可访问的对象 URL
|
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
secret: change-me-to-a-long-random-string # 生产用 PETS_JWT_SECRET 覆盖
|
secret: "" # 用 PETS_JWT_SECRET 注入
|
||||||
|
expire_hours: 168
|
||||||
|
|
||||||
wechat:
|
wechat:
|
||||||
app_id: your-wechat-app-id
|
app_id: ""
|
||||||
app_secret: your-wechat-app-secret # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
|
app_secret: ""
|
||||||
|
|
||||||
auth:
|
auth:
|
||||||
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录
|
dev_login: false
|
||||||
|
|
||||||
admin:
|
admin:
|
||||||
username: sundynix
|
username: sundynix
|
||||||
password: change-me # 启动时若无同名管理员则 seed 此账号
|
password: sundynix
|
||||||
|
|
||||||
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,32 @@
|
|||||||
|
server:
|
||||||
|
port: 9090
|
||||||
|
mode: debug # debug | release
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
host: 192.168.100.127
|
||||||
|
port: 5432
|
||||||
|
user: root
|
||||||
|
password: root
|
||||||
|
database: sundynix-pets
|
||||||
|
ssl_mode: disable
|
||||||
|
|
||||||
|
minio:
|
||||||
|
endpoint: https://s3.sundynix.cn
|
||||||
|
access_key: sundynix
|
||||||
|
secret_key: sundynix
|
||||||
|
bucket: sundynix-pet
|
||||||
|
use_ssl: true
|
||||||
|
|
||||||
|
jwt:
|
||||||
|
secret: kFdKDNCXpyJO2W+htdxobhHNzUIYuwcaZV94aJbIGHQ= # 生产用 PETS_JWT_SECRET 覆盖
|
||||||
|
|
||||||
|
wechat:
|
||||||
|
app_id: wx52dfc635739a9c19
|
||||||
|
app_secret: b3c78abf7bcdd5b4e3c444f9e6caad3f # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
|
||||||
|
|
||||||
|
auth:
|
||||||
|
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录
|
||||||
|
|
||||||
|
admin:
|
||||||
|
username: sundynix
|
||||||
|
password: sundynix
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
services:
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: pets-mysql
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: root
|
|
||||||
MYSQL_DATABASE: pets
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
command:
|
|
||||||
- --character-set-server=utf8mb4
|
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
|
||||||
- --default-authentication-plugin=mysql_native_password
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
|
|
||||||
minio:
|
|
||||||
image: minio/minio:latest
|
|
||||||
container_name: pets-minio
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MINIO_ROOT_USER: sundynix
|
|
||||||
MINIO_ROOT_PASSWORD: sundynix
|
|
||||||
command: server /data --console-address ":9001"
|
|
||||||
ports:
|
|
||||||
- "9000:9000"
|
|
||||||
- "9001:9001"
|
|
||||||
volumes:
|
|
||||||
- minio_data:/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mc", "ready", "local"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 20
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mysql_data:
|
|
||||||
minio_data:
|
|
||||||
+8
-6
@@ -1,20 +1,19 @@
|
|||||||
module github.com/sundynix/pets-be
|
module github.com/sundynix/pets-be
|
||||||
|
|
||||||
go 1.26.4
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/minio/minio-go/v7 v7.2.1
|
github.com/minio/minio-go/v7 v7.2.1
|
||||||
github.com/spf13/viper v1.21.0
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.53.0
|
golang.org/x/crypto v0.53.0
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
@@ -27,11 +26,13 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-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/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@@ -63,6 +64,7 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.38.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
+9
-24
@@ -1,5 +1,3 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@@ -33,8 +31,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/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 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
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 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
@@ -43,10 +39,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
|||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -54,12 +46,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -83,8 +75,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-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 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
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 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
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 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||||
@@ -127,6 +117,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.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.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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
@@ -174,15 +165,9 @@ gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
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 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
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 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -5,8 +5,8 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>admin</title>
|
<title>admin</title>
|
||||||
<script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
|
<script type="module" crossorigin src="/admin/assets/index-DmUsbLpt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
|
<link rel="stylesheet" crossorigin href="/admin/assets/index-BQA5NViF.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
// Package ai 提供 provider 中立的大模型调用能力。
|
|
||||||
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
|
|
||||||
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
|
|
||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Message 一条对话消息
|
|
||||||
type Message struct {
|
|
||||||
Role string // system / user / assistant
|
|
||||||
Content string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options 单次调用参数
|
|
||||||
type Options struct {
|
|
||||||
JSON bool // 要求返回 JSON(结构化输出)
|
|
||||||
MaxTokens int // 0 用配置默认
|
|
||||||
Temperature float64 // <0 用配置默认
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provider 大模型供应商接口
|
|
||||||
type Provider interface {
|
|
||||||
Name() string
|
|
||||||
// Complete 传入 system 提示与多轮消息,返回助手回复文本
|
|
||||||
Complete(system string, messages []Message, opts Options) (string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Engine 对外统一入口,持有当前 provider 与开关
|
|
||||||
type Engine struct {
|
|
||||||
provider Provider
|
|
||||||
cfg config.AIConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
|
|
||||||
func New(cfg config.AIConfig) *Engine {
|
|
||||||
e := &Engine{cfg: cfg}
|
|
||||||
if !cfg.Enabled || cfg.APIKey == "" {
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
switch cfg.Provider {
|
|
||||||
case "", "openai":
|
|
||||||
e.provider = newOpenAICompatProvider(cfg)
|
|
||||||
default:
|
|
||||||
// 未知 provider 视为未启用,回退规则化
|
|
||||||
}
|
|
||||||
return e
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled 是否可用真实模型
|
|
||||||
func (e *Engine) Enabled() bool {
|
|
||||||
return e.provider != nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ProviderName 当前供应商名(诊断用)
|
|
||||||
func (e *Engine) ProviderName() string {
|
|
||||||
if e.provider == nil {
|
|
||||||
return "disabled"
|
|
||||||
}
|
|
||||||
return e.provider.Name()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Complete 代理到当前 provider(调用前请先判断 Enabled)
|
|
||||||
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
|
|
||||||
if opts.MaxTokens == 0 {
|
|
||||||
opts.MaxTokens = e.cfg.MaxTokens
|
|
||||||
}
|
|
||||||
if opts.Temperature < 0 {
|
|
||||||
opts.Temperature = e.cfg.Temperature
|
|
||||||
}
|
|
||||||
return e.provider.Complete(system, messages, opts)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
package ai
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
|
|
||||||
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
|
|
||||||
type openaiCompatProvider struct {
|
|
||||||
baseURL string
|
|
||||||
apiKey string
|
|
||||||
model string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
|
|
||||||
timeout := time.Duration(cfg.TimeoutSec) * time.Second
|
|
||||||
if timeout <= 0 {
|
|
||||||
timeout = 30 * time.Second
|
|
||||||
}
|
|
||||||
return &openaiCompatProvider{
|
|
||||||
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
|
|
||||||
apiKey: cfg.APIKey,
|
|
||||||
model: cfg.Model,
|
|
||||||
client: &http.Client{Timeout: timeout},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
|
|
||||||
|
|
||||||
type chatReq struct {
|
|
||||||
Model string `json:"model"`
|
|
||||||
Messages []chatMsg `json:"messages"`
|
|
||||||
Temperature float64 `json:"temperature,omitempty"`
|
|
||||||
MaxTokens int `json:"max_tokens,omitempty"`
|
|
||||||
ResponseFormat *respFormat `json:"response_format,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatMsg struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type respFormat struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type chatResp struct {
|
|
||||||
Choices []struct {
|
|
||||||
Message struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
} `json:"message"`
|
|
||||||
} `json:"choices"`
|
|
||||||
Error *struct {
|
|
||||||
Message string `json:"message"`
|
|
||||||
} `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
|
|
||||||
msgs := make([]chatMsg, 0, len(messages)+1)
|
|
||||||
if system != "" {
|
|
||||||
msgs = append(msgs, chatMsg{Role: "system", Content: system})
|
|
||||||
}
|
|
||||||
for _, m := range messages {
|
|
||||||
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
|
|
||||||
}
|
|
||||||
|
|
||||||
body := chatReq{
|
|
||||||
Model: p.model,
|
|
||||||
Messages: msgs,
|
|
||||||
Temperature: opts.Temperature,
|
|
||||||
MaxTokens: opts.MaxTokens,
|
|
||||||
}
|
|
||||||
if opts.JSON {
|
|
||||||
body.ResponseFormat = &respFormat{Type: "json_object"}
|
|
||||||
}
|
|
||||||
raw, _ := json.Marshal(body)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
|
||||||
|
|
||||||
resp, err := p.client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
data, _ := io.ReadAll(resp.Body)
|
|
||||||
var out chatResp
|
|
||||||
if err := json.Unmarshal(data, &out); err != nil {
|
|
||||||
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
|
|
||||||
}
|
|
||||||
if out.Error != nil {
|
|
||||||
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
|
|
||||||
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncate(s string, n int) string {
|
|
||||||
if len(s) <= n {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:n]
|
|
||||||
}
|
|
||||||
@@ -9,28 +9,14 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config 全局配置
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server ServerConfig `mapstructure:"server"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
MySQL MySQLConfig `mapstructure:"mysql"`
|
Postgres PostgresConfig `mapstructure:"postgres"`
|
||||||
MinIO MinIOConfig `mapstructure:"minio"`
|
MinIO MinIOConfig `mapstructure:"minio"`
|
||||||
JWT JWTConfig `mapstructure:"jwt"`
|
JWT JWTConfig `mapstructure:"jwt"`
|
||||||
WeChat WeChatConfig `mapstructure:"wechat"`
|
WeChat WeChatConfig `mapstructure:"wechat"`
|
||||||
Auth AuthConfig `mapstructure:"auth"`
|
Auth AuthConfig `mapstructure:"auth"`
|
||||||
Admin AdminConfig `mapstructure:"admin"`
|
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 {
|
type ServerConfig struct {
|
||||||
@@ -38,47 +24,36 @@ type ServerConfig struct {
|
|||||||
Mode string `mapstructure:"mode"`
|
Mode string `mapstructure:"mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MySQLConfig struct {
|
type PostgresConfig struct {
|
||||||
Host string `mapstructure:"host"`
|
Host string `mapstructure:"host"`
|
||||||
Port int `mapstructure:"port"`
|
Port int `mapstructure:"port"`
|
||||||
User string `mapstructure:"user"`
|
User string `mapstructure:"user"`
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
Database string `mapstructure:"database"`
|
Database string `mapstructure:"database"`
|
||||||
Charset string `mapstructure:"charset"`
|
SSLMode string `mapstructure:"ssl_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DSN 返回 GORM MySQL 连接串
|
func (p PostgresConfig) DSN() string {
|
||||||
func (m MySQLConfig) DSN() string {
|
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
|
||||||
return fmt.Sprintf(
|
p.Host, p.Port, p.User, p.Password, p.Database, p.SSLMode)
|
||||||
"%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 {
|
type MinIOConfig struct {
|
||||||
Endpoint string `mapstructure:"endpoint"`
|
Endpoint string `mapstructure:"endpoint"`
|
||||||
AccessKey string `mapstructure:"access_key"`
|
AccessKey string `mapstructure:"access_key"`
|
||||||
SecretKey string `mapstructure:"secret_key"`
|
SecretKey string `mapstructure:"secret_key"`
|
||||||
Bucket string `mapstructure:"bucket"`
|
Bucket string `mapstructure:"bucket"`
|
||||||
UseSSL bool `mapstructure:"use_ssl"`
|
UseSSL bool `mapstructure:"use_ssl"`
|
||||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// JWTConfig access token 短、refresh token 长:
|
|
||||||
// access 过期由前端拿 refresh 静默续期,refresh 每次使用都轮换。
|
|
||||||
type JWTConfig struct {
|
type JWTConfig struct {
|
||||||
Secret string `mapstructure:"secret"`
|
Secret string `mapstructure:"secret"`
|
||||||
ExpireHours int `mapstructure:"expire_hours"` // access token 有效期
|
ExpireHours int `mapstructure:"expire_hours"`
|
||||||
RefreshExpireHours int `mapstructure:"refresh_expire_hours"` // 小程序用户刷新令牌有效期
|
|
||||||
AdminRefreshExpireHours int `mapstructure:"admin_refresh_expire_hours"` // 后台刷新令牌有效期(更短)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeChatConfig struct {
|
type WeChatConfig struct {
|
||||||
AppID string `mapstructure:"app_id"`
|
AppID string `mapstructure:"app_id"`
|
||||||
AppSecret string `mapstructure:"app_secret"`
|
AppSecret string `mapstructure:"app_secret"`
|
||||||
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
|
|
||||||
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
|
|
||||||
MsgToken string `mapstructure:"msg_token"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@@ -90,79 +65,54 @@ type AdminConfig struct {
|
|||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDefaults 为每个配置项登记默认值。
|
|
||||||
// 必须登记:viper 的 AutomaticEnv 只对「已知的 key」生效,未登记的 key 即使设了
|
|
||||||
// PETS_XXX 环境变量,Unmarshal 时也绑不上(容器里纯 .env 部署会静默取到空值)。
|
|
||||||
func setDefaults(v *viper.Viper) {
|
|
||||||
v.SetDefault("server.port", 9090)
|
|
||||||
v.SetDefault("server.mode", "release")
|
|
||||||
|
|
||||||
v.SetDefault("mysql.host", "127.0.0.1")
|
|
||||||
v.SetDefault("mysql.port", 3306)
|
|
||||||
v.SetDefault("mysql.user", "root")
|
|
||||||
v.SetDefault("mysql.password", "")
|
|
||||||
v.SetDefault("mysql.database", "pets")
|
|
||||||
v.SetDefault("mysql.charset", "utf8mb4")
|
|
||||||
|
|
||||||
v.SetDefault("minio.endpoint", "127.0.0.1:9000")
|
|
||||||
v.SetDefault("minio.access_key", "")
|
|
||||||
v.SetDefault("minio.secret_key", "")
|
|
||||||
v.SetDefault("minio.bucket", "pets")
|
|
||||||
v.SetDefault("minio.use_ssl", false)
|
|
||||||
v.SetDefault("minio.public_base_url", "")
|
|
||||||
|
|
||||||
v.SetDefault("jwt.secret", "")
|
|
||||||
v.SetDefault("jwt.expire_hours", 2)
|
|
||||||
v.SetDefault("jwt.refresh_expire_hours", 720) // 30 天
|
|
||||||
v.SetDefault("jwt.admin_refresh_expire_hours", 168)
|
|
||||||
|
|
||||||
v.SetDefault("wechat.app_id", "")
|
|
||||||
v.SetDefault("wechat.app_secret", "")
|
|
||||||
v.SetDefault("wechat.msg_token", "") // 用 PETS_WECHAT_MSG_TOKEN 覆盖
|
|
||||||
|
|
||||||
v.SetDefault("auth.dev_login", false)
|
|
||||||
|
|
||||||
v.SetDefault("admin.username", "sundynix")
|
|
||||||
v.SetDefault("admin.password", "")
|
|
||||||
|
|
||||||
v.SetDefault("ai.enabled", false)
|
|
||||||
v.SetDefault("ai.provider", "openai")
|
|
||||||
v.SetDefault("ai.base_url", "")
|
|
||||||
v.SetDefault("ai.api_key", "")
|
|
||||||
v.SetDefault("ai.model", "")
|
|
||||||
v.SetDefault("ai.temperature", 0.6)
|
|
||||||
v.SetDefault("ai.max_tokens", 1024)
|
|
||||||
v.SetDefault("ai.timeout_sec", 30)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load 载入配置:默认值 → configs/config.yaml(可选)→ 环境变量覆盖。
|
|
||||||
// 环境变量用 PETS_ 前缀、点转下划线,例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password。
|
|
||||||
// 容器部署不带 config.yaml,全部走 .env 注入的环境变量。
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
v := viper.New()
|
v := viper.New()
|
||||||
setDefaults(v)
|
v.SetDefault("server.port", 9090)
|
||||||
|
v.SetDefault("server.mode", "debug")
|
||||||
|
v.SetDefault("postgres.host", "192.168.100.127")
|
||||||
|
v.SetDefault("postgres.port", 5432)
|
||||||
|
v.SetDefault("postgres.user", "root")
|
||||||
|
v.SetDefault("postgres.password", "")
|
||||||
|
v.SetDefault("postgres.database", "sundynix-pets")
|
||||||
|
v.SetDefault("postgres.ssl_mode", "disable")
|
||||||
|
v.SetDefault("minio.endpoint", "https://s3.sundynix.cn")
|
||||||
|
v.SetDefault("minio.access_key", "")
|
||||||
|
v.SetDefault("minio.secret_key", "")
|
||||||
|
v.SetDefault("minio.bucket", "sundynix-pet")
|
||||||
|
v.SetDefault("minio.use_ssl", true)
|
||||||
|
v.SetDefault("jwt.secret", "")
|
||||||
|
v.SetDefault("jwt.expire_hours", 168)
|
||||||
|
v.SetDefault("wechat.app_id", "")
|
||||||
|
v.SetDefault("wechat.app_secret", "")
|
||||||
|
v.SetDefault("auth.dev_login", false)
|
||||||
|
v.SetDefault("admin.username", "sundynix")
|
||||||
|
v.SetDefault("admin.password", "")
|
||||||
|
|
||||||
v.SetConfigName("config")
|
v.SetConfigName("config")
|
||||||
v.SetConfigType("yaml")
|
v.SetConfigType("yaml")
|
||||||
v.AddConfigPath("./configs")
|
v.AddConfigPath("./configs")
|
||||||
v.AddConfigPath("../../configs")
|
v.AddConfigPath("../../configs")
|
||||||
v.AddConfigPath(".")
|
|
||||||
|
|
||||||
v.SetEnvPrefix("PETS")
|
v.SetEnvPrefix("PETS")
|
||||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
v.AutomaticEnv()
|
v.AutomaticEnv()
|
||||||
|
|
||||||
// 配置文件可选:找不到就只用默认值 + 环境变量;文件存在但解析失败仍要报错。
|
|
||||||
if err := v.ReadInConfig(); err != nil {
|
if err := v.ReadInConfig(); err != nil {
|
||||||
var notFound viper.ConfigFileNotFoundError
|
var missing viper.ConfigFileNotFoundError
|
||||||
if !errors.As(err, ¬Found) && !os.IsNotExist(err) {
|
if !errors.As(err, &missing) && !os.IsNotExist(err) {
|
||||||
return nil, fmt.Errorf("read config: %w", err)
|
return nil, fmt.Errorf("read config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := v.Unmarshal(&cfg); err != nil {
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
return nil, fmt.Errorf("decode config: %w", err)
|
||||||
|
}
|
||||||
|
if cfg.Postgres.Password == "" || cfg.JWT.Secret == "" {
|
||||||
|
return nil, errors.New("postgres.password and jwt.secret are required")
|
||||||
|
}
|
||||||
|
if cfg.MinIO.AccessKey == "" || cfg.MinIO.SecretKey == "" {
|
||||||
|
return nil, errors.New("minio access_key and secret_key are required")
|
||||||
|
}
|
||||||
|
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
|
||||||
|
return nil, errors.New("dev login must be disabled in release mode")
|
||||||
}
|
}
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
|
|
||||||
//
|
|
||||||
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
|
|
||||||
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
|
|
||||||
// 青年期 绝育窗口 + 换成年粮
|
|
||||||
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
|
|
||||||
// 中年期 年度血检开始有意义,体重和牙结石重点看
|
|
||||||
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
|
|
||||||
//
|
|
||||||
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
|
|
||||||
//
|
|
||||||
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
|
|
||||||
// 用户建完档只能自己从零排计划。
|
|
||||||
|
|
||||||
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
|
|
||||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
|
|
||||||
Title: title, Description: desc, SheetType: sheet}
|
|
||||||
}
|
|
||||||
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
|
|
||||||
it := plan(title, desc, model.RepeatWeekly, sheet)
|
|
||||||
it.Weekdays = weekdays
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
|
|
||||||
it := plan(title, desc, model.RepeatMonthly, sheet)
|
|
||||||
it.MonthDay = day
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
|
|
||||||
it := plan(title, desc, model.RepeatOnce, sheet)
|
|
||||||
it.Day = day
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
|
|
||||||
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
|
|
||||||
Title: title, OffsetDays: offset, Frequency: freq}
|
|
||||||
}
|
|
||||||
func important(it model.CareTemplateItem) model.CareTemplateItem {
|
|
||||||
it.Priority = "重要"
|
|
||||||
return it
|
|
||||||
}
|
|
||||||
|
|
||||||
// TplDef 一套方案的定义(迁移程序要读它)
|
|
||||||
type tplDef struct {
|
|
||||||
Species string
|
|
||||||
Stage string
|
|
||||||
Name string
|
|
||||||
Summary string
|
|
||||||
Items []model.CareTemplateItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// CareTemplateDefs 导出给一次性迁移程序用
|
|
||||||
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
|
|
||||||
|
|
||||||
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
|
|
||||||
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
|
|
||||||
|
|
||||||
func careTemplateDefs() []tplDef {
|
|
||||||
const (
|
|
||||||
J = "幼年期"
|
|
||||||
Y = "青年期"
|
|
||||||
A = "成年期"
|
|
||||||
M = "中年期"
|
|
||||||
S = "老年期"
|
|
||||||
)
|
|
||||||
return []tplDef{
|
|
||||||
// ───────────────── 猫 ─────────────────
|
|
||||||
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
|
|
||||||
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
|
|
||||||
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
|
|
||||||
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
|
|
||||||
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
|
|
||||||
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
|
|
||||||
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
|
|
||||||
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
|
|
||||||
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
|
|
||||||
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
|
|
||||||
remind("weight", "记录体重", 7, "每周一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
|
|
||||||
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
|
|
||||||
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
|
|
||||||
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
|
|
||||||
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
|
|
||||||
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
|
|
||||||
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
|
|
||||||
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
|
|
||||||
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
|
|
||||||
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
|
|
||||||
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
|
|
||||||
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
|
|
||||||
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
|
|
||||||
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
|
|
||||||
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
|
|
||||||
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
|
|
||||||
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
|
|
||||||
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
|
|
||||||
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
|
|
||||||
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
|
|
||||||
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
|
|
||||||
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("weight", "记录体重", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
|
|
||||||
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
|
|
||||||
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
|
|
||||||
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
|
|
||||||
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
|
|
||||||
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
|
|
||||||
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
|
|
||||||
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
|
|
||||||
// ───────────────── 狗 ─────────────────
|
|
||||||
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
|
|
||||||
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
|
|
||||||
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
|
|
||||||
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
|
|
||||||
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
|
|
||||||
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
|
|
||||||
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
|
|
||||||
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
|
|
||||||
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
|
|
||||||
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
|
|
||||||
remind("weight", "记录体重", 7, "每周一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
|
|
||||||
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
|
|
||||||
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
|
|
||||||
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
|
|
||||||
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
|
|
||||||
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
|
|
||||||
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
|
|
||||||
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
|
|
||||||
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
|
|
||||||
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
|
|
||||||
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
|
|
||||||
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
|
|
||||||
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
|
|
||||||
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
|
|
||||||
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
|
|
||||||
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
|
|
||||||
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
|
|
||||||
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
|
|
||||||
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
|
|
||||||
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
|
|
||||||
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
|
|
||||||
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("weight", "记录体重", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
|
|
||||||
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
|
|
||||||
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
|
|
||||||
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
|
|
||||||
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
|
|
||||||
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
|
|
||||||
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
|
|
||||||
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
|
|
||||||
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
|
|
||||||
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
|
|
||||||
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedCareTemplates 只在方案表为空时铺一次
|
|
||||||
func seedCareTemplates(db *gorm.DB) error {
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return insertCareTemplates(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertCareTemplates(db *gorm.DB) error {
|
|
||||||
for i, d := range careTemplateDefs() {
|
|
||||||
tpl := model.CareTemplate{
|
|
||||||
Species: d.Species, Stage: d.Stage, Name: d.Name,
|
|
||||||
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
|
|
||||||
}
|
|
||||||
if err := db.Create(&tpl).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
items := make([]model.CareTemplateItem, 0, len(d.Items))
|
|
||||||
for j, it := range d.Items {
|
|
||||||
it.TemplateID = tpl.ID
|
|
||||||
it.Sort = j
|
|
||||||
items = append(items, it)
|
|
||||||
}
|
|
||||||
if len(items) > 0 {
|
|
||||||
if err := db.Create(&items).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
@@ -13,40 +13,31 @@ import (
|
|||||||
"github.com/sundynix/pets-be/internal/model"
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
|
func Open(cfg config.PostgresConfig, release bool) (*gorm.DB, error) {
|
||||||
func New(cfg *config.Config) (*gorm.DB, error) {
|
|
||||||
logLevel := logger.Info
|
logLevel := logger.Info
|
||||||
if cfg.Server.Mode == "release" {
|
if release {
|
||||||
logLevel = logger.Warn
|
logLevel = logger.Warn
|
||||||
}
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
|
||||||
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
|
NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
|
||||||
NamingStrategy: schema.NamingStrategy{
|
Logger: logger.Default.LogMode(logLevel),
|
||||||
TablePrefix: "sundynix_",
|
|
||||||
SingularTable: false,
|
|
||||||
},
|
|
||||||
// 关联在 Go 层管,不靠库级外键。迁移期建外键时 MySQL 会为外键列自动
|
|
||||||
// 补一个索引,和字段上的 `index` tag 撞成同名索引,父子表同迁时第二次
|
|
||||||
// 建索引报 1061 duplicate。关掉外键约束创建,索引就只由 index tag 建一次
|
|
||||||
DisableForeignKeyConstraintWhenMigrating: true,
|
|
||||||
Logger: logger.Default.LogMode(logLevel),
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("gorm open: %w", err)
|
return nil, fmt.Errorf("open postgres: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlDB, err := db.DB()
|
sqlDB, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sqlDB.SetMaxOpenConns(50)
|
sqlDB.SetMaxOpenConns(25)
|
||||||
sqlDB.SetMaxIdleConns(10)
|
sqlDB.SetMaxIdleConns(10)
|
||||||
sqlDB.SetConnMaxLifetime(time.Hour)
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate 自动建表
|
|
||||||
func Migrate(db *gorm.DB) error {
|
func Migrate(db *gorm.DB) error {
|
||||||
return db.AutoMigrate(model.AllModels()...)
|
if err := db.AutoMigrate(model.All()...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Seed 初始化种子数据:管理员账号 + 记录类型。
|
|
||||||
//
|
|
||||||
// 文章、养护模板这类**内容**数据不放代码里——正文几百字塞进 Go 源文件,既看不清
|
|
||||||
// 也没法在后台改完再同步回来。上线时直接同步数据库。
|
|
||||||
//
|
|
||||||
// 记录类型是另一码事,它是**结构**:表空着的话记录页一个可记事项都没有,
|
|
||||||
// App 直接不能用。所以和管理员账号一样,只在表完全为空时铺一次底,
|
|
||||||
// 之后一切以数据库为准,代码里这份不再参与。
|
|
||||||
func Seed(db *gorm.DB, cfg *config.Config) error {
|
|
||||||
if err := seedAdmin(db, cfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := seedRecordTypes(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := seedBreeds(db); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return seedCareTemplates(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
|
|
||||||
//
|
|
||||||
// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。
|
|
||||||
// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬)
|
|
||||||
// 自动转常出错,人来定比库来猜靠谱。
|
|
||||||
func seedBreeds(db *gorm.DB) error {
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
breeds := []model.Breed{
|
|
||||||
// 猫 34 种
|
|
||||||
{Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10},
|
|
||||||
{Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20},
|
|
||||||
{Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30},
|
|
||||||
{Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40},
|
|
||||||
{Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50},
|
|
||||||
{Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60},
|
|
||||||
{Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70},
|
|
||||||
{Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80},
|
|
||||||
{Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90},
|
|
||||||
{Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100},
|
|
||||||
{Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110},
|
|
||||||
{Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120},
|
|
||||||
{Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130},
|
|
||||||
{Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190},
|
|
||||||
{Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200},
|
|
||||||
{Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210},
|
|
||||||
{Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220},
|
|
||||||
{Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230},
|
|
||||||
{Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240},
|
|
||||||
{Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250},
|
|
||||||
{Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260},
|
|
||||||
{Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270},
|
|
||||||
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280},
|
|
||||||
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290},
|
|
||||||
{Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300},
|
|
||||||
{Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310},
|
|
||||||
{Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320},
|
|
||||||
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
|
|
||||||
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
|
|
||||||
// 狗 46 种
|
|
||||||
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
|
|
||||||
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
|
|
||||||
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
|
|
||||||
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
|
|
||||||
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
|
|
||||||
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
|
|
||||||
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
|
|
||||||
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
|
|
||||||
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
|
|
||||||
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
|
|
||||||
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
|
|
||||||
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
|
|
||||||
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
|
|
||||||
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
|
|
||||||
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
|
|
||||||
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
|
|
||||||
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
|
|
||||||
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
|
|
||||||
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
|
|
||||||
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
|
|
||||||
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
|
|
||||||
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
|
|
||||||
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
|
|
||||||
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
|
|
||||||
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
|
|
||||||
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
|
|
||||||
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
|
|
||||||
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
|
|
||||||
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
|
|
||||||
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
|
|
||||||
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
|
|
||||||
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
|
|
||||||
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
|
|
||||||
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
|
|
||||||
}
|
|
||||||
for i := range breeds {
|
|
||||||
breeds[i].Enabled = true
|
|
||||||
}
|
|
||||||
return db.Create(&breeds).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰,
|
|
||||||
// 否则后台删掉的类型会在每次重启后自己长回来
|
|
||||||
func seedRecordTypes(db *gorm.DB) error {
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&model.RecordType{}).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// locked=true 的这 9 种在 record.go / insight.go / report.go / home.go 里有真实
|
|
||||||
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
|
|
||||||
// care_template_items.sheet_type 引用,后台不许删,只能停用。
|
|
||||||
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
|
|
||||||
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
|
|
||||||
// 空 = 只要「时间 + 描述 + 照片」。
|
|
||||||
//
|
|
||||||
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
|
|
||||||
// weight number → num_value,CreateRecord 靠它回写宠物档案
|
|
||||||
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
|
|
||||||
// poop 状态→category,食便相关性分析排除 category='正常'
|
|
||||||
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
|
|
||||||
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
|
|
||||||
form := model.RecordFormSimple
|
|
||||||
if locked {
|
|
||||||
form = model.RecordFormFull
|
|
||||||
}
|
|
||||||
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
|
|
||||||
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
|
|
||||||
}
|
|
||||||
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
|
|
||||||
return mk(code, label, icon, group, sort, true, fields)
|
|
||||||
}
|
|
||||||
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
|
|
||||||
return mk(code, label, icon, group, sort, false, fields)
|
|
||||||
}
|
|
||||||
|
|
||||||
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
|
|
||||||
types := []model.RecordType{
|
|
||||||
// 日常
|
|
||||||
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
|
|
||||||
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
|
|
||||||
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
|
|
||||||
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
|
|
||||||
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
|
|
||||||
full("photo", "照片", "photo", d, 60, ""),
|
|
||||||
// 健康
|
|
||||||
full("symptom", "异常", "symptom", h, 10,
|
|
||||||
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
|
|
||||||
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
|
|
||||||
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
|
|
||||||
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
|
|
||||||
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
|
|
||||||
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
|
|
||||||
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
|
|
||||||
// 洗护
|
|
||||||
simple("bath", "洗澡", "bath", c, 10, ""),
|
|
||||||
simple("nail", "剪指甲", "nail", c, 20, ""),
|
|
||||||
simple("ear", "洗耳朵", "ear", c, 30, ""),
|
|
||||||
simple("tooth", "刷牙", "tooth", c, 40, ""),
|
|
||||||
simple("brush", "梳毛", "brush", c, 50, ""),
|
|
||||||
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
|
|
||||||
// 清洁
|
|
||||||
simple("litter", "换猫砂", "litter", cl, 10, ""),
|
|
||||||
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
|
|
||||||
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
|
|
||||||
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
|
|
||||||
simple("clean", "消毒", "clean", cl, 50, ""),
|
|
||||||
}
|
|
||||||
return db.Create(&types).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
|
|
||||||
var count int64
|
|
||||||
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return db.Create(&model.Admin{
|
|
||||||
Username: cfg.Admin.Username,
|
|
||||||
PasswordHash: string(hash),
|
|
||||||
Role: "admin",
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
@@ -1,558 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type adminLoginReq struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Password string `json:"password"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminLogin POST /api/admin/login
|
|
||||||
func (h *Handler) AdminLogin(c *gin.Context) {
|
|
||||||
var req adminLoginReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
admin, err := h.svc.AdminLogin(req.Username, req.Password)
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, "账号或密码错误")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := h.adminTokens(admin)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
|
|
||||||
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
|
|
||||||
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return gin.H{
|
|
||||||
"token": token,
|
|
||||||
"refresh_token": refresh,
|
|
||||||
"expires_in": h.jwt.ExpireSeconds(),
|
|
||||||
"refresh_expires_at": refreshExp,
|
|
||||||
"admin": admin,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminRefresh POST /api/admin/refresh
|
|
||||||
func (h *Handler) AdminRefresh(c *gin.Context) {
|
|
||||||
var req refreshReq
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
admin, err := h.svc.GetAdmin(adminID)
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, "登录已失效,请重新登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if admin.Disabled {
|
|
||||||
response.Fail(c, 40300, "账号已被禁用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := h.adminTokens(admin)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
|
|
||||||
func (h *Handler) AdminLogout(c *gin.Context) {
|
|
||||||
var req refreshReq
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminMe GET /api/admin/me
|
|
||||||
func (h *Handler) AdminMe(c *gin.Context) {
|
|
||||||
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, admin)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminStats GET /api/admin/stats
|
|
||||||
func (h *Handler) AdminStats(c *gin.Context) {
|
|
||||||
stats, err := h.svc.Stats()
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
|
|
||||||
func (h *Handler) AdminAnalytics(c *gin.Context) {
|
|
||||||
days, _ := strconv.Atoi(c.Query("days"))
|
|
||||||
data, err := h.svc.AdminAnalytics(days)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
type adminListReq struct {
|
|
||||||
response.PageQuery
|
|
||||||
Keyword string `form:"keyword"`
|
|
||||||
Status string `form:"status"`
|
|
||||||
Type string `form:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListUsers GET /api/admin/users
|
|
||||||
func (h *Handler) AdminListUsers(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
users, total, err := h.svc.ListUsers(req.Keyword, req.Type, req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(users, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
type disableReq struct {
|
|
||||||
Disabled bool `json:"disabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
|
|
||||||
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
|
|
||||||
var req disableReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.SetUserDisabled(idParam(c, "id"), req.Disabled); err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListPets GET /api/admin/pets
|
|
||||||
func (h *Handler) AdminListPets(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(pets, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListPosts GET /api/admin/posts
|
|
||||||
func (h *Handler) AdminListPosts(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
type postStatusReq struct {
|
|
||||||
Status string `json:"status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSetPostStatus PUT /api/admin/posts/:id/status
|
|
||||||
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
|
|
||||||
var req postStatusReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.SetPostStatus(idParam(c, "id"), req.Status); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListComments GET /api/admin/comments
|
|
||||||
func (h *Handler) AdminListComments(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
comments, total, err := h.svc.ListCommentsAdmin(req.Status, req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
|
|
||||||
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
|
|
||||||
var req postStatusReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteComment DELETE /api/admin/comments/:id
|
|
||||||
func (h *Handler) AdminDeleteComment(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListArticles GET /api/admin/articles
|
|
||||||
func (h *Handler) AdminListArticles(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(articles, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
type articleReq struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
RelatedSheetType string `json:"related_sheet_type"`
|
|
||||||
Published bool `json:"published"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveArticle POST /api/admin/articles(id>0 为更新)
|
|
||||||
func (h *Handler) AdminSaveArticle(c *gin.Context) {
|
|
||||||
var req articleReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
article := &model.Article{
|
|
||||||
Icon: req.Icon, Title: req.Title, Description: req.Description, Content: req.Content,
|
|
||||||
Category: req.Category, RelatedSheetType: req.RelatedSheetType, Published: req.Published,
|
|
||||||
}
|
|
||||||
article.ID = req.ID
|
|
||||||
if err := h.svc.SaveArticle(article); err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, article)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteArticle DELETE /api/admin/articles/:id
|
|
||||||
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteArticle(idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListPro GET /api/admin/memberships
|
|
||||||
func (h *Handler) AdminListPro(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(list, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
|
|
||||||
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
|
|
||||||
// ── 品种 ──
|
|
||||||
|
|
||||||
type breedReq struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Species string `json:"species"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Initial string `json:"initial"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回
|
|
||||||
func (h *Handler) AdminListBreeds(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.AdminListBreeds(c.Query("species")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveBreed POST /api/admin/breeds
|
|
||||||
func (h *Handler) AdminSaveBreed(c *gin.Context) {
|
|
||||||
var req breedReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
b := &model.Breed{Species: req.Species, Name: req.Name,
|
|
||||||
Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled}
|
|
||||||
b.ID = req.ID
|
|
||||||
if err := h.svc.SaveBreed(b); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteBreed DELETE /api/admin/breeds/:id
|
|
||||||
func (h *Handler) AdminDeleteBreed(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 记录类型 ──
|
|
||||||
|
|
||||||
type recordTypeReq struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Code string `json:"code"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Group string `json:"group"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Form string `json:"form"`
|
|
||||||
Fields string `json:"fields_raw"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
|
|
||||||
func (h *Handler) AdminListRecordTypes(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.AdminListRecordTypes())
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveRecordType POST /api/admin/record-types 新增或更新
|
|
||||||
func (h *Handler) AdminSaveRecordType(c *gin.Context) {
|
|
||||||
var req recordTypeReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
t := &model.RecordType{
|
|
||||||
Code: req.Code, Label: req.Label, Icon: req.Icon,
|
|
||||||
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
|
|
||||||
Fields: req.Fields,
|
|
||||||
}
|
|
||||||
t.ID = req.ID
|
|
||||||
if err := h.svc.SaveRecordType(t); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, t)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteRecordType DELETE /api/admin/record-types/:id
|
|
||||||
// locked 的和已有记录的删不了,service 层硬拦
|
|
||||||
func (h *Handler) AdminDeleteRecordType(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteRecordType(idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
|
|
||||||
response.OK(c, gin.H{
|
|
||||||
"species": []gin.H{
|
|
||||||
{"key": model.SpeciesCat, "label": "猫猫"},
|
|
||||||
{"key": model.SpeciesDog, "label": "狗狗"},
|
|
||||||
},
|
|
||||||
"stages": service.CareStages,
|
|
||||||
// 「关联记录事项」的可选值读 record_types 表,不再写死。
|
|
||||||
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
|
|
||||||
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
|
|
||||||
// 而后台新加的 24 种事项一个都选不到
|
|
||||||
"record_types": h.svc.AdminListRecordTypes(),
|
|
||||||
"repeats": []string{"once", "daily", "weekly", "monthly"},
|
|
||||||
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
|
|
||||||
"priorities": []string{"", "重要"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
|
|
||||||
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.AdminListTemplates())
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
|
|
||||||
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
|
|
||||||
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, tpl)
|
|
||||||
}
|
|
||||||
|
|
||||||
type careTemplateReq struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Species string `json:"species"`
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Items []model.CareTemplateItem `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
|
|
||||||
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
|
|
||||||
var req careTemplateReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
tpl := &model.CareTemplate{
|
|
||||||
Species: req.Species, Stage: req.Stage, Name: req.Name,
|
|
||||||
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
|
|
||||||
}
|
|
||||||
tpl.ID = req.ID
|
|
||||||
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
|
|
||||||
response.OK(c, saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
|
|
||||||
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
type careGenReq struct {
|
|
||||||
Species string `json:"species"`
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGenerateCareTemplate POST /api/admin/care-templates/generate
|
|
||||||
// AI 生成草稿(不落库),未开启 AI 时回退内置默认
|
|
||||||
func (h *Handler) AdminGenerateCareTemplate(c *gin.Context) {
|
|
||||||
var req careGenReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
items, err := h.svc.GenerateCareTemplate(req.Species, req.Stage)
|
|
||||||
if err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetCommunityBot GET /api/admin/community-bot
|
|
||||||
func (h *Handler) AdminGetCommunityBot(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.GetCommunityBotConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveCommunityBot PUT /api/admin/community-bot
|
|
||||||
func (h *Handler) AdminSaveCommunityBot(c *gin.Context) {
|
|
||||||
var req model.CommunityBotConfig
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
saved, err := h.svc.SaveCommunityBotConfig(req)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, saved)
|
|
||||||
}
|
|
||||||
|
|
||||||
type communityGenReq struct {
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGenerateCommunityPosts POST /api/admin/community-bot/generate
|
|
||||||
func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
|
|
||||||
var req communityGenReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Count <= 0 {
|
|
||||||
req.Count = 1
|
|
||||||
}
|
|
||||||
if req.Count > 30 {
|
|
||||||
req.Count = 30
|
|
||||||
}
|
|
||||||
made, err := h.svc.GenerateCommunityPosts(req.Count)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"made": made})
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetAIQuota GET /api/admin/ai-quota
|
|
||||||
func (h *Handler) AdminGetAIQuota(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.GetAIQuotaConfig())
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSaveAIQuota PUT /api/admin/ai-quota
|
|
||||||
func (h *Handler) AdminSaveAIQuota(c *gin.Context) {
|
|
||||||
var req model.AIQuotaConfig
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cfg, err := h.svc.SaveAIQuotaConfig(req)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, cfg)
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type loginReq struct {
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// userTokens 签发小程序用户的「access + refresh」令牌对。
|
|
||||||
// access token 只有 2 小时,前端过期后拿 refresh_token 静默续期。
|
|
||||||
func (h *Handler) userTokens(user *model.User) (gin.H, error) {
|
|
||||||
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindUser), user.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return gin.H{
|
|
||||||
"token": token,
|
|
||||||
"refresh_token": refresh,
|
|
||||||
"expires_in": h.jwt.ExpireSeconds(),
|
|
||||||
"refresh_expires_at": refreshExp,
|
|
||||||
"user": user,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login 开发态 Mock 登录
|
|
||||||
func (h *Handler) Login(c *gin.Context) {
|
|
||||||
if !h.cfg.Auth.DevLogin {
|
|
||||||
response.Fail(c, 40300, "开发登录未开启,请使用微信登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var req loginReq
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
user, err := h.svc.MockLogin(req.Nickname)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := h.userTokens(user)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
type wechatLoginReq struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Nickname string `json:"nickname"`
|
|
||||||
Avatar string `json:"avatar"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatLogin 微信小程序登录:前端 wx.login 拿 code 传入
|
|
||||||
func (h *Handler) WechatLogin(c *gin.Context) {
|
|
||||||
var req wechatLoginReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Code == "" {
|
|
||||||
response.FailParams(c, "缺少 code")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.svc.WechatLogin(req.Code)
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// 可选:首次登录写入用户授权的昵称/头像
|
|
||||||
if fields := map[string]any{}; true {
|
|
||||||
// 只覆盖系统生成的昵称。用户自己改过的(NicknameAuto=false)不动
|
|
||||||
if req.Nickname != "" && user.NicknameAuto {
|
|
||||||
fields["nickname"] = req.Nickname
|
|
||||||
fields["nickname_auto"] = false
|
|
||||||
}
|
|
||||||
if req.Avatar != "" && user.Avatar == "" {
|
|
||||||
fields["avatar"] = req.Avatar
|
|
||||||
}
|
|
||||||
if len(fields) > 0 {
|
|
||||||
if u, e := h.svc.UpdateUser(user.ID, fields); e == nil {
|
|
||||||
user = u
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data, err := h.userTokens(user)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
type refreshReq struct {
|
|
||||||
RefreshToken string `json:"refresh_token"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RefreshToken POST /api/auth/refresh
|
|
||||||
// 用刷新令牌换新的 access token;刷新令牌本身也会轮换,旧的立即失效。
|
|
||||||
func (h *Handler) RefreshToken(c *gin.Context) {
|
|
||||||
var req refreshReq
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
|
|
||||||
userID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.svc.GetUser(userID)
|
|
||||||
if err != nil {
|
|
||||||
response.Fail(c, 40100, "登录已失效,请重新登录")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user.Disabled {
|
|
||||||
response.Fail(c, 40300, "账号已被禁用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := h.userTokens(user)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout POST /api/auth/logout 作废当前刷新令牌
|
|
||||||
func (h *Handler) Logout(c *gin.Context) {
|
|
||||||
var req refreshReq
|
|
||||||
_ = c.ShouldBindJSON(&req)
|
|
||||||
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profile 当前用户资料
|
|
||||||
func (h *Handler) Profile(c *gin.Context) {
|
|
||||||
user, err := h.svc.GetUser(middleware.UserID(c))
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateProfileReq struct {
|
|
||||||
Nickname *string `json:"nickname"`
|
|
||||||
Avatar *string `json:"avatar"`
|
|
||||||
AvatarFileID *string `json:"avatar_file_id"`
|
|
||||||
Phone *string `json:"phone"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfile 更新资料
|
|
||||||
// decorationReq 主页装扮。全部用指针:区分「没传」和「传了空串」——
|
|
||||||
// 用户清空个性签名和不改个性签名是两件事
|
|
||||||
type decorationReq struct {
|
|
||||||
Bio *string `json:"bio"`
|
|
||||||
BgFileID *string `json:"bg_file_id"`
|
|
||||||
Theme *string `json:"theme"`
|
|
||||||
ShowPets *bool `json:"show_pets"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateDecoration PUT /api/user/decoration 装扮自己的主页
|
|
||||||
func (h *Handler) UpdateDecoration(c *gin.Context) {
|
|
||||||
var req decorationReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
user, err := h.svc.UpdateDecoration(middleware.UserID(c), req.Bio, req.BgFileID, req.Theme, req.ShowPets)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
|
||||||
var req updateProfileReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fields := map[string]any{}
|
|
||||||
if req.Nickname != nil {
|
|
||||||
fields["nickname"] = *req.Nickname
|
|
||||||
fields["nickname_auto"] = false // 用户自己设过就别再被微信授权覆盖
|
|
||||||
}
|
|
||||||
if req.Avatar != nil {
|
|
||||||
fields["avatar"] = *req.Avatar
|
|
||||||
}
|
|
||||||
if req.AvatarFileID != nil {
|
|
||||||
fields["avatar_file_id"] = *req.AvatarFileID
|
|
||||||
}
|
|
||||||
if req.Phone != nil {
|
|
||||||
fields["phone"] = *req.Phone
|
|
||||||
}
|
|
||||||
user, err := h.svc.UpdateUser(middleware.UserID(c), fields)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindPhone POST /api/user/phone 用小程序 getPhoneNumber 的 code 绑定手机号
|
|
||||||
func (h *Handler) BindPhone(c *gin.Context) {
|
|
||||||
var req struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" {
|
|
||||||
response.FailParams(c, "缺少 code")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
phone, err := h.svc.BindPhoneByCode(middleware.UserID(c), req.Code)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"phone": phone})
|
|
||||||
}
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type postListReq struct {
|
|
||||||
response.PageQuery
|
|
||||||
Tab string `form:"tab"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPosts GET /api/posts?tab=&page=&page_size=
|
|
||||||
func (h *Handler) ListPosts(c *gin.Context) {
|
|
||||||
var req postListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
|
|
||||||
posts, total, err := h.svc.ListPosts(middleware.UserID(c), req.Tab, req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPost GET /api/posts/:id
|
|
||||||
func (h *Handler) GetPost(c *gin.Context) {
|
|
||||||
post, err := h.svc.GetPost(idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
type postReq struct {
|
|
||||||
PetID *string `json:"pet_id"`
|
|
||||||
Identity string `json:"identity"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
Tags []string `json:"tags"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
ImageFileIDs []string `json:"image_file_ids"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePost POST /api/posts
|
|
||||||
func (h *Handler) CreatePost(c *gin.Context) {
|
|
||||||
var req postReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
in := service.PostInput{PetID: req.PetID, Identity: req.Identity, Content: req.Content}
|
|
||||||
if b, err := jsonMarshal(req.Tags); err == nil {
|
|
||||||
in.Tags = datatypes.JSON(b)
|
|
||||||
}
|
|
||||||
if b, err := jsonMarshal(req.Images); err == nil {
|
|
||||||
in.Images = datatypes.JSON(b)
|
|
||||||
}
|
|
||||||
if len(req.ImageFileIDs) > 0 {
|
|
||||||
if b, err := jsonMarshal(req.ImageFileIDs); err == nil {
|
|
||||||
in.ImageFileIDs = datatypes.JSON(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
post, err := h.svc.CreatePost(middleware.UserID(c), in)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LikePost POST /api/posts/:id/like
|
|
||||||
func (h *Handler) LikePost(c *gin.Context) {
|
|
||||||
count, err := h.svc.LikePost(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"like_count": count})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlikePost DELETE /api/posts/:id/like
|
|
||||||
func (h *Handler) UnlikePost(c *gin.Context) {
|
|
||||||
count, err := h.svc.UnlikePost(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"like_count": count})
|
|
||||||
}
|
|
||||||
|
|
||||||
type commentListReq struct {
|
|
||||||
response.PageQuery
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListComments GET /api/posts/:id/comments
|
|
||||||
func (h *Handler) ListComments(c *gin.Context) {
|
|
||||||
var req commentListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
|
|
||||||
comments, total, err := h.svc.ListComments(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(comments, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
type commentReq struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Images []string `json:"images"`
|
|
||||||
ParentID string `json:"parent_id"` // 回复某条评论时传它的 id
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateComment POST /api/posts/:id/comments
|
|
||||||
func (h *Handler) CreateComment(c *gin.Context) {
|
|
||||||
var req commentReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Content == "" {
|
|
||||||
response.FailParams(c, "评论内容不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content, req.Images, req.ParentID)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, comment)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FollowUser POST /api/users/:id/follow
|
|
||||||
func (h *Handler) FollowUser(c *gin.Context) {
|
|
||||||
if err := h.svc.FollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"followed": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnfollowUser DELETE /api/users/:id/follow
|
|
||||||
func (h *Handler) UnfollowUser(c *gin.Context) {
|
|
||||||
if err := h.svc.UnfollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"followed": false})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteOwnPost DELETE /api/posts/:id (仅限本人)
|
|
||||||
func (h *Handler) DeleteOwnPost(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteOwnPost(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteOwnComment DELETE /api/comments/:id (仅限本人)
|
|
||||||
func (h *Handler) DeleteOwnComment(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteOwnComment(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListReplies GET /api/comments/:id/replies 展开某条评论的全部回复
|
|
||||||
func (h *Handler) ListReplies(c *gin.Context) {
|
|
||||||
list, err := h.svc.ListReplies(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserCard GET /api/users/:id/profile 某人的公开资料
|
|
||||||
func (h *Handler) UserCard(c *gin.Context) {
|
|
||||||
card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, card)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserPosts GET /api/users/:id/posts 某人发布的帖子
|
|
||||||
func (h *Handler) UserPosts(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(posts, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relations GET /api/users/:id/relations?kind=following|followers
|
|
||||||
func (h *Handler) Relations(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
kind := c.Query("kind")
|
|
||||||
list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(list, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/sundynix/pets-be/internal/config"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Handler 所有 HTTP 处理器聚合,方法按领域分散在各文件
|
|
||||||
type Handler struct {
|
|
||||||
svc *service.Service
|
|
||||||
jwt *appjwt.Manager
|
|
||||||
cfg *config.Config
|
|
||||||
}
|
|
||||||
|
|
||||||
func New(svc *service.Service, jm *appjwt.Manager, cfg *config.Config) *Handler {
|
|
||||||
return &Handler{svc: svc, jwt: jm, cfg: cfg}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/errcode"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// idParam 取路径参数(字符串雪花 ID)
|
|
||||||
func idParam(c *gin.Context, key string) string {
|
|
||||||
return c.Param(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonMarshal 便捷序列化
|
|
||||||
func jsonMarshal(v any) ([]byte, error) {
|
|
||||||
return json.Marshal(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// respondErr 统一错误响应:区分 not found 与内部错误
|
|
||||||
func respondErr(c *gin.Context, err error) {
|
|
||||||
if errors.Is(err, service.ErrNotFound) {
|
|
||||||
response.Fail(c, errcode.ErrNotFound, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, service.ErrForbidden) {
|
|
||||||
response.Fail(c, errcode.ErrForbidden, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if errors.Is(err, service.ErrInvalidParam) {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.FailErr(c, err)
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListArticles GET /api/articles
|
|
||||||
func (h *Handler) ListArticles(c *gin.Context) {
|
|
||||||
articles, err := h.svc.ListArticles()
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, articles)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetArticle GET /api/articles/:id
|
|
||||||
func (h *Handler) GetArticle(c *gin.Context) {
|
|
||||||
article, err := h.svc.GetArticle(idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, article)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPro GET /api/pro
|
|
||||||
func (h *Handler) GetPro(c *gin.Context) {
|
|
||||||
info, err := h.svc.GetPro(middleware.UserID(c))
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ActivatePro POST /api/pro/activate
|
|
||||||
func (h *Handler) ActivatePro(c *gin.Context) {
|
|
||||||
info, err := h.svc.ActivatePro(middleware.UserID(c))
|
|
||||||
if err != nil {
|
|
||||||
// 支付未接入不是服务器错误,按参数错返回,前端直接展示提示文案
|
|
||||||
if errors.Is(err, service.ErrPayNotReady) {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, info)
|
|
||||||
}
|
|
||||||
|
|
||||||
type aiChatReq struct {
|
|
||||||
PetID *string `json:"pet_id"`
|
|
||||||
Session string `json:"session"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIChat POST /api/ai/chat
|
|
||||||
func (h *Handler) AIChat(c *gin.Context) {
|
|
||||||
var req aiChatReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Text == "" {
|
|
||||||
response.FailParams(c, "问题不能为空")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, service.ErrAIQuotaExceeded) {
|
|
||||||
response.Fail(c, 42900, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"reply": reply})
|
|
||||||
}
|
|
||||||
|
|
||||||
// HomeSummary GET /api/pets/:id/home-summary
|
|
||||||
func (h *Handler) HomeSummary(c *gin.Context) {
|
|
||||||
res, err := h.svc.GetHomeSummary(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserSummary GET /api/user/summary
|
|
||||||
func (h *Handler) UserSummary(c *gin.Context) {
|
|
||||||
res, err := h.svc.GetUserSummary(middleware.UserID(c))
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
type assessSymptomReq struct {
|
|
||||||
Symptoms []string `json:"symptoms"`
|
|
||||||
Duration string `json:"duration"`
|
|
||||||
Spirit string `json:"spirit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssessSymptom POST /api/pets/:id/ai/assess-symptom
|
|
||||||
func (h *Handler) AssessSymptom(c *gin.Context) {
|
|
||||||
var req assessSymptomReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := h.svc.AssessSymptom(middleware.UserID(c), idParam(c, "id"), service.SymptomInput{
|
|
||||||
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
type feedbackReq struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Contact string `json:"contact"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFeedback POST /api/feedback
|
|
||||||
func (h *Handler) CreateFeedback(c *gin.Context) {
|
|
||||||
var req feedbackReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f, err := h.svc.CreateFeedback(middleware.UserID(c), req.Content, req.Contact)
|
|
||||||
if err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListFeedback GET /api/admin/feedback
|
|
||||||
func (h *Handler) AdminListFeedback(c *gin.Context) {
|
|
||||||
var req adminListReq
|
|
||||||
_ = c.ShouldBindQuery(&req)
|
|
||||||
req.Normalize()
|
|
||||||
list, total, err := h.svc.ListFeedbackAdmin(req.Offset(), req.Limit())
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(list, total, req.PageQuery))
|
|
||||||
}
|
|
||||||
|
|
||||||
type handledReq struct {
|
|
||||||
Handled bool `json:"handled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminSetFeedbackHandled PUT /api/admin/feedback/:id/handled
|
|
||||||
func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
|
|
||||||
var req handledReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.SetFeedbackHandled(idParam(c, "id"), req.Handled); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAIMessages GET /api/ai/messages?session=&limit=
|
|
||||||
func (h *Handler) ListAIMessages(c *gin.Context) {
|
|
||||||
limit := 0
|
|
||||||
if v := c.Query("limit"); v != "" {
|
|
||||||
limit, _ = strconv.Atoi(v)
|
|
||||||
}
|
|
||||||
msgs, err := h.svc.ListAIMessages(middleware.UserID(c), c.Query("session"), limit)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIQuota GET /api/ai/quota 今日剩余次数
|
|
||||||
func (h *Handler) AIQuota(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.AIQuotaLeft(middleware.UserID(c)))
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type petReq struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Emoji string `json:"emoji"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Gender string `json:"gender"`
|
|
||||||
Birthday string `json:"birthday"` // YYYY-MM-DD
|
|
||||||
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
|
|
||||||
Weight string `json:"weight"`
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
Age string `json:"age"`
|
|
||||||
Color string `json:"color"`
|
|
||||||
Breed string `json:"breed"`
|
|
||||||
AvatarFileID string `json:"avatar_file_id"`
|
|
||||||
Goals []string `json:"goals"`
|
|
||||||
Personality string `json:"personality"`
|
|
||||||
Allergy string `json:"allergy"`
|
|
||||||
Notes string `json:"notes"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r petReq) toInput() service.PetInput {
|
|
||||||
in := service.PetInput{
|
|
||||||
Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender,
|
|
||||||
Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed,
|
|
||||||
AvatarFileID: r.AvatarFileID,
|
|
||||||
Personality: r.Personality, Allergy: r.Allergy, Notes: r.Notes,
|
|
||||||
}
|
|
||||||
if r.Birthday != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil {
|
|
||||||
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
|
|
||||||
if !t.After(time.Now()) {
|
|
||||||
in.Birthday = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r.ArrivedAt != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", r.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
|
|
||||||
in.ArrivedAt = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
|
|
||||||
// 交给 handler 里 h.svc.NormalizeStage 处理。
|
|
||||||
if r.Goals != nil {
|
|
||||||
if b, err := jsonMarshal(r.Goals); err == nil {
|
|
||||||
in.Goals = datatypes.JSON(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return in
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPets GET /api/pets
|
|
||||||
func (h *Handler) ListPets(c *gin.Context) {
|
|
||||||
pets, err := h.svc.ListPets(middleware.UserID(c))
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, pets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePet POST /api/pets
|
|
||||||
func (h *Handler) CreatePet(c *gin.Context) {
|
|
||||||
var req petReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
in := req.toInput()
|
|
||||||
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
|
|
||||||
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, pet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPet GET /api/pets/:id
|
|
||||||
func (h *Handler) GetPet(c *gin.Context) {
|
|
||||||
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, pet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePet PUT /api/pets/:id
|
|
||||||
func (h *Handler) UpdatePet(c *gin.Context) {
|
|
||||||
var req petReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fields := map[string]any{}
|
|
||||||
if req.Name != "" {
|
|
||||||
fields["name"] = req.Name
|
|
||||||
}
|
|
||||||
if req.Emoji != "" {
|
|
||||||
fields["emoji"] = req.Emoji
|
|
||||||
}
|
|
||||||
if req.Weight != "" {
|
|
||||||
fields["weight"] = req.Weight
|
|
||||||
}
|
|
||||||
if req.Stage != "" {
|
|
||||||
fields["stage"] = req.Stage
|
|
||||||
}
|
|
||||||
if req.Gender != "" {
|
|
||||||
fields["gender"] = req.Gender
|
|
||||||
}
|
|
||||||
if req.Age != "" {
|
|
||||||
fields["age"] = req.Age
|
|
||||||
}
|
|
||||||
if req.Color != "" {
|
|
||||||
fields["color"] = req.Color
|
|
||||||
}
|
|
||||||
// 性格/过敏/备注:空串也允许写(用户可能想清空),用指针语义太重,
|
|
||||||
// 这里约定前端总是回传当前值,非空才更新;清空场景暂用「无」占位
|
|
||||||
if req.Personality != "" {
|
|
||||||
fields["personality"] = req.Personality
|
|
||||||
}
|
|
||||||
if req.Allergy != "" {
|
|
||||||
fields["allergy"] = req.Allergy
|
|
||||||
}
|
|
||||||
if req.Notes != "" {
|
|
||||||
fields["notes"] = req.Notes
|
|
||||||
}
|
|
||||||
if req.Breed != "" {
|
|
||||||
fields["breed"] = req.Breed
|
|
||||||
}
|
|
||||||
// 生日和到家日期是日期列,得先解析成 time。之前漏了这两个,
|
|
||||||
// 导致编辑档案时改到家日期不落库(建档走 toInput 才正常)
|
|
||||||
if req.Birthday != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", req.Birthday, time.Local); err == nil {
|
|
||||||
fields["birthday"] = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.ArrivedAt != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", req.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
|
|
||||||
fields["arrived_at"] = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.AvatarFileID != "" {
|
|
||||||
fields["avatar_file_id"] = req.AvatarFileID
|
|
||||||
}
|
|
||||||
pet, err := h.svc.UpdatePet(middleware.UserID(c), idParam(c, "id"), fields)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, pet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePet DELETE /api/pets/:id
|
|
||||||
func (h *Handler) DeletePet(c *gin.Context) {
|
|
||||||
if err := h.svc.DeletePet(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Onboarding POST /api/onboarding
|
|
||||||
func (h *Handler) Onboarding(c *gin.Context) {
|
|
||||||
var req petReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
in := req.toInput()
|
|
||||||
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
|
|
||||||
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, pet)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Breeds GET /api/breeds?species=cat 品种,按首字母分组。
|
|
||||||
// 建档第二步的选择器直接照这个渲染,前端不做分组也不做排序
|
|
||||||
func (h *Handler) Breeds(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.ListBreeds(c.Query("species")))
|
|
||||||
}
|
|
||||||
|
|
||||||
// LifeStages GET /api/life-stages?species=dog&size=large
|
|
||||||
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
|
|
||||||
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
|
|
||||||
func (h *Handler) LifeStages(c *gin.Context) {
|
|
||||||
species := c.Query("species")
|
|
||||||
if species == "" {
|
|
||||||
species = model.SpeciesCat
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{
|
|
||||||
"species": species,
|
|
||||||
"size": c.Query("size"),
|
|
||||||
"stages": service.StageRules(species, c.Query("size")),
|
|
||||||
"sizes": service.SizeLabels,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// PetStageDrift GET /api/pets/:id/stage-drift
|
|
||||||
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
|
|
||||||
func (h *Handler) PetStageDrift(c *gin.Context) {
|
|
||||||
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, h.svc.CheckStageDrift(pet))
|
|
||||||
}
|
|
||||||
|
|
||||||
// PetIDCard GET /api/pets/:id/id-card 宠物身份卡内容(含小程序码,未发布时无码)
|
|
||||||
func (h *Handler) PetIDCard(c *gin.Context) {
|
|
||||||
card, err := h.svc.GetIDCard(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, card)
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListTasks GET /api/pets/:id/tasks?date=YYYY-MM-DD
|
|
||||||
func (h *Handler) ListTasks(c *gin.Context) {
|
|
||||||
var date *time.Time
|
|
||||||
if q := c.Query("date"); q != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", q, time.Local); err == nil {
|
|
||||||
date = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tasks, err := h.svc.ListTasks(middleware.UserID(c), idParam(c, "id"), date)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 任务的增删改统一走 /plan-tasks 那套 —— 「今日任务」和「计划节点」合并之后,
|
|
||||||
// /tasks 这边只保留「读某天的」和「勾选」,重复的 CRUD 删掉了。
|
|
||||||
// 留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。
|
|
||||||
|
|
||||||
// ToggleTask POST /api/tasks/:id/toggle
|
|
||||||
func (h *Handler) ToggleTask(c *gin.Context) {
|
|
||||||
task, err := h.svc.ToggleTask(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, task)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CompleteAllTasks POST /api/pets/:id/tasks/complete-all
|
|
||||||
func (h *Handler) CompleteAllTasks(c *gin.Context) {
|
|
||||||
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, tasks)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPlan GET /api/pets/:id/plan
|
|
||||||
func (h *Handler) GetPlan(c *gin.Context) {
|
|
||||||
plan, err := h.svc.GetPlan(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DayPlan GET /api/pets/:id/day-plan?date=YYYY-MM-DD
|
|
||||||
func (h *Handler) DayPlan(c *gin.Context) {
|
|
||||||
res, err := h.svc.DayPlan(middleware.UserID(c), idParam(c, "id"), c.Query("date"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
|
|
||||||
func (h *Handler) PlanCalendar(c *gin.Context) {
|
|
||||||
now := time.Now()
|
|
||||||
year, month := now.Year(), int(now.Month())
|
|
||||||
if q := c.Query("month"); q != "" {
|
|
||||||
if t, err := time.Parse("2006-01", q); err == nil {
|
|
||||||
year, month = t.Year(), int(t.Month())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
res, err := h.svc.Calendar(middleware.UserID(c), idParam(c, "id"), year, month)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
|
|
||||||
type aiPlanReq struct {
|
|
||||||
Input string `json:"input"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateAIPlan POST /api/pets/:id/ai-plan
|
|
||||||
func (h *Handler) CreateAIPlan(c *gin.Context) {
|
|
||||||
var req aiPlanReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), idParam(c, "id"), req.Input)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyAIPlan POST /api/ai-plan/:id/apply
|
|
||||||
func (h *Handler) ApplyAIPlan(c *gin.Context) {
|
|
||||||
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, plan)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TogglePlanTask POST /api/plan-tasks/:id/toggle
|
|
||||||
func (h *Handler) TogglePlanTask(c *gin.Context) {
|
|
||||||
if err := h.svc.TogglePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"ok": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// planTaskReq 用户自己加/改的计划节点
|
|
||||||
type planTaskReq struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
SheetType string `json:"sheet_type"`
|
|
||||||
Daily bool `json:"daily"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r planTaskReq) input() service.PlanTaskInput {
|
|
||||||
return service.PlanTaskInput{Date: r.Date, Title: r.Title,
|
|
||||||
Description: r.Description, SheetType: r.SheetType, Daily: r.Daily}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条
|
|
||||||
func (h *Handler) AddPlanTask(c *gin.Context) {
|
|
||||||
var req planTaskReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
list, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// daily 会一次建多条,统一返回数组,前端不用分两种情况处理
|
|
||||||
response.OK(c, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdatePlanTask PUT /api/plan-tasks/:id
|
|
||||||
func (h *Handler) UpdatePlanTask(c *gin.Context) {
|
|
||||||
var req planTaskReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := h.svc.UpdatePlanTask(middleware.UserID(c), idParam(c, "id"), req.input()); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePlanTask DELETE /api/plan-tasks/:id
|
|
||||||
func (h *Handler) DeletePlanTask(c *gin.Context) {
|
|
||||||
if err := h.svc.DeletePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanMonthStatus GET /api/pets/:id/plan/month-status
|
|
||||||
// 首页「管理计划」那个提醒角标要的:本月剩几件、下月排了没有
|
|
||||||
func (h *Handler) PlanMonthStatus(c *gin.Context) {
|
|
||||||
st, err := h.svc.PlanMonthStatus(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, st)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CareTemplateOptions GET /api/care-templates?species=cat&stage=幼年期
|
|
||||||
// 建档最后一步和计划页的「套用方案」都读这个
|
|
||||||
func (h *Handler) CareTemplateOptions(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.ListTemplateOptions(c.Query("species"), c.Query("stage")))
|
|
||||||
}
|
|
||||||
|
|
||||||
type applyTemplateReq struct {
|
|
||||||
TemplateID string `json:"template_id"`
|
|
||||||
MonthOffset int `json:"month_offset"` // 0 本月,1 下月
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyTemplate POST /api/pets/:id/apply-template
|
|
||||||
func (h *Handler) ApplyTemplate(c *gin.Context) {
|
|
||||||
var req applyTemplateReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.TemplateID == "" {
|
|
||||||
response.FailParams(c, "缺少方案 id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
res, err := h.svc.ApplyTemplate(middleware.UserID(c), idParam(c, "id"), req.TemplateID, req.MonthOffset)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, res)
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type recordReq struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Icon string `json:"icon"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
NumValue float64 `json:"num_value"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
ImageURL string `json:"image_url"`
|
|
||||||
ImageFileID string `json:"image_file_id"`
|
|
||||||
Extra datatypes.JSON `json:"extra"`
|
|
||||||
OccurredAt string `json:"occurred_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRecords GET /api/pets/:id/records?type=
|
|
||||||
func (h *Handler) ListRecords(c *gin.Context) {
|
|
||||||
var pq response.PageQuery
|
|
||||||
_ = c.ShouldBindQuery(&pq)
|
|
||||||
pq.Normalize()
|
|
||||||
records, total, err := h.svc.ListRecords(middleware.UserID(c), idParam(c, "id"), c.Query("type"), pq.Offset(), pq.Limit())
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, response.NewPage(records, total, pq))
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateRecord POST /api/pets/:id/records
|
|
||||||
func (h *Handler) CreateRecord(c *gin.Context) {
|
|
||||||
var req recordReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
in := service.RecordInput{
|
|
||||||
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
|
|
||||||
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, ImageFileID: req.ImageFileID, Extra: req.Extra,
|
|
||||||
}
|
|
||||||
if req.OccurredAt != "" {
|
|
||||||
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
|
|
||||||
in.OccurredAt = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rec, err := h.svc.CreateRecord(middleware.UserID(c), idParam(c, "id"), in)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, rec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteRecord DELETE /api/records/:id
|
|
||||||
func (h *Handler) DeleteRecord(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteRecord(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeightTrend GET /api/pets/:id/records/weight-trend
|
|
||||||
func (h *Handler) WeightTrend(c *gin.Context) {
|
|
||||||
points, err := h.svc.WeightTrend(middleware.UserID(c), idParam(c, "id"), 7)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, points)
|
|
||||||
}
|
|
||||||
|
|
||||||
// PetInsights GET /api/pets/:id/insights
|
|
||||||
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
|
|
||||||
func (h *Handler) PetInsights(c *gin.Context) {
|
|
||||||
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecordTypes GET /api/record-types 记录页可记事项,按分组归好。
|
|
||||||
// 类型从表里来而不是前端写死,后台加一种不用发版
|
|
||||||
func (h *Handler) RecordTypes(c *gin.Context) {
|
|
||||||
response.OK(c, h.svc.ListRecordTypes())
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/internal/service"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
type reminderReq struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
NextDueDate string `json:"next_due_date"` // YYYY-MM-DD
|
|
||||||
Frequency string `json:"frequency"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListReminders GET /api/pets/:id/reminders
|
|
||||||
func (h *Handler) ListReminders(c *gin.Context) {
|
|
||||||
reminders, err := h.svc.ListReminders(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, reminders)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateReminder POST /api/pets/:id/reminders
|
|
||||||
func (h *Handler) CreateReminder(c *gin.Context) {
|
|
||||||
var req reminderReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
|
|
||||||
if req.NextDueDate != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
|
|
||||||
in.NextDueDate = &t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r, err := h.svc.CreateReminder(middleware.UserID(c), idParam(c, "id"), in)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateReminder PUT /api/reminders/:id
|
|
||||||
func (h *Handler) UpdateReminder(c *gin.Context) {
|
|
||||||
var req reminderReq
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
|
||||||
response.FailParams(c, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fields := map[string]any{}
|
|
||||||
if req.Type != "" {
|
|
||||||
fields["type"] = req.Type
|
|
||||||
}
|
|
||||||
if req.Title != "" {
|
|
||||||
fields["title"] = req.Title
|
|
||||||
}
|
|
||||||
if req.Frequency != "" {
|
|
||||||
fields["frequency"] = req.Frequency
|
|
||||||
}
|
|
||||||
if req.NextDueDate != "" {
|
|
||||||
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
|
|
||||||
fields["next_due_date"] = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
r, err := h.svc.UpdateReminder(middleware.UserID(c), idParam(c, "id"), fields)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteReminder DELETE /api/reminders/:id
|
|
||||||
func (h *Handler) DeleteReminder(c *gin.Context) {
|
|
||||||
if err := h.svc.DeleteReminder(middleware.UserID(c), idParam(c, "id")); err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"deleted": true})
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// WeeklyReport GET /api/pets/:id/report/weekly
|
|
||||||
func (h *Handler) WeeklyReport(c *gin.Context) {
|
|
||||||
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, rep)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bill GET /api/pets/:id/bill?period=month
|
|
||||||
func (h *Handler) Bill(c *gin.Context) {
|
|
||||||
period := c.DefaultQuery("period", "month")
|
|
||||||
bill, err := h.svc.GetBill(middleware.UserID(c), idParam(c, "id"), period)
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, bill)
|
|
||||||
}
|
|
||||||
|
|
||||||
// HealthSummary GET /api/pets/:id/health-summary
|
|
||||||
func (h *Handler) HealthSummary(c *gin.Context) {
|
|
||||||
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, sum)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poster GET /api/pets/:id/poster
|
|
||||||
func (h *Handler) Poster(c *gin.Context) {
|
|
||||||
poster, err := h.svc.GetPoster(middleware.UserID(c), idParam(c, "id"))
|
|
||||||
if err != nil {
|
|
||||||
respondErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, poster)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Upload POST /api/upload multipart/form-data,字段名 file。
|
|
||||||
// 按 MD5 去重:相同内容直接返回已有文件,不重复上传。返回 {id,url,md5}。
|
|
||||||
func (h *Handler) Upload(c *gin.Context) {
|
|
||||||
fileHeader, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
response.FailParams(c, "缺少上传文件 file")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
src, err := fileHeader.Open()
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer src.Close()
|
|
||||||
|
|
||||||
f, err := h.svc.UploadFile(src, fileHeader.Filename, fileHeader.Header.Get("Content-Type"))
|
|
||||||
if err != nil {
|
|
||||||
response.FailErr(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
response.OK(c, gin.H{"id": f.ID, "url": f.URL, "md5": f.MD5})
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"encoding/xml"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// secCallbackMsg 兼容消息推送的两种数据格式:JSON 和 XML(字段名一致,标签两套都标)
|
|
||||||
type secCallbackMsg struct {
|
|
||||||
Event string `json:"Event" xml:"Event"`
|
|
||||||
MsgType string `json:"MsgType" xml:"MsgType"`
|
|
||||||
TraceID string `json:"trace_id" xml:"trace_id"`
|
|
||||||
Result struct {
|
|
||||||
Suggest string `json:"suggest" xml:"suggest"`
|
|
||||||
} `json:"result" xml:"result"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片异步安全检测的结果回调(微信「消息推送」服务器配置指向这里)。
|
|
||||||
// GET 校验服务器:把 echostr 原样返回
|
|
||||||
// POST 接收 wxa_media_check 结果:用 trace_id 找回帖子/评论并放出或打回
|
|
||||||
//
|
|
||||||
// 明文模式(消息加解密方式选「明文」)下 body 就是 JSON,直接解析;
|
|
||||||
// 若配了加密模式还需要 AES 解密,这里暂不处理(上线时消息推送选明文即可)。
|
|
||||||
|
|
||||||
// verifyWxSignature 校验 signature = sha1(sort(token, timestamp, nonce))
|
|
||||||
func (h *Handler) verifyWxSignature(c *gin.Context) bool {
|
|
||||||
token := h.cfg.WeChat.MsgToken
|
|
||||||
if token == "" {
|
|
||||||
return true // 没配 token(本地调试):不校验
|
|
||||||
}
|
|
||||||
sig := c.Query("signature")
|
|
||||||
parts := []string{token, c.Query("timestamp"), c.Query("nonce")}
|
|
||||||
sort.Strings(parts)
|
|
||||||
sum := sha1.Sum([]byte(strings.Join(parts, "")))
|
|
||||||
return hex.EncodeToString(sum[:]) == sig
|
|
||||||
}
|
|
||||||
|
|
||||||
// WxSecCallback GET+POST /api/wx/sec-callback
|
|
||||||
func (h *Handler) WxSecCallback(c *gin.Context) {
|
|
||||||
if !h.verifyWxSignature(c) {
|
|
||||||
c.String(http.StatusForbidden, "invalid signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if c.Request.Method == http.MethodGet {
|
|
||||||
c.String(http.StatusOK, c.Query("echostr")) // 服务器配置校验
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, _ := io.ReadAll(c.Request.Body)
|
|
||||||
// 数据格式 JSON / XML 都兼容:以 '<' 开头当 XML,否则当 JSON
|
|
||||||
var msg secCallbackMsg
|
|
||||||
var err error
|
|
||||||
if trimmed := strings.TrimSpace(string(raw)); strings.HasPrefix(trimmed, "<") {
|
|
||||||
err = xml.Unmarshal(raw, &msg)
|
|
||||||
} else {
|
|
||||||
err = json.Unmarshal(raw, &msg)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("[warn] sec-callback 解析失败: %v, body=%s", err, string(raw))
|
|
||||||
c.String(http.StatusOK, "success") // 回 success 让微信别重推
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if msg.Event == "wxa_media_check" && msg.TraceID != "" {
|
|
||||||
h.svc.ResolveMediaCheck(msg.TraceID, msg.Result.Suggest)
|
|
||||||
}
|
|
||||||
c.String(http.StatusOK, "success")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) adminStats(c *gin.Context) {
|
||||||
|
counts := gin.H{}
|
||||||
|
for _, pair := range []struct {
|
||||||
|
name string
|
||||||
|
value any
|
||||||
|
}{{"users", &model.User{}}, {"pets", &model.Pet{}}, {"walks", &model.Walk{}}, {"meetups", &model.Meetup{}}} {
|
||||||
|
var n int64
|
||||||
|
a.db.Model(pair.value).Count(&n)
|
||||||
|
counts[pair.name] = n
|
||||||
|
}
|
||||||
|
ok(c, counts)
|
||||||
|
}
|
||||||
|
func (a *API) adminUsers(c *gin.Context) {
|
||||||
|
var rows []model.User
|
||||||
|
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||||
|
fail(c, 500, "读取用户失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, u := range rows {
|
||||||
|
v := a.userView(u)
|
||||||
|
v["disabled"] = u.Disabled
|
||||||
|
items = append(items, v)
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
func (a *API) adminPets(c *gin.Context) {
|
||||||
|
var rows []model.Pet
|
||||||
|
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||||
|
fail(c, 500, "读取宠物失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, p := range rows {
|
||||||
|
v := a.petView(p)
|
||||||
|
v["user_id"] = p.UserID
|
||||||
|
v["archived"] = p.Archived
|
||||||
|
items = append(items, v)
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
func (a *API) adminWalks(c *gin.Context) {
|
||||||
|
var rows []model.Walk
|
||||||
|
if a.db.Order("started_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||||
|
fail(c, 500, "读取遛狗失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, rows)
|
||||||
|
}
|
||||||
|
func (a *API) adminMeetups(c *gin.Context) {
|
||||||
|
var rows []model.Meetup
|
||||||
|
if a.db.Order("created_at DESC").Limit(200).Find(&rows).Error != nil {
|
||||||
|
fail(c, 500, "读取约遛失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, m := range rows {
|
||||||
|
items = append(items, a.meetupView(m))
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
func (a *API) adminSetUserDisabled(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, 400, "参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := a.db.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", in.Disabled)
|
||||||
|
if r.Error != nil || r.RowsAffected == 0 {
|
||||||
|
fail(c, 404, "用户不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"disabled": in.Disabled})
|
||||||
|
}
|
||||||
|
func (a *API) adminSetMeetupStatus(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || (in.Status != "open" && in.Status != "cancelled") {
|
||||||
|
fail(c, http.StatusBadRequest, "状态无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r := a.db.Model(&model.Meetup{}).Where("id = ?", c.Param("id")).Update("status", in.Status)
|
||||||
|
if r.Error != nil || r.RowsAffected == 0 {
|
||||||
|
fail(c, 404, "约遛不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"status": in.Status})
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) SeedAdmin() error {
|
||||||
|
if a.cfg.Admin.Password == "" {
|
||||||
|
if a.cfg.Server.Mode == "release" {
|
||||||
|
return errors.New("admin password is required in release mode")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var admin model.Admin
|
||||||
|
err := a.db.Where("username = ?", a.cfg.Admin.Username).First(&admin).Error
|
||||||
|
missing := errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
if err != nil && !missing {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !missing && bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(a.cfg.Admin.Password)) == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(a.cfg.Admin.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if missing {
|
||||||
|
return a.db.Create(&model.Admin{Username: a.cfg.Admin.Username, PasswordHash: string(hash)}).Error
|
||||||
|
}
|
||||||
|
return a.db.Model(&admin).Update("password_hash", string(hash)).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) adminLogin(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "请输入用户名和密码")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var admin model.Admin
|
||||||
|
if err := a.db.Where("username = ?", in.Username).First(&admin).Error; err != nil || bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(in.Password)) != nil {
|
||||||
|
fail(c, http.StatusUnauthorized, "用户名或密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := a.token(admin.ID, "admin")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"token": token, "admin": gin.H{"id": admin.ID, "username": admin.Username}})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) devLogin(c *gin.Context) {
|
||||||
|
if !a.cfg.Auth.DevLogin || a.cfg.Server.Mode == "release" {
|
||||||
|
fail(c, http.StatusNotFound, "接口不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Key string `json:"key" binding:"required"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || len(in.Key) > 64 {
|
||||||
|
fail(c, http.StatusBadRequest, "开发登录参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(in.Nickname)
|
||||||
|
if name == "" {
|
||||||
|
name = "柯基家长"
|
||||||
|
}
|
||||||
|
user, err := a.findOrCreateUser("dev:"+in.Key, name)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.loginUser(c, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) login(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
Code string `json:"code" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&in); err != nil || a.cfg.WeChat.AppID == "" || a.cfg.WeChat.AppSecret == "" {
|
||||||
|
fail(c, http.StatusBadRequest, "微信登录配置或参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + url.Values{
|
||||||
|
"appid": {a.cfg.WeChat.AppID}, "secret": {a.cfg.WeChat.AppSecret},
|
||||||
|
"js_code": {in.Code}, "grant_type": {"authorization_code"},
|
||||||
|
}.Encode()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "微信登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadGateway, "微信登录暂不可用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var wx struct {
|
||||||
|
OpenID string `json:"openid"`
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK || json.NewDecoder(resp.Body).Decode(&wx) != nil || wx.ErrCode != 0 || wx.OpenID == "" {
|
||||||
|
fail(c, http.StatusUnauthorized, "微信登录凭证无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := a.findOrCreateUser(wx.OpenID, "柯基家长")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.loginUser(c, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) findOrCreateUser(openID, nickname string) (model.User, error) {
|
||||||
|
var user model.User
|
||||||
|
err := a.db.Where("open_id = ?", openID).First(&user).Error
|
||||||
|
if err == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
user = model.User{OpenID: openID, Nickname: nickname}
|
||||||
|
if err := a.db.Create(&user).Error; err != nil {
|
||||||
|
// A concurrent login may have inserted the same open_id.
|
||||||
|
if lookup := a.db.Where("open_id = ?", openID).First(&user).Error; lookup == nil {
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
return user, fmt.Errorf("create user: %w", err)
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) loginUser(c *gin.Context, user model.User) {
|
||||||
|
if user.Disabled {
|
||||||
|
fail(c, http.StatusForbidden, "账号已停用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, err := a.token(user.ID, "user")
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "签发令牌失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"token": token, "user": a.userView(user)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) me(c *gin.Context) {
|
||||||
|
var user model.User
|
||||||
|
if err := a.db.First(&user, "id = ?", actor(c)).Error; err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "用户不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.userView(user))
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) fileExists(id string) bool {
|
||||||
|
if !validID(id) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
a.db.Model(&model.File{}).Where("id = ?", id).Count(&count)
|
||||||
|
return count == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxUploadSize = 10 << 20
|
||||||
|
|
||||||
|
func (a *API) uploadFile(c *gin.Context) {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+1024)
|
||||||
|
header, err := c.FormFile("file")
|
||||||
|
if err != nil || header.Size <= 0 || header.Size > maxUploadSize {
|
||||||
|
fail(c, http.StatusBadRequest, "请选择不超过 10 MB 的图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader, err := header.Open()
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "读取图片失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
signature := make([]byte, 512)
|
||||||
|
n, _ := io.ReadFull(reader, signature)
|
||||||
|
contentType := http.DetectContentType(signature[:n])
|
||||||
|
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
|
||||||
|
ext, allowed := extensions[contentType]
|
||||||
|
if !allowed {
|
||||||
|
fail(c, http.StatusBadRequest, "仅支持 JPG、PNG、WebP 图片")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "读取图片失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hasher := md5.New()
|
||||||
|
if _, err := io.Copy(hasher, reader); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "读取图片失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := reader.Seek(0, io.SeekStart); err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "读取图片失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
digest := hex.EncodeToString(hasher.Sum(nil))
|
||||||
|
var existing model.File
|
||||||
|
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil {
|
||||||
|
if existing.Size != header.Size {
|
||||||
|
fail(c, http.StatusConflict, "文件校验冲突")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.fileView(existing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := "files/" + digest[:2] + "/" + digest + ext
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := a.storage.Put(ctx, key, reader, header.Size, contentType); err != nil {
|
||||||
|
fail(c, http.StatusBadGateway, "上传对象存储失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file := model.File{
|
||||||
|
Bucket: a.storage.Bucket(), ObjectKey: key, MD5: digest, URL: a.storage.PublicURL(key),
|
||||||
|
Name: strings.TrimSpace(header.Filename), ContentType: contentType,
|
||||||
|
Size: header.Size,
|
||||||
|
}
|
||||||
|
if err := a.db.Create(&file).Error; err != nil {
|
||||||
|
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil && existing.Size == header.Size {
|
||||||
|
ok(c, a.fileView(existing))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(c, http.StatusInternalServerError, "保存文件信息失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created(c, a.fileView(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) getFile(c *gin.Context) {
|
||||||
|
if !validID(c.Param("id")) {
|
||||||
|
fail(c, http.StatusBadRequest, "文件 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var file model.File
|
||||||
|
if err := a.db.Where("id = ?", c.Param("id")).First(&file).Error; err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "文件不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.fileView(file))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) fileView(file model.File) gin.H {
|
||||||
|
return gin.H{"id": file.ID, "name": file.Name, "content_type": file.ContentType,
|
||||||
|
"size": file.Size, "md5": file.MD5, "url": file.URL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) fileURL(id *string) string {
|
||||||
|
if id == nil || !validID(*id) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var file model.File
|
||||||
|
if a.db.Select("url").First(&file, "id = ?", *id).Error != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return file.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) userView(user model.User) gin.H {
|
||||||
|
return gin.H{"id": user.ID, "nickname": user.Nickname, "avatar_file_id": user.AvatarFileID,
|
||||||
|
"avatar_url": a.fileURL(user.AvatarFileID), "created_at": user.CreatedAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) petView(pet model.Pet) gin.H {
|
||||||
|
var birthDate string
|
||||||
|
if pet.BirthDate != nil {
|
||||||
|
birthDate = pet.BirthDate.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed,
|
||||||
|
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
|
||||||
|
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
|
||||||
|
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
|
||||||
|
"created_at": pet.CreatedAt}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/pets-be/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoginRoute(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
New(nil, nil, &config.Config{}).Register(router)
|
||||||
|
for _, route := range router.Routes() {
|
||||||
|
if !strings.HasPrefix(route.Path, "/api/") {
|
||||||
|
t.Errorf("API route %q does not have /api/ prefix", route.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{path: "/api/auth/login", want: http.StatusBadRequest},
|
||||||
|
{path: "/api/auth/wechat", want: http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(`{}`))
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != test.want {
|
||||||
|
t.Errorf("POST %s: got %d, want %d", test.path, recorder.Code, test.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) meetupView(m model.Meetup) gin.H {
|
||||||
|
var count int64
|
||||||
|
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
|
||||||
|
return gin.H{"id": m.ID, "host_user_id": m.HostUserID, "title": m.Title, "description": m.Description, "place_name": m.PlaceName, "latitude": m.Latitude, "longitude": m.Longitude, "starts_at": m.StartsAt, "max_people": m.MaxPeople, "status": m.Status, "participants": count, "cover_file_id": m.CoverFileID, "cover_url": a.fileURL(m.CoverFileID)}
|
||||||
|
}
|
||||||
|
func (a *API) listMeetups(c *gin.Context) {
|
||||||
|
var rows []model.Meetup
|
||||||
|
if a.db.Where("starts_at > ? AND status = ?", time.Now().Add(-time.Hour), "open").Order("starts_at ASC").Limit(100).Find(&rows).Error != nil {
|
||||||
|
fail(c, 500, "读取约遛失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(rows))
|
||||||
|
for _, m := range rows {
|
||||||
|
items = append(items, a.meetupView(m))
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
func (a *API) getMeetup(c *gin.Context) {
|
||||||
|
var m model.Meetup
|
||||||
|
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
|
||||||
|
fail(c, 404, "约遛不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := a.meetupView(m)
|
||||||
|
var joins []model.MeetupParticipant
|
||||||
|
a.db.Where("meetup_id = ?", m.ID).Find(&joins)
|
||||||
|
people := make([]gin.H, 0, len(joins))
|
||||||
|
for _, j := range joins {
|
||||||
|
var pets []model.MeetupPet
|
||||||
|
a.db.Where("participant_id = ?", j.ID).Find(&pets)
|
||||||
|
ids := []string{}
|
||||||
|
for _, p := range pets {
|
||||||
|
ids = append(ids, p.PetID)
|
||||||
|
}
|
||||||
|
people = append(people, gin.H{"user_id": j.UserID, "pet_ids": ids})
|
||||||
|
}
|
||||||
|
view["people"] = people
|
||||||
|
ok(c, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
type meetupInput struct {
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PlaceName string `json:"place_name" binding:"required"`
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
StartsAt time.Time `json:"starts_at" binding:"required"`
|
||||||
|
MaxPeople int `json:"max_people"`
|
||||||
|
PetIDs []string `json:"pet_ids" binding:"required"`
|
||||||
|
CoverFileID *string `json:"cover_file_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createMeetup(c *gin.Context) {
|
||||||
|
var in meetupInput
|
||||||
|
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.PlaceName) == "" || !in.StartsAt.After(time.Now()) || len(in.PetIDs) == 0 {
|
||||||
|
fail(c, 400, "约遛资料无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.MaxPeople == 0 {
|
||||||
|
in.MaxPeople = 8
|
||||||
|
}
|
||||||
|
if in.MaxPeople < 2 || in.MaxPeople > 100 {
|
||||||
|
fail(c, 400, "人数应为 2 到 100")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if _, err := a.ownedPet(actor(c), id); err != nil {
|
||||||
|
fail(c, 400, "宠物无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.CoverFileID != nil && !a.fileExists(*in.CoverFileID) {
|
||||||
|
fail(c, 400, "封面文件无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m := model.Meetup{HostUserID: actor(c), Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), PlaceName: strings.TrimSpace(in.PlaceName), Latitude: in.Latitude, Longitude: in.Longitude, StartsAt: in.StartsAt, MaxPeople: in.MaxPeople, Status: "open", CoverFileID: in.CoverFileID}
|
||||||
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&m).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
|
||||||
|
if e := tx.Create(&j).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 500, "发布失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created(c, a.meetupView(m))
|
||||||
|
}
|
||||||
|
func (a *API) joinMeetup(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
PetIDs []string `json:"pet_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
|
||||||
|
fail(c, 400, "请选择宠物")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if _, err := a.ownedPet(actor(c), id); err != nil {
|
||||||
|
fail(c, 400, "宠物无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var m model.Meetup
|
||||||
|
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
|
||||||
|
fail(c, 404, "约遛不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m.Status != "open" || !m.StartsAt.After(time.Now()) {
|
||||||
|
fail(c, 409, "约遛已关闭")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
|
||||||
|
if count >= int64(m.MaxPeople) {
|
||||||
|
fail(c, 409, "人数已满")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var existing int64
|
||||||
|
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ? AND user_id = ?", m.ID, actor(c)).Count(&existing)
|
||||||
|
if existing > 0 {
|
||||||
|
fail(c, 409, "已经参加")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
|
||||||
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&j).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail(c, 500, "报名失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.meetupView(m))
|
||||||
|
}
|
||||||
|
func (a *API) leaveMeetup(c *gin.Context) {
|
||||||
|
var j model.MeetupParticipant
|
||||||
|
if !validID(c.Param("id")) || a.db.Where("meetup_id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&j).Error != nil {
|
||||||
|
fail(c, 404, "未参加")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var m model.Meetup
|
||||||
|
a.db.First(&m, "id = ?", j.MeetupID)
|
||||||
|
if m.HostUserID == actor(c) {
|
||||||
|
fail(c, http.StatusConflict, "发起人请取消约遛")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Where("participant_id = ?", j.ID).Delete(&model.MeetupPet{}).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return tx.Delete(&j).Error
|
||||||
|
}) != nil {
|
||||||
|
fail(c, 500, "退出失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"left": true})
|
||||||
|
}
|
||||||
|
func (a *API) cancelMeetup(c *gin.Context) {
|
||||||
|
if !validID(c.Param("id")) {
|
||||||
|
fail(c, 400, "ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := a.db.Model(&model.Meetup{}).Where("id = ? AND host_user_id = ? AND status = ?", c.Param("id"), actor(c), "open").Update("status", "cancelled")
|
||||||
|
if result.Error != nil || result.RowsAffected == 0 {
|
||||||
|
fail(c, 404, "约遛不存在或已关闭")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"status": "cancelled"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type petInput struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Species string `json:"species"`
|
||||||
|
Breed string `json:"breed"`
|
||||||
|
Gender string `json:"gender"`
|
||||||
|
BirthDate string `json:"birth_date"`
|
||||||
|
WeightKg float64 `json:"weight_kg"`
|
||||||
|
AvatarFileID *string `json:"avatar_file_id"`
|
||||||
|
Coat string `json:"coat"`
|
||||||
|
Allergies string `json:"allergies"`
|
||||||
|
CareNotes string `json:"care_notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) listPets(c *gin.Context) {
|
||||||
|
var pets []model.Pet
|
||||||
|
if err := a.db.Where("user_id = ? AND archived = false", actor(c)).Order("created_at ASC").Find(&pets).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "读取宠物失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(pets))
|
||||||
|
for _, pet := range pets {
|
||||||
|
items = append(items, a.petView(pet))
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) getPet(c *gin.Context) {
|
||||||
|
pet, err := a.ownedPet(actor(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.petView(pet))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createPet(c *gin.Context) {
|
||||||
|
var in petInput
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "宠物资料无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pet, valid := a.buildPet(c, in)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pet.UserID = actor(c)
|
||||||
|
if err := a.db.Create(&pet).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "创建宠物失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created(c, a.petView(pet))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) updatePet(c *gin.Context) {
|
||||||
|
pet, err := a.ownedPet(actor(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in petInput
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "宠物资料无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, valid := a.buildPet(c, in)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pet.Name, pet.Species, pet.Breed, pet.Gender = updated.Name, updated.Species, updated.Breed, updated.Gender
|
||||||
|
pet.BirthDate, pet.WeightKg, pet.AvatarFileID = updated.BirthDate, updated.WeightKg, updated.AvatarFileID
|
||||||
|
pet.Coat, pet.Allergies, pet.CareNotes = updated.Coat, updated.Allergies, updated.CareNotes
|
||||||
|
if err := a.db.Save(&pet).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "更新宠物失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.petView(pet))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) buildPet(c *gin.Context, in petInput) (model.Pet, bool) {
|
||||||
|
pet := model.Pet{}
|
||||||
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
if in.Name == "" || len([]rune(in.Name)) > 64 || in.WeightKg < 0 || in.WeightKg > 200 {
|
||||||
|
fail(c, http.StatusBadRequest, "名字或体重无效")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
if in.Species == "" {
|
||||||
|
in.Species = "dog"
|
||||||
|
}
|
||||||
|
if in.Species != "dog" && in.Species != "cat" && in.Species != "other" {
|
||||||
|
fail(c, http.StatusBadRequest, "宠物类型无效")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
if in.AvatarFileID != nil {
|
||||||
|
var count int64
|
||||||
|
if !validID(*in.AvatarFileID) || a.db.Model(&model.File{}).Where("id = ?", *in.AvatarFileID).Count(&count).Error != nil || count == 0 {
|
||||||
|
fail(c, http.StatusBadRequest, "头像文件无效")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var birthday *time.Time
|
||||||
|
if in.BirthDate != "" {
|
||||||
|
d, err := parseDay(in.BirthDate)
|
||||||
|
if err != nil || d.After(time.Now()) {
|
||||||
|
fail(c, http.StatusBadRequest, "生日无效")
|
||||||
|
return pet, false
|
||||||
|
}
|
||||||
|
birthday = &d
|
||||||
|
}
|
||||||
|
pet = model.Pet{Name: in.Name, Species: in.Species, Breed: strings.TrimSpace(in.Breed),
|
||||||
|
Gender: in.Gender, BirthDate: birthday, WeightKg: in.WeightKg,
|
||||||
|
AvatarFileID: in.AvatarFileID, Coat: in.Coat, Allergies: in.Allergies, CareNotes: in.CareNotes}
|
||||||
|
return pet, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) archivePet(c *gin.Context) {
|
||||||
|
pet, err := a.ownedPet(actor(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var active int64
|
||||||
|
if err := a.db.Table("sundynix_walk_pet AS wp").Joins("JOIN sundynix_walk AS w ON w.id = wp.walk_id").
|
||||||
|
Where("wp.pet_id = ? AND w.status IN ?", pet.ID, []string{"active", "paused"}).Count(&active).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "检查遛狗状态失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if active > 0 {
|
||||||
|
fail(c, http.StatusConflict, "这只宠物正在遛狗,结束后再归档")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.db.Model(&model.Pet{}).Where("id = ? AND user_id = ?", pet.ID, actor(c)).Update("archived", true).Error; err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
fail(c, http.StatusInternalServerError, "归档失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"archived": true})
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/config"
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
"github.com/sundynix/pets-be/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct {
|
||||||
|
db *gorm.DB
|
||||||
|
storage *storage.Storage
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *gorm.DB, storage *storage.Storage, cfg *config.Config) *API {
|
||||||
|
return &API{db: db, storage: storage, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) Register(r *gin.Engine) {
|
||||||
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
|
_ = r.SetTrustedProxies(nil)
|
||||||
|
api := r.Group("/api")
|
||||||
|
api.GET("/ping", func(c *gin.Context) { ok(c, gin.H{"pong": true}) })
|
||||||
|
api.GET("/share/walk/:token", a.publicWalkLocation)
|
||||||
|
api.POST("/auth/login", a.login)
|
||||||
|
api.POST("/auth/dev", a.devLogin)
|
||||||
|
api.POST("/admin/login", a.adminLogin)
|
||||||
|
|
||||||
|
user := api.Group("")
|
||||||
|
user.Use(a.requireRole("user"))
|
||||||
|
user.GET("/me", a.me)
|
||||||
|
user.GET("/pets", a.listPets)
|
||||||
|
user.POST("/pets", a.createPet)
|
||||||
|
user.GET("/pets/:id", a.getPet)
|
||||||
|
user.PUT("/pets/:id", a.updatePet)
|
||||||
|
user.DELETE("/pets/:id", a.archivePet)
|
||||||
|
user.POST("/files", a.uploadFile)
|
||||||
|
user.GET("/files/:id", a.getFile)
|
||||||
|
user.GET("/tasks", a.listTasks)
|
||||||
|
user.POST("/tasks", a.createTask)
|
||||||
|
user.PUT("/tasks/:id", a.updateTask)
|
||||||
|
user.DELETE("/tasks/:id", a.deleteTask)
|
||||||
|
user.PUT("/tasks/:id/completion", a.setTaskCompletion)
|
||||||
|
user.GET("/walks", a.listWalks)
|
||||||
|
user.POST("/walks", a.startWalk)
|
||||||
|
user.GET("/walks/:id", a.getWalk)
|
||||||
|
user.POST("/walks/:id/points", a.addWalkPoint)
|
||||||
|
user.POST("/walks/:id/pause", a.pauseWalk)
|
||||||
|
user.POST("/walks/:id/resume", a.resumeWalk)
|
||||||
|
user.POST("/walks/:id/finish", a.finishWalk)
|
||||||
|
user.PUT("/walks/:id/share", a.setWalkShare)
|
||||||
|
user.GET("/meetups", a.listMeetups)
|
||||||
|
user.POST("/meetups", a.createMeetup)
|
||||||
|
user.GET("/meetups/:id", a.getMeetup)
|
||||||
|
user.POST("/meetups/:id/join", a.joinMeetup)
|
||||||
|
user.DELETE("/meetups/:id/join", a.leaveMeetup)
|
||||||
|
user.POST("/meetups/:id/cancel", a.cancelMeetup)
|
||||||
|
|
||||||
|
admin := api.Group("/admin")
|
||||||
|
admin.Use(a.requireRole("admin"))
|
||||||
|
admin.GET("/stats", a.adminStats)
|
||||||
|
admin.GET("/users", a.adminUsers)
|
||||||
|
admin.GET("/pets", a.adminPets)
|
||||||
|
admin.GET("/walks", a.adminWalks)
|
||||||
|
admin.GET("/meetups", a.adminMeetups)
|
||||||
|
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
|
||||||
|
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
type claims struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) token(id, role string) (string, error) {
|
||||||
|
expire := a.cfg.JWT.ExpireHours
|
||||||
|
if expire <= 0 {
|
||||||
|
expire = 168
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(expire) * time.Hour)),
|
||||||
|
}})
|
||||||
|
return t.SignedString([]byte(a.cfg.JWT.Secret))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) requireRole(role string) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
header := c.GetHeader("Authorization")
|
||||||
|
if !strings.HasPrefix(header, "Bearer ") {
|
||||||
|
fail(c, http.StatusUnauthorized, "请先登录")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := jwt.ParseWithClaims(strings.TrimPrefix(header, "Bearer "), &claims{}, func(t *jwt.Token) (any, error) {
|
||||||
|
if t.Method != jwt.SigningMethodHS256 {
|
||||||
|
return nil, errors.New("unexpected signing method")
|
||||||
|
}
|
||||||
|
return []byte(a.cfg.JWT.Secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !t.Valid {
|
||||||
|
fail(c, http.StatusUnauthorized, "登录已过期")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cl, valid := t.Claims.(*claims)
|
||||||
|
if !valid || cl.Role != role || uuid.Validate(cl.Subject) != nil {
|
||||||
|
fail(c, http.StatusForbidden, "没有访问权限")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if role == "user" {
|
||||||
|
var user model.User
|
||||||
|
if err := a.db.Select("id", "disabled").First(&user, "id = ?", cl.Subject).Error; err != nil || user.Disabled {
|
||||||
|
fail(c, http.StatusForbidden, "账号不可用")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Set("actor_id", cl.Subject)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func actor(c *gin.Context) string { return c.GetString("actor_id") }
|
||||||
|
|
||||||
|
func ok(c *gin.Context, data any) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "ok", "data": data})
|
||||||
|
}
|
||||||
|
func created(c *gin.Context, data any) {
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"code": 0, "message": "ok", "data": data})
|
||||||
|
}
|
||||||
|
func fail(c *gin.Context, status int, message string) {
|
||||||
|
c.JSON(status, gin.H{"code": status, "message": message})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validID(id string) bool { return uuid.Validate(id) == nil }
|
||||||
|
|
||||||
|
func parseDay(value string) (time.Time, error) {
|
||||||
|
return time.ParseInLocation("2006-01-02", value, time.Local)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) ownedPet(userID, petID string) (model.Pet, error) {
|
||||||
|
var pet model.Pet
|
||||||
|
if !validID(petID) {
|
||||||
|
return pet, gorm.ErrRecordNotFound
|
||||||
|
}
|
||||||
|
err := a.db.Where("id = ? AND user_id = ? AND archived = false", petID, userID).First(&pet).Error
|
||||||
|
return pet, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clockPattern = regexp.MustCompile(`^([01][0-9]|2[0-3]):[0-5][0-9]$`)
|
||||||
|
|
||||||
|
type taskInput struct {
|
||||||
|
PetID string `json:"pet_id" binding:"required"`
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
TimeOfDay string `json:"time_of_day"`
|
||||||
|
RepeatRule string `json:"repeat_rule"`
|
||||||
|
ReminderEnabled bool `json:"reminder_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func due(task model.CareTask, date time.Time) bool {
|
||||||
|
switch task.RepeatRule {
|
||||||
|
case "daily":
|
||||||
|
return true
|
||||||
|
case "weekdays":
|
||||||
|
return date.Weekday() != time.Saturday && date.Weekday() != time.Sunday
|
||||||
|
case "weekends":
|
||||||
|
return date.Weekday() == time.Saturday || date.Weekday() == time.Sunday
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) listTasks(c *gin.Context) {
|
||||||
|
dateString := c.DefaultQuery("date", time.Now().Format("2006-01-02"))
|
||||||
|
date, err := parseDay(dateString)
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query := a.db.Where("user_id = ?", actor(c))
|
||||||
|
if petID := c.Query("pet_id"); petID != "" {
|
||||||
|
if _, err := a.ownedPet(actor(c), petID); err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
query = query.Where("pet_id = ?", petID)
|
||||||
|
}
|
||||||
|
var tasks []model.CareTask
|
||||||
|
if err := query.Order("time_of_day ASC").Find(&tasks).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "读取任务失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var completions []model.TaskCompletion
|
||||||
|
if err := a.db.Where("user_id = ? AND date = ?", actor(c), date).Find(&completions).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "读取完成状态失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done := make(map[string]bool, len(completions))
|
||||||
|
for _, item := range completions {
|
||||||
|
done[item.TaskID] = true
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(tasks))
|
||||||
|
for _, task := range tasks {
|
||||||
|
if due(task, date) {
|
||||||
|
items = append(items, taskView(task, done[task.ID]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"date": dateString, "items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
func taskView(task model.CareTask, completed bool) gin.H {
|
||||||
|
return gin.H{"id": task.ID, "pet_id": task.PetID, "title": task.Title,
|
||||||
|
"category": task.Category, "time_of_day": task.TimeOfDay,
|
||||||
|
"repeat_rule": task.RepeatRule, "reminder_enabled": task.ReminderEnabled,
|
||||||
|
"completed": completed}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) buildTask(c *gin.Context, in taskInput) (model.CareTask, bool) {
|
||||||
|
task := model.CareTask{}
|
||||||
|
if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "宠物不存在")
|
||||||
|
return task, false
|
||||||
|
}
|
||||||
|
in.Title = strings.TrimSpace(in.Title)
|
||||||
|
if in.Title == "" || len([]rune(in.Title)) > 128 {
|
||||||
|
fail(c, http.StatusBadRequest, "任务名称无效")
|
||||||
|
return task, false
|
||||||
|
}
|
||||||
|
if in.Category == "" {
|
||||||
|
in.Category = "other"
|
||||||
|
}
|
||||||
|
if in.TimeOfDay == "" {
|
||||||
|
in.TimeOfDay = "09:00"
|
||||||
|
}
|
||||||
|
if !clockPattern.MatchString(in.TimeOfDay) {
|
||||||
|
fail(c, http.StatusBadRequest, "时间格式应为 HH:MM")
|
||||||
|
return task, false
|
||||||
|
}
|
||||||
|
if in.RepeatRule == "" {
|
||||||
|
in.RepeatRule = "daily"
|
||||||
|
}
|
||||||
|
if in.RepeatRule != "daily" && in.RepeatRule != "weekdays" && in.RepeatRule != "weekends" {
|
||||||
|
fail(c, http.StatusBadRequest, "重复规则无效")
|
||||||
|
return task, false
|
||||||
|
}
|
||||||
|
task = model.CareTask{UserID: actor(c), PetID: in.PetID, Title: in.Title,
|
||||||
|
Category: in.Category, TimeOfDay: in.TimeOfDay, RepeatRule: in.RepeatRule,
|
||||||
|
ReminderEnabled: in.ReminderEnabled}
|
||||||
|
return task, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) createTask(c *gin.Context) {
|
||||||
|
var in taskInput
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "任务资料无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task, valid := a.buildTask(c, in)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.db.Create(&task).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "添加任务失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created(c, taskView(task, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) updateTask(c *gin.Context) {
|
||||||
|
var task model.CareTask
|
||||||
|
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||||
|
fail(c, http.StatusNotFound, "任务不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in taskInput
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "任务资料无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updated, valid := a.buildTask(c, in)
|
||||||
|
if !valid {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task.PetID, task.Title, task.Category = updated.PetID, updated.Title, updated.Category
|
||||||
|
task.TimeOfDay, task.RepeatRule, task.ReminderEnabled = updated.TimeOfDay, updated.RepeatRule, updated.ReminderEnabled
|
||||||
|
if a.db.Save(&task).Error != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "更新任务失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, taskView(task, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) deleteTask(c *gin.Context) {
|
||||||
|
if !validID(c.Param("id")) {
|
||||||
|
fail(c, http.StatusBadRequest, "任务 ID 无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var task model.CareTask
|
||||||
|
if err := tx.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Where("task_id = ?", task.ID).Delete(&model.TaskCompletion{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Delete(&task).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fail(c, http.StatusNotFound, "任务不存在或删除失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"deleted": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) setTaskCompletion(c *gin.Context) {
|
||||||
|
var task model.CareTask
|
||||||
|
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&task).Error != nil {
|
||||||
|
fail(c, http.StatusNotFound, "任务不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Date string `json:"date" binding:"required"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, http.StatusBadRequest, "完成状态参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
date, err := parseDay(in.Date)
|
||||||
|
if err != nil || !due(task, date) {
|
||||||
|
fail(c, http.StatusBadRequest, "该日期没有这项任务")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if in.Completed {
|
||||||
|
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
|
||||||
|
if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).FirstOrCreate(&item).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "保存完成状态失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if err := a.db.Where("task_id = ? AND date = ?", task.ID, date).Delete(&model.TaskCompletion{}).Error; err != nil {
|
||||||
|
fail(c, http.StatusInternalServerError, "取消完成失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": in.Completed})
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/sundynix/pets-be/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *API) walk(c *gin.Context) (model.Walk, bool) {
|
||||||
|
var w model.Walk
|
||||||
|
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&w).Error != nil {
|
||||||
|
fail(c, 404, "遛狗记录不存在")
|
||||||
|
return w, false
|
||||||
|
}
|
||||||
|
return w, true
|
||||||
|
}
|
||||||
|
func (a *API) walkView(w model.Walk) gin.H {
|
||||||
|
var links []model.WalkPet
|
||||||
|
var points []model.WalkPoint
|
||||||
|
a.db.Where("walk_id = ?", w.ID).Find(&links)
|
||||||
|
a.db.Where("walk_id = ?", w.ID).Order("recorded_at ASC").Find(&points)
|
||||||
|
ids := make([]string, 0, len(links))
|
||||||
|
for _, p := range links {
|
||||||
|
ids = append(ids, p.PetID)
|
||||||
|
}
|
||||||
|
return gin.H{"id": w.ID, "pet_ids": ids, "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "paused_at": w.PausedAt, "paused_seconds": w.PausedSeconds, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled, "share_token": func() string {
|
||||||
|
if w.ShareEnabled {
|
||||||
|
return w.ShareToken
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}(), "share_expires_at": w.ShareExpiresAt, "points": points}
|
||||||
|
}
|
||||||
|
func (a *API) listWalks(c *gin.Context) {
|
||||||
|
var walks []model.Walk
|
||||||
|
if a.db.Where("user_id = ?", actor(c)).Order("started_at DESC").Limit(50).Find(&walks).Error != nil {
|
||||||
|
fail(c, 500, "读取记录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items := make([]gin.H, 0, len(walks))
|
||||||
|
for _, w := range walks {
|
||||||
|
items = append(items, a.walkView(w))
|
||||||
|
}
|
||||||
|
ok(c, items)
|
||||||
|
}
|
||||||
|
func (a *API) getWalk(c *gin.Context) {
|
||||||
|
if w, yes := a.walk(c); yes {
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (a *API) startWalk(c *gin.Context) {
|
||||||
|
var in struct {
|
||||||
|
PetIDs []string `json:"pet_ids" binding:"required"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
|
||||||
|
fail(c, 400, "请至少选择一只宠物")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if seen[id] {
|
||||||
|
fail(c, 400, "宠物重复")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
if _, err := a.ownedPet(actor(c), id); err != nil {
|
||||||
|
fail(c, 400, "选择的宠物不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var active int64
|
||||||
|
a.db.Model(&model.Walk{}).Where("user_id = ? AND status IN ?", actor(c), []string{"active", "paused"}).Count(&active)
|
||||||
|
if active > 0 {
|
||||||
|
fail(c, 409, "已有正在进行的遛狗")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
w := model.Walk{UserID: actor(c), Status: "active", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
|
||||||
|
if err := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&w).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range in.PetIDs {
|
||||||
|
if err := tx.Create(&model.WalkPet{WalkID: w.ID, PetID: id}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
fail(c, 500, "开始遛狗失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
created(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func (a *API) addWalkPoint(c *gin.Context) {
|
||||||
|
w, yes := a.walk(c)
|
||||||
|
if !yes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Status != "active" {
|
||||||
|
fail(c, 409, "遛狗未进行中")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Latitude float64 `json:"latitude"`
|
||||||
|
Longitude float64 `json:"longitude"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&in) != nil || math.Abs(in.Latitude) > 90 || math.Abs(in.Longitude) > 180 || (in.Latitude == 0 && in.Longitude == 0) {
|
||||||
|
fail(c, 400, "位置无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
point := model.WalkPoint{WalkID: w.ID, Latitude: in.Latitude, Longitude: in.Longitude, RecordedAt: time.Now()}
|
||||||
|
var last model.WalkPoint
|
||||||
|
err := a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&last).Error
|
||||||
|
if err == nil && !last.RecordedAt.Before(w.SegmentStartedAt) {
|
||||||
|
d := haversine(last.Latitude, last.Longitude, in.Latitude, in.Longitude)
|
||||||
|
if d > 1000 {
|
||||||
|
fail(c, 400, "位置跳变过大")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d < 5 {
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.DistanceMeters += d
|
||||||
|
}
|
||||||
|
if txErr := a.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if e := tx.Create(&point).Error; e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return tx.Model(&w).Update("distance_meters", w.DistanceMeters).Error
|
||||||
|
}); txErr != nil {
|
||||||
|
fail(c, 500, "保存位置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func haversine(a, b, c, d float64) float64 {
|
||||||
|
const r = 6371000.
|
||||||
|
x1, x2 := a*math.Pi/180, c*math.Pi/180
|
||||||
|
dx, dy := (c-a)*math.Pi/180, (d-b)*math.Pi/180
|
||||||
|
h := math.Pow(math.Sin(dx/2), 2) + math.Cos(x1)*math.Cos(x2)*math.Pow(math.Sin(dy/2), 2)
|
||||||
|
return 2 * r * math.Asin(math.Sqrt(h))
|
||||||
|
}
|
||||||
|
func (a *API) pauseWalk(c *gin.Context) {
|
||||||
|
w, yes := a.walk(c)
|
||||||
|
if !yes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Status != "active" {
|
||||||
|
fail(c, 409, "当前不能暂停")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
w.Status = "paused"
|
||||||
|
w.PausedAt = &now
|
||||||
|
if a.db.Save(&w).Error != nil {
|
||||||
|
fail(c, 500, "暂停失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func (a *API) resumeWalk(c *gin.Context) {
|
||||||
|
w, yes := a.walk(c)
|
||||||
|
if !yes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Status != "paused" || w.PausedAt == nil {
|
||||||
|
fail(c, 409, "当前不能继续")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
|
||||||
|
w.PausedAt = nil
|
||||||
|
w.SegmentStartedAt = now
|
||||||
|
w.Status = "active"
|
||||||
|
if a.db.Save(&w).Error != nil {
|
||||||
|
fail(c, 500, "继续失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func (a *API) finishWalk(c *gin.Context) {
|
||||||
|
w, yes := a.walk(c)
|
||||||
|
if !yes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Status == "finished" {
|
||||||
|
fail(c, 409, "已结束")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if w.PausedAt != nil {
|
||||||
|
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
|
||||||
|
w.PausedAt = nil
|
||||||
|
}
|
||||||
|
w.Status = "finished"
|
||||||
|
w.EndedAt = &now
|
||||||
|
w.DurationSeconds = int(now.Sub(w.StartedAt).Seconds()) - w.PausedSeconds
|
||||||
|
w.ShareEnabled = false
|
||||||
|
w.ShareExpiresAt = nil
|
||||||
|
if a.db.Save(&w).Error != nil {
|
||||||
|
fail(c, 500, "结束遛狗失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func (a *API) setWalkShare(c *gin.Context) {
|
||||||
|
w, yes := a.walk(c)
|
||||||
|
if !yes {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w.Status == "finished" {
|
||||||
|
fail(c, 409, "已结束的遛狗不能分享")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if c.ShouldBindJSON(&in) != nil {
|
||||||
|
fail(c, 400, "参数无效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.ShareEnabled = in.Enabled
|
||||||
|
if in.Enabled {
|
||||||
|
expire := time.Now().Add(4 * time.Hour)
|
||||||
|
w.ShareExpiresAt = &expire
|
||||||
|
w.ShareToken = uuid.NewString()
|
||||||
|
} else {
|
||||||
|
w.ShareExpiresAt = nil
|
||||||
|
}
|
||||||
|
if a.db.Save(&w).Error != nil {
|
||||||
|
fail(c, 500, "分享设置失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, a.walkView(w))
|
||||||
|
}
|
||||||
|
func (a *API) publicWalkLocation(c *gin.Context) {
|
||||||
|
var w model.Walk
|
||||||
|
if !validID(c.Param("token")) || a.db.Where("share_token = ? AND share_enabled = true AND status IN ? AND share_expires_at > ?", c.Param("token"), []string{"active", "paused"}, time.Now()).First(&w).Error != nil {
|
||||||
|
fail(c, http.StatusNotFound, "分享已失效")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p model.WalkPoint
|
||||||
|
if a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&p).Error != nil {
|
||||||
|
fail(c, 404, "暂无位置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ok(c, gin.H{"latitude": p.Latitude, "longitude": p.Longitude, "recorded_at": p.RecordedAt, "status": w.Status})
|
||||||
|
}
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/pkg/errcode"
|
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// gin.Context 中存放身份的 key
|
|
||||||
const (
|
|
||||||
CtxUserID = "userID"
|
|
||||||
CtxAdminID = "adminID"
|
|
||||||
CtxName = "name"
|
|
||||||
)
|
|
||||||
|
|
||||||
func bearer(c *gin.Context) string {
|
|
||||||
h := c.GetHeader("Authorization")
|
|
||||||
if h == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if strings.HasPrefix(h, "Bearer ") {
|
|
||||||
return strings.TrimPrefix(h, "Bearer ")
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthUser 小程序用户鉴权
|
|
||||||
func AuthUser(jm *appjwt.Manager) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
claims, err := jm.Parse(bearer(c))
|
|
||||||
if err != nil || claims.Kind != appjwt.KindUser {
|
|
||||||
response.Abort(c, errcode.ErrUnauthized, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set(CtxUserID, claims.ID)
|
|
||||||
c.Set(CtxName, claims.Name)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// AuthAdmin 后台管理员鉴权
|
|
||||||
func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
claims, err := jm.Parse(bearer(c))
|
|
||||||
if err != nil || claims.Kind != appjwt.KindAdmin {
|
|
||||||
response.Abort(c, errcode.ErrUnauthized, "")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Set(CtxAdminID, claims.ID)
|
|
||||||
c.Set(CtxName, claims.Name)
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserID 从上下文取当前用户 ID
|
|
||||||
func UserID(c *gin.Context) string {
|
|
||||||
if v, ok := c.Get(CtxUserID); ok {
|
|
||||||
if id, ok := v.(string); ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminID 从上下文取当前管理员 ID
|
|
||||||
func AdminID(c *gin.Context) string {
|
|
||||||
if v, ok := c.Get(CtxAdminID); ok {
|
|
||||||
if id, ok := v.(string); ok {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package middleware
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CORS 开发期允许跨域(小程序无跨域限制,主要给后台 SPA dev server 用)
|
|
||||||
func CORS() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
origin := c.GetHeader("Origin")
|
|
||||||
if origin != "" {
|
|
||||||
c.Header("Access-Control-Allow-Origin", origin)
|
|
||||||
} else {
|
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
|
||||||
}
|
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
|
||||||
c.Header("Access-Control-Allow-Credentials", "true")
|
|
||||||
|
|
||||||
if c.Request.Method == http.MethodOptions {
|
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// Admin 后台管理员
|
|
||||||
type Admin struct {
|
|
||||||
Base
|
|
||||||
Username string `gorm:"size:64;uniqueIndex" json:"username"`
|
|
||||||
PasswordHash string `gorm:"size:128" json:"-"`
|
|
||||||
Role string `gorm:"size:32;default:admin" json:"role"`
|
|
||||||
Disabled bool `json:"disabled"`
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// AIMessage AI 聊天消息
|
|
||||||
type AIMessage struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
PetID *string `gorm:"size:24" json:"pet_id"`
|
|
||||||
Session string `gorm:"size:64;index" json:"session"`
|
|
||||||
Role string `gorm:"size:8" json:"role"` // user / ai
|
|
||||||
Text string `gorm:"type:text" json:"text"`
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// AIQuotaConfig 每日 AI 次数上限(单例,固定 id=1)。
|
|
||||||
// AI 调用是真金白银,不设上限等于把钱包交给用户。
|
|
||||||
type AIQuotaConfig struct {
|
|
||||||
Base
|
|
||||||
Enabled bool `json:"enabled"` // 关掉即不限次
|
|
||||||
DailyChat int `json:"daily_chat"` // 问问 AI,每人每天
|
|
||||||
DailySymptom int `json:"daily_symptom"` // 异常观察评估
|
|
||||||
DailyPlan int `json:"daily_plan"` // AI 生成计划
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIUsage 某人某天某类 AI 的用量。按天存,天然过期,不需要清理任务。
|
|
||||||
type AIUsage struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;uniqueIndex:idx_user_day_kind" json:"user_id"`
|
|
||||||
Day string `gorm:"size:10;uniqueIndex:idx_user_day_kind" json:"day"` // YYYY-MM-DD
|
|
||||||
Kind string `gorm:"size:16;uniqueIndex:idx_user_day_kind" json:"kind"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// Article 新手知识文章
|
|
||||||
type Article struct {
|
|
||||||
Base
|
|
||||||
Icon string `gorm:"size:16" json:"icon"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
Description string `gorm:"size:512" json:"description"`
|
|
||||||
Content string `gorm:"type:text" json:"content"`
|
|
||||||
Category string `gorm:"size:32" json:"category"`
|
|
||||||
RelatedSheetType string `gorm:"size:32" json:"related_sheet_type"`
|
|
||||||
Published bool `gorm:"default:true" json:"published"`
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/pkg/idgen"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Base 所有模型的公共字段。主键为雪花算法字符串 ID(非自增)。
|
|
||||||
type Base struct {
|
|
||||||
ID string `gorm:"primaryKey;size:24" json:"id"`
|
|
||||||
CreatedAt time.Time `json:"created_at"`
|
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeforeCreate 未显式赋值时自动生成雪花 ID(通过嵌入对所有模型生效)。
|
|
||||||
func (b *Base) BeforeCreate(tx *gorm.DB) error {
|
|
||||||
if b.ID == "" {
|
|
||||||
b.ID = idgen.Next()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
|
|
||||||
func AllModels() []any {
|
|
||||||
return []any{
|
|
||||||
&User{},
|
|
||||||
&Pet{},
|
|
||||||
&HealthRecord{},
|
|
||||||
&RecordType{},
|
|
||||||
&Breed{},
|
|
||||||
&DailyTask{},
|
|
||||||
&Plan{},
|
|
||||||
&PlanTask{},
|
|
||||||
&Reminder{},
|
|
||||||
&Post{},
|
|
||||||
&Comment{},
|
|
||||||
&PostLike{},
|
|
||||||
&Article{},
|
|
||||||
&AIMessage{},
|
|
||||||
&ProMembership{},
|
|
||||||
&Admin{},
|
|
||||||
&DailyAdvice{},
|
|
||||||
&CareTemplate{},
|
|
||||||
&CareTemplateItem{},
|
|
||||||
&CommunityBotConfig{},
|
|
||||||
&File{},
|
|
||||||
&Feedback{},
|
|
||||||
&Follow{},
|
|
||||||
&RefreshToken{},
|
|
||||||
&AIQuotaConfig{},
|
|
||||||
&AIUsage{},
|
|
||||||
&MediaCheck{},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// Breed 品种。原来建档时品种是个自由文本输入框,用户手打「柯基」「柯基犬」
|
|
||||||
// 「威尔士柯基」都算不同品种,后面想按品种做体重基准、常见病提示这类事就没法做。
|
|
||||||
type Breed struct {
|
|
||||||
Base
|
|
||||||
Species string `gorm:"size:16;index" json:"species"` // cat | dog
|
|
||||||
Name string `gorm:"size:64" json:"name"`
|
|
||||||
|
|
||||||
// Initial 首字母,A-Z,用来做侧边索引。
|
|
||||||
//
|
|
||||||
// 存字段而不是运行时算:Go 标准库没有拼音,引一个拼音库只为了取首字母不值得,
|
|
||||||
// 而且多音字(「柴犬」的柴、「藏獒」的藏)自动转常出错——「藏獒」按 cang
|
|
||||||
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
|
|
||||||
Initial string `gorm:"size:1;index" json:"initial"`
|
|
||||||
|
|
||||||
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
|
|
||||||
//
|
|
||||||
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
|
|
||||||
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
|
|
||||||
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
|
|
||||||
SizeClass string `gorm:"size:16;index" json:"size_class"`
|
|
||||||
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// 养护方案:一套「这个物种、这个阶段该做哪些事」的配置。
|
|
||||||
// 后台可以给同一个 (species, stage) 配多套,建档时让用户挑一套套用,
|
|
||||||
// 也可以不套用、自己排。原来是一个组合只能有一套(唯一索引卡着),
|
|
||||||
// 「让用户选模版」这件事在数据模型上做不到。
|
|
||||||
|
|
||||||
// 物种取值
|
|
||||||
const (
|
|
||||||
SpeciesCat = "cat"
|
|
||||||
SpeciesDog = "dog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 明细的类别。原来还有个 task(今日任务),已经和 plan 合并——
|
|
||||||
// 「今天要做的事」就是「今天那一天的计划节点」,两套并存的结果是
|
|
||||||
// 用户在计划页加的「每天喂药」在首页看不到。
|
|
||||||
const (
|
|
||||||
CareKindPlan = "plan" // 计划节点(含每天/每周/每月重复)
|
|
||||||
CareKindReminder = "reminder" // 到期提醒(疫苗、驱虫这类有固定周期的)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 重复规则。月度计划要的是「多久做一次」,不是「第几天做」——
|
|
||||||
// 原来只有 Day 偏移,月底建档的话 day=14/21/30 全落到下个月,
|
|
||||||
// 用户建完档打开计划页发现本月是空的。
|
|
||||||
const (
|
|
||||||
RepeatOnce = "once" // 一次性,按 Day 偏移(到家第 N 天,疫苗序列要用)
|
|
||||||
RepeatDaily = "daily" // 每天
|
|
||||||
RepeatWeekly = "weekly" // 每周某几天,看 Weekdays
|
|
||||||
RepeatMonthly = "monthly" // 每月某天,看 MonthDay
|
|
||||||
)
|
|
||||||
|
|
||||||
// CareTemplate 一套方案
|
|
||||||
type CareTemplate struct {
|
|
||||||
Base
|
|
||||||
Species string `gorm:"size:16;index" json:"species"` // cat | dog
|
|
||||||
Stage string `gorm:"size:48;index" json:"stage"` // 与 onboarding 阶段一致
|
|
||||||
// Name 方案名。建档时用户看到的就是这个,要能一眼分辨
|
|
||||||
// (「幼猫标准照护」/「幼猫省心版」)
|
|
||||||
Name string `gorm:"size:64" json:"name"`
|
|
||||||
// Summary 一句话摘要,选择列表里展示
|
|
||||||
Summary string `gorm:"size:255" json:"summary"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
|
|
||||||
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CareTemplateItem 明细,kind 区分两类;不同类只用到部分字段
|
|
||||||
type CareTemplateItem struct {
|
|
||||||
Base
|
|
||||||
TemplateID string `gorm:"size:24;index" json:"template_id"`
|
|
||||||
Kind string `gorm:"size:16;index" json:"kind"` // plan | reminder
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
Description string `gorm:"size:512" json:"description"`
|
|
||||||
|
|
||||||
// plan 专用
|
|
||||||
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
|
|
||||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录类型 code:weight/poop/bath...
|
|
||||||
// Repeat 列名用 repeat_rule:repeat 是 MySQL 保留字,GORM 虽然会加反引号,
|
|
||||||
// 但手写 SQL 排查时很容易踩(和 group_key 同样的理由)
|
|
||||||
Repeat string `gorm:"column:repeat_rule;size:16" json:"repeat"` // once | daily | weekly | monthly
|
|
||||||
Day int `json:"day"` // once:从套用日起第 N 天(0=当天)
|
|
||||||
// DayLabel 留着:后台的 AI 生成模板那条链路在产出它。月度计划里日期是绝对的
|
|
||||||
// 不需要「第 N 天」标签,但删它要连着改 AI 解析,不在这次范围内
|
|
||||||
DayLabel string `gorm:"size:32" json:"day_label"`
|
|
||||||
// Weekdays weekly 用:逗号分隔的 1-7(1=周一)。
|
|
||||||
// 存字符串不另开表——一条模板明细最多 7 个数字,开表换来的只是查询灵活性,
|
|
||||||
// 而这份数据从来只整条读出来展开
|
|
||||||
Weekdays string `gorm:"size:16" json:"weekdays"`
|
|
||||||
MonthDay int `json:"month_day"` // monthly 用:几号(1-31),超出当月天数按月末算
|
|
||||||
|
|
||||||
// reminder 专用
|
|
||||||
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
|
|
||||||
OffsetDays int `json:"offset_days"` // 到期 = 今天 + N 天;<=0 表示无固定到期,用频率
|
|
||||||
Frequency string `gorm:"size:64" json:"frequency"` // 频率描述,如「每周二、周五」
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "gorm.io/datatypes"
|
|
||||||
|
|
||||||
// Comment 帖子评论
|
|
||||||
type Comment struct {
|
|
||||||
Base
|
|
||||||
PostID string `gorm:"size:24;index" json:"post_id"`
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
|
||||||
Content string `gorm:"size:512" json:"content"`
|
|
||||||
Images datatypes.JSON `json:"images"`
|
|
||||||
// 两级评论:ParentID 为空是一级评论,否则是某条一级评论下的回复。
|
|
||||||
// 不做无限层级——手机上层层缩进读到第四层就没法看了,
|
|
||||||
// 主流产品(微信、小红书、B站)都是两级 + @某人 表达指向。
|
|
||||||
ParentID string `gorm:"size:24;index" json:"parent_id"`
|
|
||||||
ReplyToName string `gorm:"size:64" json:"reply_to_name"`
|
|
||||||
Status string `gorm:"size:16;default:published" json:"status"`
|
|
||||||
|
|
||||||
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
|
|
||||||
Replies []Comment `gorm:"-" json:"replies"` // 计算字段:一级评论下挂的回复
|
|
||||||
ReplyN int `gorm:"-" json:"reply_n"` // 回复总数(可能多于返回的条数)
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// CommunityBotConfig 社区 AI 运营配置(单例,固定 id=1)
|
|
||||||
type CommunityBotConfig struct {
|
|
||||||
Base
|
|
||||||
Enabled bool `json:"enabled"` // 是否开启每日自动生成
|
|
||||||
DailyCount int `json:"daily_count"` // 每天生成条数
|
|
||||||
StartHour int `json:"start_hour"` // 白天投放窗口起(含)
|
|
||||||
EndHour int `json:"end_hour"` // 白天投放窗口止(不含)
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
|
|
||||||
type DailyAdvice struct {
|
|
||||||
Base
|
|
||||||
PetID string `gorm:"size:24;uniqueIndex:idx_pet_day" json:"pet_id"`
|
|
||||||
Day string `gorm:"size:10;uniqueIndex:idx_pet_day" json:"day"` // YYYY-MM-DD
|
|
||||||
Text string `gorm:"type:text" json:"text"`
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// DailyTask 今日任务
|
|
||||||
type DailyTask struct {
|
|
||||||
Base
|
|
||||||
PetID string `gorm:"size:24;index" json:"pet_id"`
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
TaskDate time.Time `gorm:"size:24;index" json:"task_date"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
Description string `gorm:"size:512" json:"description"`
|
|
||||||
Priority string `gorm:"size:16" json:"priority"` // "" / 重要
|
|
||||||
SheetType string `gorm:"size:32" json:"sheet_type"` // 点击打开的弹层类型
|
|
||||||
Done bool `json:"done"`
|
|
||||||
CompletedAt *time.Time `json:"completed_at"`
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// Feedback 用户意见反馈
|
|
||||||
type Feedback struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
|
||||||
Content string `gorm:"type:text" json:"content"`
|
|
||||||
Contact string `gorm:"size:128" json:"contact"` // 可选,方便回访
|
|
||||||
Handled bool `gorm:"index" json:"handled"`
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// File 上传文件表:图片/头像等统一入库,按 MD5 去重,使用处以 file_id 关联。
|
|
||||||
type File struct {
|
|
||||||
Base
|
|
||||||
MD5 string `gorm:"size:32;uniqueIndex" json:"md5"` // 内容 MD5,唯一,用于去重
|
|
||||||
ObjectName string `gorm:"size:255" json:"object_name"` // MinIO 对象名
|
|
||||||
URL string `gorm:"size:512" json:"url"` // 可访问 URL
|
|
||||||
Size int64 `json:"size"`
|
|
||||||
ContentType string `gorm:"size:128" json:"content_type"`
|
|
||||||
Ext string `gorm:"size:16" json:"ext"`
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// Follow 关注关系:FollowerID 关注了 FolloweeID
|
|
||||||
type Follow struct {
|
|
||||||
Base
|
|
||||||
FollowerID string `gorm:"size:24;uniqueIndex:idx_follower_followee" json:"follower_id"`
|
|
||||||
FolloweeID string `gorm:"size:24;uniqueIndex:idx_follower_followee;index" json:"followee_id"`
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 健康记录类型
|
|
||||||
const (
|
|
||||||
RecordWeight = "weight"
|
|
||||||
RecordPoop = "poop"
|
|
||||||
RecordFood = "food"
|
|
||||||
RecordSymptom = "symptom"
|
|
||||||
RecordMedicine = "medicine"
|
|
||||||
RecordVaccine = "vaccine"
|
|
||||||
RecordDeworm = "deworm"
|
|
||||||
RecordCost = "cost"
|
|
||||||
RecordPhoto = "photo"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthRecord 统一健康/时间轴记录表,type 区分 9 类。
|
|
||||||
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
|
|
||||||
type HealthRecord struct {
|
|
||||||
Base
|
|
||||||
PetID string `gorm:"size:24;index" json:"pet_id"`
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
Type string `gorm:"size:16;index" json:"type"`
|
|
||||||
Icon string `gorm:"size:16" json:"icon"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
Description string `gorm:"size:512" json:"description"`
|
|
||||||
NumValue float64 `gorm:"type:decimal(10,2)" json:"num_value"`
|
|
||||||
Category string `gorm:"size:32" json:"category"`
|
|
||||||
ImageURL string `gorm:"size:512" json:"image_url"`
|
|
||||||
ImageFileID string `gorm:"size:24" json:"image_file_id"` // 关联 files 表:记录附图
|
|
||||||
Extra datatypes.JSON `json:"extra"`
|
|
||||||
OccurredAt time.Time `gorm:"size:24;index" json:"occurred_at"`
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// 图片异步安全检测的一次提交。mediaCheckAsync 返回 trace_id,
|
|
||||||
// 结果稍后由微信 push 回调,用 trace_id 找回它属于哪条帖子/评论。
|
|
||||||
const (
|
|
||||||
MediaPending = "pending"
|
|
||||||
MediaPass = "pass"
|
|
||||||
MediaRisky = "risky"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MediaCheck struct {
|
|
||||||
Base
|
|
||||||
TraceID string `gorm:"size:64;index" json:"trace_id"`
|
|
||||||
TargetType string `gorm:"size:16;index" json:"target_type"` // post | comment
|
|
||||||
TargetID string `gorm:"size:24;index" json:"target_id"`
|
|
||||||
Status string `gorm:"size:16" json:"status"` // pending | pass | risky
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Base gives every persisted model a PostgreSQL uuid primary key.
|
||||||
|
// GORM's naming strategy maps Go fields to snake_case columns.
|
||||||
|
type Base struct {
|
||||||
|
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Base) BeforeCreate(_ *gorm.DB) error {
|
||||||
|
if b.ID == "" {
|
||||||
|
b.ID = uuid.NewString()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
Base
|
||||||
|
Bucket string `gorm:"size:128;not null" json:"bucket"`
|
||||||
|
ObjectKey string `gorm:"size:512;uniqueIndex;not null" json:"object_key"`
|
||||||
|
MD5 string `gorm:"column:md5;size:32;uniqueIndex;not null" json:"md5"`
|
||||||
|
URL string `gorm:"size:1024;not null" json:"url"`
|
||||||
|
Name string `gorm:"size:255;not null" json:"name"`
|
||||||
|
ContentType string `gorm:"size:128;not null" json:"content_type"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Base
|
||||||
|
OpenID string `gorm:"size:128;uniqueIndex;not null" json:"-"`
|
||||||
|
Nickname string `gorm:"size:64;not null" json:"nickname"`
|
||||||
|
AvatarFileID *string `gorm:"type:uuid;index" json:"avatar_file_id"`
|
||||||
|
Disabled bool `gorm:"not null;default:false" json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Admin struct {
|
||||||
|
Base
|
||||||
|
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
|
||||||
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pet struct {
|
||||||
|
Base
|
||||||
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
|
Species string `gorm:"size:16;not null;default:dog" json:"species"`
|
||||||
|
Breed string `gorm:"size:64" json:"breed"`
|
||||||
|
Gender string `gorm:"size:16" json:"gender"`
|
||||||
|
BirthDate *time.Time `gorm:"type:date" json:"birth_date"`
|
||||||
|
WeightKg float64 `gorm:"type:numeric(6,2)" json:"weight_kg"`
|
||||||
|
AvatarFileID *string `gorm:"type:uuid;index" json:"avatar_file_id"`
|
||||||
|
Coat string `gorm:"size:64" json:"coat"`
|
||||||
|
Allergies string `gorm:"size:255" json:"allergies"`
|
||||||
|
CareNotes string `gorm:"size:1000" json:"care_notes"`
|
||||||
|
Archived bool `gorm:"not null;default:false" json:"archived"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CareTask struct {
|
||||||
|
Base
|
||||||
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
|
PetID string `gorm:"type:uuid;index;not null" json:"pet_id"`
|
||||||
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
|
Category string `gorm:"size:32;not null" json:"category"`
|
||||||
|
TimeOfDay string `gorm:"size:5;not null" json:"time_of_day"`
|
||||||
|
RepeatRule string `gorm:"size:16;not null;default:daily" json:"repeat_rule"`
|
||||||
|
ReminderEnabled bool `gorm:"not null;default:false" json:"reminder_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TaskCompletion struct {
|
||||||
|
Base
|
||||||
|
TaskID string `gorm:"type:uuid;uniqueIndex:idx_task_date;not null" json:"task_id"`
|
||||||
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
|
Date time.Time `gorm:"type:date;uniqueIndex:idx_task_date;not null" json:"date"`
|
||||||
|
CompletedAt time.Time `gorm:"not null" json:"completed_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Walk struct {
|
||||||
|
Base
|
||||||
|
UserID string `gorm:"type:uuid;index;not null" json:"user_id"`
|
||||||
|
Status string `gorm:"size:16;index;not null" json:"status"`
|
||||||
|
StartedAt time.Time `gorm:"not null" json:"started_at"`
|
||||||
|
SegmentStartedAt time.Time `gorm:"not null" json:"segment_started_at"`
|
||||||
|
PausedAt *time.Time `json:"paused_at"`
|
||||||
|
PausedSeconds int `gorm:"not null;default:0" json:"paused_seconds"`
|
||||||
|
EndedAt *time.Time `json:"ended_at"`
|
||||||
|
DurationSeconds int `gorm:"not null;default:0" json:"duration_seconds"`
|
||||||
|
DistanceMeters float64 `gorm:"type:numeric(12,2);not null;default:0" json:"distance_meters"`
|
||||||
|
ShareEnabled bool `gorm:"not null;default:false" json:"share_enabled"`
|
||||||
|
ShareToken string `gorm:"type:uuid;uniqueIndex" json:"-"`
|
||||||
|
ShareExpiresAt *time.Time `json:"share_expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WalkPet struct {
|
||||||
|
Base
|
||||||
|
WalkID string `gorm:"type:uuid;uniqueIndex:idx_walk_pet;not null" json:"walk_id"`
|
||||||
|
PetID string `gorm:"type:uuid;uniqueIndex:idx_walk_pet;index;not null" json:"pet_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WalkPoint struct {
|
||||||
|
Base
|
||||||
|
WalkID string `gorm:"type:uuid;index;not null" json:"walk_id"`
|
||||||
|
Latitude float64 `gorm:"type:numeric(10,7);not null" json:"latitude"`
|
||||||
|
Longitude float64 `gorm:"type:numeric(10,7);not null" json:"longitude"`
|
||||||
|
RecordedAt time.Time `gorm:"index;not null" json:"recorded_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Meetup struct {
|
||||||
|
Base
|
||||||
|
HostUserID string `gorm:"type:uuid;index;not null" json:"host_user_id"`
|
||||||
|
Title string `gorm:"size:128;not null" json:"title"`
|
||||||
|
Description string `gorm:"size:1000" json:"description"`
|
||||||
|
PlaceName string `gorm:"size:255;not null" json:"place_name"`
|
||||||
|
Latitude float64 `gorm:"type:numeric(10,7)" json:"latitude"`
|
||||||
|
Longitude float64 `gorm:"type:numeric(10,7)" json:"longitude"`
|
||||||
|
StartsAt time.Time `gorm:"index;not null" json:"starts_at"`
|
||||||
|
MaxPeople int `gorm:"not null;default:8" json:"max_people"`
|
||||||
|
Status string `gorm:"size:16;index;not null;default:open" json:"status"`
|
||||||
|
CoverFileID *string `gorm:"type:uuid;index" json:"cover_file_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetupParticipant struct {
|
||||||
|
Base
|
||||||
|
MeetupID string `gorm:"type:uuid;uniqueIndex:idx_meetup_user;not null" json:"meetup_id"`
|
||||||
|
UserID string `gorm:"type:uuid;uniqueIndex:idx_meetup_user;index;not null" json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MeetupPet struct {
|
||||||
|
Base
|
||||||
|
ParticipantID string `gorm:"type:uuid;uniqueIndex:idx_participant_pet;not null" json:"participant_id"`
|
||||||
|
PetID string `gorm:"type:uuid;uniqueIndex:idx_participant_pet;index;not null" json:"pet_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func All() []any {
|
||||||
|
return []any{
|
||||||
|
&File{}, &User{}, &Admin{}, &Pet{}, &CareTask{}, &TaskCompletion{},
|
||||||
|
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pet 宠物,属于 User,支持多宠
|
|
||||||
type Pet struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
Name string `gorm:"size:64" json:"name"`
|
|
||||||
Emoji string `gorm:"size:16" json:"emoji"`
|
|
||||||
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表:宠物照片
|
|
||||||
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
|
|
||||||
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
|
|
||||||
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
|
|
||||||
Birthday *time.Time `json:"birthday"`
|
|
||||||
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
|
|
||||||
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
|
|
||||||
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
|
|
||||||
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
|
|
||||||
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
|
|
||||||
Age string `gorm:"-" json:"age"`
|
|
||||||
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
|
|
||||||
Breed string `gorm:"size:64" json:"breed"` // 品种
|
|
||||||
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
|
|
||||||
Goals datatypes.JSON `json:"goals"` // onboarding 目标多选
|
|
||||||
|
|
||||||
// —— 身份卡用的补充信息 ——
|
|
||||||
Personality string `gorm:"size:64" json:"personality"` // 性格标签,逗号分隔,如 "活泼,亲人,贪吃"
|
|
||||||
Allergy string `gorm:"size:128" json:"allergy"` // 过敏情况,没有就填「无」
|
|
||||||
Notes string `gorm:"size:255" json:"notes"` // 备注信息(照护提醒)
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 计划类型
|
|
||||||
const (
|
|
||||||
PlanThirtyDay = "thirty_day"
|
|
||||||
PlanAI = "ai"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Plan 养宠计划(30 天路线图 / AI 计划)
|
|
||||||
type Plan struct {
|
|
||||||
Base
|
|
||||||
PetID string `gorm:"size:24;index" json:"pet_id"`
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
Kind string `gorm:"size:16;index" json:"kind"` // thirty_day / ai
|
|
||||||
Stage string `gorm:"size:32" json:"stage"`
|
|
||||||
StartDate *time.Time `json:"start_date"`
|
|
||||||
EndDate *time.Time `json:"end_date"`
|
|
||||||
UserInput string `gorm:"size:512" json:"user_input"`
|
|
||||||
Extracted datatypes.JSON `json:"extracted"` // AI 提取信息
|
|
||||||
CompletionPct int `json:"completion_pct"`
|
|
||||||
Status string `gorm:"size:16;default:active" json:"status"` // active / completed / archived
|
|
||||||
Tasks []PlanTask `gorm:"foreignKey:PlanID" json:"tasks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PlanTask 计划明细项
|
|
||||||
type PlanTask struct {
|
|
||||||
Base
|
|
||||||
PlanID string `gorm:"size:24;index" json:"plan_id"`
|
|
||||||
Day int `json:"day"`
|
|
||||||
DayLabel string `gorm:"size:32" json:"day_label"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
Description string `gorm:"size:512" json:"description"`
|
|
||||||
SheetType string `gorm:"size:32" json:"sheet_type"`
|
|
||||||
// Priority 从 DailyTask 合并过来的:首页那份「今天要做的事」要能标出哪条重要
|
|
||||||
Priority string `gorm:"size:16" json:"priority"`
|
|
||||||
Done bool `json:"done"`
|
|
||||||
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "gorm.io/datatypes"
|
|
||||||
|
|
||||||
// 帖子状态
|
|
||||||
const (
|
|
||||||
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
|
|
||||||
PostPublished = "published" // 已过审,正常展示
|
|
||||||
PostRejected = "rejected" // 内容安全判定风险,打回
|
|
||||||
PostHidden = "hidden"
|
|
||||||
PostDeleted = "deleted"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Post 社区帖子
|
|
||||||
type Post struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
PetID *string `gorm:"size:24" json:"pet_id"`
|
|
||||||
AuthorName string `gorm:"size:64" json:"author_name"`
|
|
||||||
AuthorEmoji string `gorm:"size:16" json:"author_emoji"`
|
|
||||||
Identity string `gorm:"size:16" json:"identity"` // petName / anonymous / official
|
|
||||||
Content string `gorm:"type:text" json:"content"`
|
|
||||||
Tags datatypes.JSON `json:"tags"`
|
|
||||||
Images datatypes.JSON `json:"images"` // 展示用:URL 数组(真实图)或 emoji 数组(机器人)
|
|
||||||
ImageFileIDs datatypes.JSON `json:"image_file_ids"` // 关联 files 表的 id 数组,读取时解析为 Images
|
|
||||||
LikeCount int `json:"like_count"`
|
|
||||||
CommentCount int `json:"comment_count"`
|
|
||||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
|
||||||
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
|
|
||||||
|
|
||||||
// 以下为计算字段,按当前登录用户填充
|
|
||||||
Followed bool `gorm:"-" json:"followed"` // 我是否已关注该作者
|
|
||||||
IsSelf bool `gorm:"-" json:"is_self"` // 是不是我自己发的
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// PostLike 帖子点赞,(post_id, user_id) 唯一
|
|
||||||
type PostLike struct {
|
|
||||||
Base
|
|
||||||
PostID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"post_id"`
|
|
||||||
UserID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"user_id"`
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// Pro 会员状态
|
|
||||||
const (
|
|
||||||
ProActive = "active"
|
|
||||||
ProExpired = "expired"
|
|
||||||
ProNone = "none"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProMembership 会员,1:1 于 User
|
|
||||||
type ProMembership struct {
|
|
||||||
Base
|
|
||||||
UserID string `gorm:"size:24;uniqueIndex" json:"user_id"`
|
|
||||||
Status string `gorm:"size:16;default:none" json:"status"`
|
|
||||||
PlanType string `gorm:"size:16" json:"plan_type"` // yearly / monthly
|
|
||||||
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
|
|
||||||
StartDate *time.Time `json:"start_date"`
|
|
||||||
EndDate *time.Time `json:"end_date"`
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// 记录类型。原来这 9 种是写在 health_record.go 里的常量,加一种就得发版;
|
|
||||||
// 挪到表里之后后台可以直接增删改。
|
|
||||||
//
|
|
||||||
// 注意这里**没有**替换掉那 9 个常量:weight / poop / food / symptom / cost /
|
|
||||||
// vaccine 在 record.go、insight.go、report.go、home.go 里有真实的代码分支
|
|
||||||
// (体重回写宠物档案、食便相关性分析、消费统计、疫苗完成度……),那些分支
|
|
||||||
// 按具体常量过滤,不是 switch 全枚举。所以:
|
|
||||||
// - 表负责「展示什么、能记什么」
|
|
||||||
// - 常量负责「哪几种参与分析」
|
|
||||||
// 两者各管一段,新增类型不会碰到任何分析逻辑。
|
|
||||||
|
|
||||||
// 记录类型分组
|
|
||||||
const (
|
|
||||||
RecordGroupDaily = "daily" // 日常
|
|
||||||
RecordGroupHealth = "health" // 健康
|
|
||||||
RecordGroupCare = "care" // 洗护
|
|
||||||
RecordGroupClean = "clean" // 清洁
|
|
||||||
)
|
|
||||||
|
|
||||||
// 弹层形态
|
|
||||||
const (
|
|
||||||
RecordFormFull = "full" // 有专用表单(原来那 9 种,各自一个 wx:elif 分支)
|
|
||||||
RecordFormSimple = "simple" // 通用简易表单:发生时间 + 备注 + 可选照片
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecordType 一种可记录的事项
|
|
||||||
type RecordType struct {
|
|
||||||
Base
|
|
||||||
// Code 就是小程序 bottom-sheet 的 type,也是 health_records.type 落库的值。
|
|
||||||
// 建过记录之后改 Code 会让历史数据对不上,后台不允许改(只能删了重建)。
|
|
||||||
Code string `gorm:"size:32;uniqueIndex" json:"code"`
|
|
||||||
Label string `gorm:"size:32" json:"label"`
|
|
||||||
Icon string `gorm:"size:32" json:"icon"` // pt-icon 名,见 styles/iconfont.wxss
|
|
||||||
|
|
||||||
// GroupKey 存成 group_key 而不是 group:group 是 MySQL 保留字,
|
|
||||||
// GORM 虽然会加反引号,但手写 SQL 排查时很容易踩
|
|
||||||
GroupKey string `gorm:"column:group_key;size:16;index" json:"group"`
|
|
||||||
Sort int `json:"sort"`
|
|
||||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
|
||||||
Form string `gorm:"size:16" json:"form"`
|
|
||||||
|
|
||||||
// Locked 有代码分支或模板引用依赖它,后台不许删。
|
|
||||||
// 硬拦在 service 层,不是靠前端隐藏按钮。
|
|
||||||
Locked bool `json:"locked"`
|
|
||||||
|
|
||||||
// Fields 表单字段配置,一行一个。后台用紧凑写法编辑:
|
|
||||||
//
|
|
||||||
// number:体重:kg 数值,落 num_value
|
|
||||||
// options:状态:正常|软便|拉稀 单选,落 category
|
|
||||||
// options:症状:呕吐|拉稀|精神差:- 单选,末尾 - 表示不落 category(只进标题)
|
|
||||||
// text:吃了什么 文本,只进标题
|
|
||||||
//
|
|
||||||
// 空 = 这个事项只要「时间 + 描述 + 照片」(洗澡、换猫砂这类)。
|
|
||||||
//
|
|
||||||
// 为什么要有「不落 category」这个开关:异常观察有两个单选(症状 + 严重程度),
|
|
||||||
// 而 category 只有一个坑,周报的高风险数按 category='高' 统计,
|
|
||||||
// 所以必须能指定是哪一个占这个坑。
|
|
||||||
Fields string `gorm:"type:text" json:"fields_raw"`
|
|
||||||
|
|
||||||
// ParsedFields 由 Fields 解析出来,前端直接照它渲染表单,不用自己写解析器
|
|
||||||
ParsedFields []RecordField `gorm:"-" json:"fields"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字段类型
|
|
||||||
const (
|
|
||||||
FieldNumber = "number"
|
|
||||||
FieldOptions = "options"
|
|
||||||
FieldText = "text"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RecordField 一个表单字段
|
|
||||||
type RecordField struct {
|
|
||||||
Kind string `json:"kind"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
Unit string `json:"unit,omitempty"`
|
|
||||||
Options []string `json:"options,omitempty"`
|
|
||||||
// ToCategory 这个字段的值是否落到 health_records.category。
|
|
||||||
// 同一个事项里最多一个字段能占它
|
|
||||||
ToCategory bool `json:"to_category"`
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// RefreshToken 刷新令牌。
|
|
||||||
// access token(JWT) 只有 2 小时,过期后用它换新的,用户不必重新登录;
|
|
||||||
// 库里只存 sha256,即使数据库泄露也换不出可用令牌。
|
|
||||||
// 每次刷新都会轮换(旧的立即作废),旧令牌被再次使用视为泄露。
|
|
||||||
type RefreshToken struct {
|
|
||||||
Base
|
|
||||||
TokenHash string `gorm:"size:64;uniqueIndex" json:"-"`
|
|
||||||
Kind string `gorm:"size:16;index" json:"kind"` // user | admin
|
|
||||||
SubjectID string `gorm:"size:24;index" json:"subject_id"` // 用户 ID 或管理员 ID
|
|
||||||
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
|
||||||
RevokedAt *time.Time `json:"revoked_at"`
|
|
||||||
// 作废原因:rotate=正常轮换(有宽限期)/ logout=主动退出 / reuse=检测到泄露被连坐吊销。
|
|
||||||
// 只有 rotate 允许宽限期内再用一次,否则「吊销全部会话」会被宽限期架空。
|
|
||||||
RevokedReason string `gorm:"size:16" json:"revoked_reason"`
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
// 提醒类型
|
|
||||||
const (
|
|
||||||
ReminderVaccine = "vaccine"
|
|
||||||
ReminderDeworm = "deworm"
|
|
||||||
ReminderWeight = "weight"
|
|
||||||
ReminderMonthlyReport = "monthlyReport"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Reminder 提醒
|
|
||||||
type Reminder struct {
|
|
||||||
Base
|
|
||||||
PetID string `gorm:"size:24;index" json:"pet_id"`
|
|
||||||
UserID string `gorm:"size:24;index" json:"user_id"`
|
|
||||||
Type string `gorm:"size:24;index" json:"type"`
|
|
||||||
Title string `gorm:"size:128" json:"title"`
|
|
||||||
NextDueDate *time.Time `json:"next_due_date"`
|
|
||||||
Frequency string `gorm:"size:64" json:"frequency"`
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// User 小程序用户(铲屎官)
|
|
||||||
type User struct {
|
|
||||||
Base
|
|
||||||
OpenID string `gorm:"size:64;index" json:"openid"`
|
|
||||||
Nickname string `gorm:"size:64" json:"nickname"`
|
|
||||||
// 昵称是系统随机生成的(还没被用户或微信授权覆盖过)。
|
|
||||||
// 有这个标记才能安全地在用户授权时覆盖,而不会把他改过的昵称冲掉。
|
|
||||||
NicknameAuto bool `json:"nickname_auto"`
|
|
||||||
PetCount int `gorm:"-" json:"pet_count"` // 计算字段:后台列表用
|
|
||||||
Avatar string `gorm:"size:512" json:"avatar"`
|
|
||||||
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表;优先于 Avatar
|
|
||||||
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
|
|
||||||
Phone string `gorm:"size:32" json:"phone"`
|
|
||||||
Onboarded bool `json:"onboarded"`
|
|
||||||
Disabled bool `json:"disabled"`
|
|
||||||
IsBot bool `gorm:"size:24;index" json:"is_bot"` // 虚拟宠友账号(AI 社区运营用)
|
|
||||||
|
|
||||||
// —— 个人主页装扮 ——
|
|
||||||
Bio string `gorm:"size:120" json:"bio"` // 个性签名
|
|
||||||
BgFileID string `gorm:"size:24" json:"bg_file_id"` // 主页头图,关联 files 表
|
|
||||||
BgURL string `gorm:"-" json:"bg_url"` // 计算字段:由 BgFileID 解析
|
|
||||||
Theme string `gorm:"size:16" json:"theme"` // 主题色 key,见 service.UserThemes
|
|
||||||
// 存「隐藏」而不是「展示」,为的是零值即默认:老数据 AutoMigrate 出来是 0,
|
|
||||||
// 正好等于「展示宠物墙」,不需要给存量用户跑一次刷值
|
|
||||||
HidePets bool `json:"hide_pets"`
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
package router
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/admin"
|
|
||||||
"github.com/sundynix/pets-be/internal/handler"
|
|
||||||
"github.com/sundynix/pets-be/internal/middleware"
|
|
||||||
appjwt "github.com/sundynix/pets-be/pkg/jwt"
|
|
||||||
"github.com/sundynix/pets-be/pkg/response"
|
|
||||||
)
|
|
||||||
|
|
||||||
// New 组装 gin 引擎与所有路由
|
|
||||||
func New(h *handler.Handler, jm *appjwt.Manager, mode string) *gin.Engine {
|
|
||||||
gin.SetMode(mode)
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(gin.Logger(), gin.Recovery(), middleware.CORS())
|
|
||||||
|
|
||||||
// 健康检查
|
|
||||||
r.GET("/api/ping", func(c *gin.Context) { response.OK(c, gin.H{"pong": true}) })
|
|
||||||
// 根路径跳转后台
|
|
||||||
r.GET("/", func(c *gin.Context) { c.Redirect(302, "/admin/") })
|
|
||||||
// 微信图片异步安全检测的结果回调(消息推送),公开、自带签名校验
|
|
||||||
r.Any("/api/wx/sec-callback", h.WxSecCallback)
|
|
||||||
|
|
||||||
api := r.Group("/api")
|
|
||||||
registerAuth(api, h)
|
|
||||||
registerUserAPI(api, h, jm)
|
|
||||||
registerAdminAPI(api, h, jm)
|
|
||||||
|
|
||||||
// 内嵌后台 SPA
|
|
||||||
admin.Register(r)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerAuth(api *gin.RouterGroup, h *handler.Handler) {
|
|
||||||
auth := api.Group("/auth")
|
|
||||||
auth.POST("/login", h.Login)
|
|
||||||
auth.POST("/wechat", h.WechatLogin)
|
|
||||||
// 刷新/退出走刷新令牌,不需要(也不可能带)有效的 access token
|
|
||||||
auth.POST("/refresh", h.RefreshToken)
|
|
||||||
auth.POST("/logout", h.Logout)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 小程序用户接口(需 user JWT)
|
|
||||||
func registerUserAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
|
||||||
g := api.Group("")
|
|
||||||
g.Use(middleware.AuthUser(jm))
|
|
||||||
|
|
||||||
g.GET("/user/profile", h.Profile)
|
|
||||||
g.PUT("/user/profile", h.UpdateProfile)
|
|
||||||
g.POST("/user/phone", h.BindPhone)
|
|
||||||
g.GET("/user/summary", h.UserSummary)
|
|
||||||
g.PUT("/user/decoration", h.UpdateDecoration)
|
|
||||||
|
|
||||||
g.GET("/pets", h.ListPets)
|
|
||||||
g.POST("/pets", h.CreatePet)
|
|
||||||
g.POST("/onboarding", h.Onboarding)
|
|
||||||
g.GET("/pets/:id", h.GetPet)
|
|
||||||
g.PUT("/pets/:id", h.UpdatePet)
|
|
||||||
g.DELETE("/pets/:id", h.DeletePet)
|
|
||||||
|
|
||||||
g.GET("/record-types", h.RecordTypes)
|
|
||||||
g.GET("/breeds", h.Breeds)
|
|
||||||
g.GET("/life-stages", h.LifeStages)
|
|
||||||
g.GET("/pets/:id/records", h.ListRecords)
|
|
||||||
g.POST("/pets/:id/records", h.CreateRecord)
|
|
||||||
g.GET("/pets/:id/records/weight-trend", h.WeightTrend)
|
|
||||||
g.GET("/pets/:id/insights", h.PetInsights)
|
|
||||||
g.DELETE("/records/:id", h.DeleteRecord)
|
|
||||||
|
|
||||||
g.GET("/pets/:id/tasks", h.ListTasks)
|
|
||||||
g.POST("/pets/:id/tasks/complete-all", h.CompleteAllTasks)
|
|
||||||
g.POST("/tasks/:id/toggle", h.ToggleTask)
|
|
||||||
|
|
||||||
g.GET("/pets/:id/home-summary", h.HomeSummary)
|
|
||||||
|
|
||||||
g.GET("/pets/:id/plan", h.GetPlan)
|
|
||||||
g.GET("/pets/:id/plan/calendar", h.PlanCalendar)
|
|
||||||
g.GET("/pets/:id/stage-drift", h.PetStageDrift)
|
|
||||||
g.GET("/pets/:id/id-card", h.PetIDCard)
|
|
||||||
g.GET("/pets/:id/plan/month-status", h.PlanMonthStatus)
|
|
||||||
// 养护方案:列出可选的、套用一套到某个月
|
|
||||||
g.GET("/care-templates", h.CareTemplateOptions)
|
|
||||||
g.POST("/pets/:id/apply-template", h.ApplyTemplate)
|
|
||||||
g.GET("/pets/:id/day-plan", h.DayPlan)
|
|
||||||
g.POST("/pets/:id/ai-plan", h.CreateAIPlan)
|
|
||||||
g.POST("/ai-plan/:id/apply", h.ApplyAIPlan)
|
|
||||||
g.POST("/plan-tasks/:id/toggle", h.TogglePlanTask)
|
|
||||||
// 用户自己管理计划节点(原来只能勾选内置模板生成的那些)
|
|
||||||
g.POST("/pets/:id/plan-tasks", h.AddPlanTask)
|
|
||||||
g.PUT("/plan-tasks/:id", h.UpdatePlanTask)
|
|
||||||
g.DELETE("/plan-tasks/:id", h.DeletePlanTask)
|
|
||||||
|
|
||||||
g.GET("/pets/:id/reminders", h.ListReminders)
|
|
||||||
g.POST("/pets/:id/reminders", h.CreateReminder)
|
|
||||||
g.PUT("/reminders/:id", h.UpdateReminder)
|
|
||||||
g.DELETE("/reminders/:id", h.DeleteReminder)
|
|
||||||
|
|
||||||
g.GET("/pets/:id/report/weekly", h.WeeklyReport)
|
|
||||||
g.GET("/pets/:id/bill", h.Bill)
|
|
||||||
g.GET("/pets/:id/health-summary", h.HealthSummary)
|
|
||||||
g.GET("/pets/:id/poster", h.Poster)
|
|
||||||
|
|
||||||
g.GET("/users/:id/profile", h.UserCard)
|
|
||||||
g.GET("/users/:id/posts", h.UserPosts)
|
|
||||||
g.GET("/users/:id/relations", h.Relations)
|
|
||||||
g.POST("/users/:id/follow", h.FollowUser)
|
|
||||||
g.DELETE("/users/:id/follow", h.UnfollowUser)
|
|
||||||
|
|
||||||
g.GET("/posts", h.ListPosts)
|
|
||||||
g.POST("/posts", h.CreatePost)
|
|
||||||
g.GET("/posts/:id", h.GetPost)
|
|
||||||
g.POST("/posts/:id/like", h.LikePost)
|
|
||||||
g.DELETE("/posts/:id/like", h.UnlikePost)
|
|
||||||
g.GET("/posts/:id/comments", h.ListComments)
|
|
||||||
g.POST("/posts/:id/comments", h.CreateComment)
|
|
||||||
g.DELETE("/posts/:id", h.DeleteOwnPost)
|
|
||||||
g.DELETE("/comments/:id", h.DeleteOwnComment)
|
|
||||||
g.GET("/comments/:id/replies", h.ListReplies)
|
|
||||||
|
|
||||||
g.GET("/articles", h.ListArticles)
|
|
||||||
g.POST("/feedback", h.CreateFeedback)
|
|
||||||
g.GET("/articles/:id", h.GetArticle)
|
|
||||||
|
|
||||||
g.GET("/pro", h.GetPro)
|
|
||||||
g.POST("/pro/activate", h.ActivatePro)
|
|
||||||
|
|
||||||
g.POST("/ai/chat", h.AIChat)
|
|
||||||
g.GET("/ai/messages", h.ListAIMessages)
|
|
||||||
g.GET("/ai/quota", h.AIQuota)
|
|
||||||
g.POST("/pets/:id/ai/assess-symptom", h.AssessSymptom)
|
|
||||||
g.POST("/upload", h.Upload)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 后台接口(登录开放,其余需 admin JWT)
|
|
||||||
func registerAdminAPI(api *gin.RouterGroup, h *handler.Handler, jm *appjwt.Manager) {
|
|
||||||
api.POST("/admin/login", h.AdminLogin)
|
|
||||||
api.POST("/admin/refresh", h.AdminRefresh)
|
|
||||||
api.POST("/admin/logout", h.AdminLogout)
|
|
||||||
|
|
||||||
g := api.Group("/admin")
|
|
||||||
g.Use(middleware.AuthAdmin(jm))
|
|
||||||
|
|
||||||
g.GET("/me", h.AdminMe)
|
|
||||||
g.GET("/stats", h.AdminStats)
|
|
||||||
g.GET("/analytics", h.AdminAnalytics)
|
|
||||||
|
|
||||||
g.GET("/users", h.AdminListUsers)
|
|
||||||
g.PUT("/users/:id/disabled", h.AdminSetUserDisabled)
|
|
||||||
|
|
||||||
g.GET("/pets", h.AdminListPets)
|
|
||||||
|
|
||||||
g.GET("/posts", h.AdminListPosts)
|
|
||||||
g.PUT("/posts/:id/status", h.AdminSetPostStatus)
|
|
||||||
|
|
||||||
g.GET("/comments", h.AdminListComments)
|
|
||||||
g.PUT("/comments/:id/status", h.AdminSetCommentStatus)
|
|
||||||
g.DELETE("/comments/:id", h.AdminDeleteComment)
|
|
||||||
|
|
||||||
g.GET("/articles", h.AdminListArticles)
|
|
||||||
g.POST("/articles", h.AdminSaveArticle)
|
|
||||||
g.DELETE("/articles/:id", h.AdminDeleteArticle)
|
|
||||||
|
|
||||||
g.GET("/breeds", h.AdminListBreeds)
|
|
||||||
g.POST("/breeds", h.AdminSaveBreed)
|
|
||||||
g.DELETE("/breeds/:id", h.AdminDeleteBreed)
|
|
||||||
|
|
||||||
g.GET("/record-types", h.AdminListRecordTypes)
|
|
||||||
g.POST("/record-types", h.AdminSaveRecordType)
|
|
||||||
g.DELETE("/record-types/:id", h.AdminDeleteRecordType)
|
|
||||||
|
|
||||||
g.GET("/care-templates/meta", h.AdminCareTemplateMeta)
|
|
||||||
g.GET("/care-templates", h.AdminListCareTemplates)
|
|
||||||
g.GET("/care-templates/:id", h.AdminGetCareTemplate)
|
|
||||||
g.POST("/care-templates", h.AdminSaveCareTemplate)
|
|
||||||
g.DELETE("/care-templates/:id", h.AdminDeleteCareTemplate)
|
|
||||||
g.POST("/care-templates/generate", h.AdminGenerateCareTemplate)
|
|
||||||
|
|
||||||
g.GET("/ai-quota", h.AdminGetAIQuota)
|
|
||||||
g.PUT("/ai-quota", h.AdminSaveAIQuota)
|
|
||||||
|
|
||||||
g.GET("/community-bot", h.AdminGetCommunityBot)
|
|
||||||
g.PUT("/community-bot", h.AdminSaveCommunityBot)
|
|
||||||
g.POST("/community-bot/generate", h.AdminGenerateCommunityPosts)
|
|
||||||
|
|
||||||
g.GET("/feedback", h.AdminListFeedback)
|
|
||||||
g.PUT("/feedback/:id/handled", h.AdminSetFeedbackHandled)
|
|
||||||
|
|
||||||
g.GET("/memberships", h.AdminListPro)
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AdminLogin 校验管理员账号密码。仅允许配置里指定的唯一管理员账号登录后台。
|
|
||||||
func (s *Service) AdminLogin(username, password string) (*model.Admin, error) {
|
|
||||||
if username != s.cfg.Admin.Username {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
var admin model.Admin
|
|
||||||
if err := s.db.Where("username = ?", username).First(&admin).Error; err != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if admin.Disabled {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(password)); err != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return &admin, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAdmin 取管理员
|
|
||||||
func (s *Service) GetAdmin(id string) (*model.Admin, error) {
|
|
||||||
var admin model.Admin
|
|
||||||
if err := s.db.First(&admin, id).Error; err != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return &admin, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminStats 后台统计
|
|
||||||
type AdminStats struct {
|
|
||||||
Users int64 `json:"users"`
|
|
||||||
Pets int64 `json:"pets"`
|
|
||||||
Posts int64 `json:"posts"`
|
|
||||||
Records int64 `json:"records"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stats Dashboard 计数
|
|
||||||
func (s *Service) Stats() (*AdminStats, error) {
|
|
||||||
var st AdminStats
|
|
||||||
s.db.Model(&model.User{}).Count(&st.Users)
|
|
||||||
s.db.Model(&model.Pet{}).Count(&st.Pets)
|
|
||||||
s.db.Model(&model.Post{}).Count(&st.Posts)
|
|
||||||
s.db.Model(&model.HealthRecord{}).Count(&st.Records)
|
|
||||||
return &st, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListUsers 用户分页(keyword 匹配昵称)
|
|
||||||
func (s *Service) ListUsers(keyword, userType string, offset, limit int) ([]model.User, int64, error) {
|
|
||||||
q := s.db.Model(&model.User{})
|
|
||||||
if keyword != "" {
|
|
||||||
q = q.Where("nickname LIKE ?", "%"+keyword+"%")
|
|
||||||
}
|
|
||||||
switch userType {
|
|
||||||
case "bot":
|
|
||||||
q = q.Where("is_bot = ?", true)
|
|
||||||
case "real":
|
|
||||||
q = q.Where("is_bot IS NULL OR is_bot = ?", false)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
q.Count(&total)
|
|
||||||
var users []model.User
|
|
||||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&users).Error
|
|
||||||
if err == nil {
|
|
||||||
s.fillPetCounts(users)
|
|
||||||
}
|
|
||||||
return users, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fillPetCounts 一次 GROUP BY 查出这一页用户各建了几个宠物档,
|
|
||||||
// 避免每行一次 count 把列表打成 N+1 查询
|
|
||||||
func (s *Service) fillPetCounts(users []model.User) {
|
|
||||||
if len(users) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ids := make([]string, 0, len(users))
|
|
||||||
for i := range users {
|
|
||||||
ids = append(ids, users[i].ID)
|
|
||||||
}
|
|
||||||
var rows []struct {
|
|
||||||
UserID string
|
|
||||||
N int
|
|
||||||
}
|
|
||||||
if err := s.db.Model(&model.Pet{}).
|
|
||||||
Select("user_id, count(*) as n").
|
|
||||||
Where("user_id IN ?", ids).
|
|
||||||
Group("user_id").Scan(&rows).Error; err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m := make(map[string]int, len(rows))
|
|
||||||
for _, r := range rows {
|
|
||||||
m[r.UserID] = r.N
|
|
||||||
}
|
|
||||||
for i := range users {
|
|
||||||
users[i].PetCount = m[users[i].ID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetUserDisabled 启用/禁用用户
|
|
||||||
func (s *Service) SetUserDisabled(id string, disabled bool) error {
|
|
||||||
if err := s.db.Model(&model.User{}).Where("id = ?", id).Update("disabled", disabled).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if disabled {
|
|
||||||
// 顺手吊销刷新令牌,否则禁用后对方还能一直续期
|
|
||||||
s.RevokeAllRefreshTokens("user", id)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPetsAdmin 宠物分页
|
|
||||||
func (s *Service) ListPetsAdmin(offset, limit int) ([]model.Pet, int64, error) {
|
|
||||||
var total int64
|
|
||||||
s.db.Model(&model.Pet{}).Count(&total)
|
|
||||||
var pets []model.Pet
|
|
||||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&pets).Error
|
|
||||||
return pets, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPostsAdmin 帖子分页(status 可选过滤)
|
|
||||||
func (s *Service) ListPostsAdmin(status string, offset, limit int) ([]model.Post, int64, error) {
|
|
||||||
q := s.db.Model(&model.Post{})
|
|
||||||
if status != "" {
|
|
||||||
q = q.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
q.Count(&total)
|
|
||||||
var posts []model.Post
|
|
||||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error
|
|
||||||
// 真实帖子的图存的是 file id,要解析成 URL 后台才看得到图
|
|
||||||
s.attachPostImages(posts)
|
|
||||||
return posts, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetPostStatus 审核帖子(published/hidden/deleted)
|
|
||||||
func (s *Service) SetPostStatus(id string, status string) error {
|
|
||||||
res := s.db.Model(&model.Post{}).Where("id = ?", id).Update("status", status)
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListCommentsAdmin 评论分页,status 非空则按状态过滤(待审核用 pending)
|
|
||||||
func (s *Service) ListCommentsAdmin(status string, offset, limit int) ([]model.Comment, int64, error) {
|
|
||||||
q := s.db.Model(&model.Comment{})
|
|
||||||
if status != "" {
|
|
||||||
q = q.Where("status = ?", status)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
q.Count(&total)
|
|
||||||
var comments []model.Comment
|
|
||||||
err := q.Order("id desc").Offset(offset).Limit(limit).Find(&comments).Error
|
|
||||||
return comments, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetCommentStatus 审核评论。从待审核过审时补上楼主的评论数
|
|
||||||
func (s *Service) SetCommentStatus(id, status string) error {
|
|
||||||
var c model.Comment
|
|
||||||
if err := s.db.First(&c, "id = ?", id).Error; err != nil {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if c.Status == status {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", status).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// pending → published:这条评论此前没计数,过审后补 1
|
|
||||||
if status == model.PostPublished && c.Status == model.PostPending {
|
|
||||||
s.db.Model(&model.Post{}).Where("id = ?", c.PostID).
|
|
||||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteCommentAdmin 删除评论
|
|
||||||
func (s *Service) DeleteCommentAdmin(id string) error {
|
|
||||||
return s.db.Model(&model.Comment{}).Where("id = ?", id).Update("status", "deleted").Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListArticlesAdmin 文章分页(含未发布)
|
|
||||||
func (s *Service) ListArticlesAdmin(offset, limit int) ([]model.Article, int64, error) {
|
|
||||||
var total int64
|
|
||||||
s.db.Model(&model.Article{}).Count(&total)
|
|
||||||
var articles []model.Article
|
|
||||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&articles).Error
|
|
||||||
return articles, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveArticle 新增或更新文章(ID 为 0 则新增)
|
|
||||||
func (s *Service) SaveArticle(a *model.Article) error {
|
|
||||||
if a.ID == "" {
|
|
||||||
return s.db.Create(a).Error
|
|
||||||
}
|
|
||||||
return s.db.Model(&model.Article{}).Where("id = ?", a.ID).Updates(map[string]any{
|
|
||||||
"icon": a.Icon,
|
|
||||||
"title": a.Title,
|
|
||||||
"description": a.Description,
|
|
||||||
"content": a.Content,
|
|
||||||
"category": a.Category,
|
|
||||||
"related_sheet_type": a.RelatedSheetType,
|
|
||||||
"published": a.Published,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteArticle 删除文章
|
|
||||||
func (s *Service) DeleteArticle(id string) error {
|
|
||||||
res := s.db.Delete(&model.Article{}, id)
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListProAdmin 会员分页
|
|
||||||
func (s *Service) ListProAdmin(offset, limit int) ([]model.ProMembership, int64, error) {
|
|
||||||
var total int64
|
|
||||||
s.db.Model(&model.ProMembership{}).Count(&total)
|
|
||||||
var list []model.ProMembership
|
|
||||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
|
|
||||||
return list, total, err
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 后台看板的分析数据。真实用户口径(排除 bot),活跃口径=当天产生过
|
|
||||||
// 健康记录/发帖/评论中的任意一种。数据量在预生产规模下很小,直接拉进内存算,
|
|
||||||
// 省掉一堆按天分组的 SQL。
|
|
||||||
|
|
||||||
// DayPoint 折线/柱状的一个点。Date 视粒度是 MM-DD 或 YYYY-MM
|
|
||||||
type DayPoint struct {
|
|
||||||
Date string `json:"date"`
|
|
||||||
Count int `json:"count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// RetPoint 留存曲线一个点:注册后第 Day 天仍活跃的比例
|
|
||||||
type RetPoint struct {
|
|
||||||
Day int `json:"day"` // 注册后天数
|
|
||||||
Rate float64 `json:"rate"` // 0~1
|
|
||||||
Base int `json:"base"` // 该口径下够“年龄”的用户数(分母)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Analytics struct {
|
|
||||||
Summary struct {
|
|
||||||
TotalUsers int `json:"total_users"` // 真实用户总数
|
|
||||||
NewToday int `json:"new_today"`
|
|
||||||
NewMonth int `json:"new_month"`
|
|
||||||
DAU int `json:"dau"` // 今日活跃
|
|
||||||
WAU int `json:"wau"` // 近 7 日活跃
|
|
||||||
MAU int `json:"mau"` // 近 30 日活跃
|
|
||||||
TotalPets int `json:"total_pets"`
|
|
||||||
TotalPosts int `json:"total_posts"`
|
|
||||||
TotalRecords int `json:"total_records"`
|
|
||||||
} `json:"summary"`
|
|
||||||
NewTrend []DayPoint `json:"new_trend"` // 每日新增,近 days 天
|
|
||||||
ActiveTrend []DayPoint `json:"active_trend"` // 每日活跃(DAU),近 days 天
|
|
||||||
MauTrend []DayPoint `json:"mau_trend"` // 月活,近 6 个月
|
|
||||||
Retention []RetPoint `json:"retention"` // 留存曲线
|
|
||||||
}
|
|
||||||
|
|
||||||
// dayKey 把时间压成 yyyymmdd 整数,单调,便于比较和当 map key
|
|
||||||
func dayKey(t time.Time) int {
|
|
||||||
return t.Year()*10000 + int(t.Month())*100 + t.Day()
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminAnalytics days=趋势天数(默认 30,封顶 180)
|
|
||||||
func (s *Service) AdminAnalytics(days int) (*Analytics, error) {
|
|
||||||
if days <= 0 || days > 180 {
|
|
||||||
days = 30
|
|
||||||
}
|
|
||||||
loc := time.Local
|
|
||||||
now := time.Now().In(loc)
|
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
|
|
||||||
|
|
||||||
out := &Analytics{}
|
|
||||||
|
|
||||||
// —— 真实用户(排除 bot)——
|
|
||||||
type uRow struct {
|
|
||||||
ID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
var users []uRow
|
|
||||||
s.db.Model(&model.User{}).
|
|
||||||
Where("is_bot IS NULL OR is_bot = 0").
|
|
||||||
Select("id, created_at").Scan(&users)
|
|
||||||
|
|
||||||
signup := make(map[string]int, len(users)) // userID -> 注册日 dayKey
|
|
||||||
signupTime := make(map[string]time.Time, len(users))
|
|
||||||
ids := make([]string, 0, len(users))
|
|
||||||
todayKey := dayKey(today)
|
|
||||||
monthStart := dayKey(time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc))
|
|
||||||
for _, u := range users {
|
|
||||||
t := u.CreatedAt.In(loc)
|
|
||||||
signup[u.ID] = dayKey(t)
|
|
||||||
signupTime[u.ID] = t
|
|
||||||
ids = append(ids, u.ID)
|
|
||||||
k := dayKey(t)
|
|
||||||
if k == todayKey {
|
|
||||||
out.Summary.NewToday++
|
|
||||||
}
|
|
||||||
if k >= monthStart {
|
|
||||||
out.Summary.NewMonth++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.Summary.TotalUsers = len(users)
|
|
||||||
|
|
||||||
// —— 活跃事件:近 180 天,真实用户的 记录/帖子/评论 ——
|
|
||||||
windowStart := today.AddDate(0, 0, -180)
|
|
||||||
type ev struct {
|
|
||||||
UserID string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
// userID -> 活跃日集合;以及每日活跃用户集合(供 DAU/MAU 用)
|
|
||||||
userActive := make(map[string]map[int]bool)
|
|
||||||
dayUsers := make(map[int]map[string]bool)
|
|
||||||
mark := func(table string) {
|
|
||||||
if len(ids) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var rows []ev
|
|
||||||
s.db.Table(table).Select("user_id, created_at").
|
|
||||||
Where("created_at >= ?", windowStart).
|
|
||||||
Where("user_id IN ?", ids).Scan(&rows)
|
|
||||||
for _, r := range rows {
|
|
||||||
k := dayKey(r.CreatedAt.In(loc))
|
|
||||||
if userActive[r.UserID] == nil {
|
|
||||||
userActive[r.UserID] = map[int]bool{}
|
|
||||||
}
|
|
||||||
userActive[r.UserID][k] = true
|
|
||||||
if dayUsers[k] == nil {
|
|
||||||
dayUsers[k] = map[string]bool{}
|
|
||||||
}
|
|
||||||
dayUsers[k][r.UserID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mark("sundynix_health_records")
|
|
||||||
mark("sundynix_posts")
|
|
||||||
mark("sundynix_comments")
|
|
||||||
|
|
||||||
// distinctInRange 统计 [fromDay, toDay] 内的去重活跃用户
|
|
||||||
distinctInRange := func(from, to time.Time) int {
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
||||||
for u := range dayUsers[dayKey(d)] {
|
|
||||||
seen[u] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return len(seen)
|
|
||||||
}
|
|
||||||
out.Summary.DAU = len(dayUsers[todayKey])
|
|
||||||
out.Summary.WAU = distinctInRange(today.AddDate(0, 0, -6), today)
|
|
||||||
out.Summary.MAU = distinctInRange(today.AddDate(0, 0, -29), today)
|
|
||||||
|
|
||||||
// —— 趋势:新增 + DAU,近 days 天 ——
|
|
||||||
for i := days - 1; i >= 0; i-- {
|
|
||||||
d := today.AddDate(0, 0, -i)
|
|
||||||
k := dayKey(d)
|
|
||||||
label := d.Format("01-02")
|
|
||||||
newN := 0
|
|
||||||
for _, sk := range signup {
|
|
||||||
if sk == k {
|
|
||||||
newN++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.NewTrend = append(out.NewTrend, DayPoint{Date: label, Count: newN})
|
|
||||||
out.ActiveTrend = append(out.ActiveTrend, DayPoint{Date: label, Count: len(dayUsers[k])})
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 月活:近 6 个月 ——
|
|
||||||
for i := 5; i >= 0; i-- {
|
|
||||||
mStart := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, loc).AddDate(0, -i, 0)
|
|
||||||
mEnd := mStart.AddDate(0, 1, -1)
|
|
||||||
out.MauTrend = append(out.MauTrend, DayPoint{
|
|
||||||
Date: mStart.Format("2006-01"),
|
|
||||||
Count: distinctInRange(mStart, mEnd),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 留存曲线:注册后第 K 天仍活跃(滚动口径:K 天当天或之后还有活跃)——
|
|
||||||
// 分母只算“年龄”够 K 天、且在观测窗内的用户
|
|
||||||
offsets := []int{1, 3, 7, 14, 30}
|
|
||||||
minSignup := dayKey(windowStart)
|
|
||||||
for _, K := range offsets {
|
|
||||||
base, retained := 0, 0
|
|
||||||
for id, sk := range signup {
|
|
||||||
if sk < minSignup {
|
|
||||||
continue // 太老,活跃窗看不全
|
|
||||||
}
|
|
||||||
plusK := dayKey(signupTime[id].AddDate(0, 0, K))
|
|
||||||
if plusK > todayKey {
|
|
||||||
continue // 还没到第 K 天,不够“年龄”
|
|
||||||
}
|
|
||||||
base++
|
|
||||||
for ak := range userActive[id] {
|
|
||||||
if ak >= plusK {
|
|
||||||
retained++
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rate := 0.0
|
|
||||||
if base > 0 {
|
|
||||||
rate = float64(retained) / float64(base)
|
|
||||||
}
|
|
||||||
out.Retention = append(out.Retention, RetPoint{Day: K, Rate: rate, Base: base})
|
|
||||||
}
|
|
||||||
|
|
||||||
var pets, posts, records int64
|
|
||||||
s.db.Model(&model.Pet{}).Count(&pets)
|
|
||||||
s.db.Model(&model.Post{}).Count(&posts)
|
|
||||||
s.db.Model(&model.HealthRecord{}).Count(&records)
|
|
||||||
out.Summary.TotalPets = int(pets)
|
|
||||||
out.Summary.TotalPosts = int(posts)
|
|
||||||
out.Summary.TotalRecords = int(records)
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AIChat 记录一问一答:启用模型则真调(注入宠物档案),否则规则化文案
|
|
||||||
func (s *Service) AIChat(userID string, petID *string, session, text string) (string, error) {
|
|
||||||
// 先扣额度再请求大模型:失败也算用掉一次,否则刷接口空转照样烧钱
|
|
||||||
if err := s.consumeAIQuota(userID, aiKindChat); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
reply := "我会先判断风险等级,再建议你记录关键观察项。若出现频繁呕吐、便血、精神明显变差或持续超过 24 小时,建议尽快就医。"
|
|
||||||
if s.ai != nil && s.ai.Enabled() {
|
|
||||||
if r, err := s.llmChat(petID, text); err == nil && r != "" {
|
|
||||||
reply = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
msgs := []model.AIMessage{
|
|
||||||
{UserID: userID, PetID: petID, Session: session, Role: "user", Text: text},
|
|
||||||
{UserID: userID, PetID: petID, Session: session, Role: "ai", Text: reply},
|
|
||||||
}
|
|
||||||
if err := s.db.Create(&msgs).Error; err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return reply, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upload 代理到对象存储
|
|
||||||
func (s *Service) Upload(objectName string, reader io.Reader, size int64, contentType string) (string, error) {
|
|
||||||
return s.storage.Upload(objectName, reader, size, contentType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAIMessages 取当前用户的聊天历史(按会话,默认最近 N 条,返回时间正序)
|
|
||||||
func (s *Service) ListAIMessages(userID, session string, limit int) ([]model.AIMessage, error) {
|
|
||||||
if limit <= 0 || limit > 100 {
|
|
||||||
limit = 30
|
|
||||||
}
|
|
||||||
q := s.db.Where("user_id = ?", userID)
|
|
||||||
if session != "" {
|
|
||||||
q = q.Where("session = ?", session)
|
|
||||||
}
|
|
||||||
var msgs []model.AIMessage
|
|
||||||
if err := q.Order("id desc").Limit(limit).Find(&msgs).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 反转成时间正序,前端直接按顺序渲染
|
|
||||||
for i, j := 0, len(msgs)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
msgs[i], msgs[j] = msgs[j], msgs[i]
|
|
||||||
}
|
|
||||||
return msgs, nil
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/ai"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
|
|
||||||
const aiSafetyPrompt = `你是「肉垫计划」的养宠助手,服务新手猫狗主人。要求:
|
|
||||||
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
|
|
||||||
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
|
|
||||||
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
|
|
||||||
4. 语气亲切、简洁,用中文,避免长篇大论。`
|
|
||||||
|
|
||||||
// petBrief 组装宠物档案 + 近期记录的上下文文本
|
|
||||||
func (s *Service) petBrief(petID string) string {
|
|
||||||
var pet model.Pet
|
|
||||||
if err := s.db.First(&pet, petID).Error; err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var records []model.HealthRecord
|
|
||||||
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
fmt.Fprintf(&b, "宠物档案:%s(%s,%s,%s,当前体重%s)。\n",
|
|
||||||
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
|
|
||||||
if len(records) > 0 {
|
|
||||||
b.WriteString("近期记录:")
|
|
||||||
items := make([]string, 0, len(records))
|
|
||||||
for _, r := range records {
|
|
||||||
items = append(items, r.Title)
|
|
||||||
}
|
|
||||||
b.WriteString(strings.Join(items, ";"))
|
|
||||||
b.WriteString("。")
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// llmChat 真实模型聊天回复
|
|
||||||
func (s *Service) llmChat(petID *string, text string) (string, error) {
|
|
||||||
system := aiSafetyPrompt
|
|
||||||
if petID != nil {
|
|
||||||
if brief := s.petBrief(*petID); brief != "" {
|
|
||||||
system += "\n\n" + brief
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SymptomInput 异常观察入参
|
|
||||||
type SymptomInput struct {
|
|
||||||
Symptoms []string
|
|
||||||
Duration string
|
|
||||||
Spirit string
|
|
||||||
}
|
|
||||||
|
|
||||||
// SymptomResult 风险评估结果(对应前端 risk 弹层)
|
|
||||||
type SymptomResult struct {
|
|
||||||
RiskLevel string `json:"risk_level"` // 低/中/高
|
|
||||||
Causes string `json:"causes"`
|
|
||||||
Suggestion string `json:"suggestion"`
|
|
||||||
SeekCare string `json:"seek_care"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
|
|
||||||
func (s *Service) AssessSymptom(userID, petID string, in SymptomInput) (*SymptomResult, error) {
|
|
||||||
if err := s.consumeAIQuota(userID, aiKindSymptom); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := s.ownedPet(userID, petID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 规则化兜底
|
|
||||||
fallback := &SymptomResult{
|
|
||||||
RiskLevel: "中",
|
|
||||||
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
|
|
||||||
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
|
|
||||||
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
|
|
||||||
}
|
|
||||||
if s.ai == nil || !s.ai.Enabled() {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
|
|
||||||
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
|
|
||||||
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
|
|
||||||
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
|
|
||||||
|
|
||||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
|
||||||
if err != nil {
|
|
||||||
return fallback, nil // 模型故障不阻断,回退
|
|
||||||
}
|
|
||||||
var res SymptomResult
|
|
||||||
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
|
|
||||||
return fallback, nil
|
|
||||||
}
|
|
||||||
return &res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
|
|
||||||
type aiPlanExtracted struct {
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
Risk string `json:"risk"`
|
|
||||||
Priority string `json:"priority"`
|
|
||||||
Reminder string `json:"reminder"`
|
|
||||||
Tasks []struct {
|
|
||||||
DayLabel string `json:"day_label"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
} `json:"tasks"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
|
|
||||||
if s.ai == nil || !s.ai.Enabled() {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
|
||||||
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
|
|
||||||
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
|
|
||||||
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]},tasks 3-5 条。`
|
|
||||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
|
|
||||||
if err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
var ex aiPlanExtracted
|
|
||||||
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return &ex, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
|
|
||||||
func extractJSON(s string) string {
|
|
||||||
i := strings.IndexByte(s, '{')
|
|
||||||
j := strings.LastIndexByte(s, '}')
|
|
||||||
if i >= 0 && j > i {
|
|
||||||
return s[i : j+1]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrAIQuotaExceeded 今日 AI 次数用完
|
|
||||||
var ErrAIQuotaExceeded = errors.New("今日 AI 次数已用完")
|
|
||||||
|
|
||||||
// AI 调用是真金白银,不设上限等于把钱包交给用户。
|
|
||||||
// 额度按「用户 + 自然日」计,从后台配置读,改完立刻生效。
|
|
||||||
const (
|
|
||||||
aiKindChat = "chat" // 问问 AI
|
|
||||||
aiKindSymptom = "symptom" // 异常观察评估
|
|
||||||
aiKindPlan = "plan" // AI 生成计划
|
|
||||||
)
|
|
||||||
|
|
||||||
// aiQuotaConfig 从后台配置取每日额度。取不到就用保守默认值,
|
|
||||||
// 绝不「取不到就不限制」——那正是配置出问题时最不该发生的事。
|
|
||||||
func (s *Service) aiQuotaConfig() model.AIQuotaConfig {
|
|
||||||
var c model.AIQuotaConfig
|
|
||||||
if err := s.db.First(&c, "id = ?", "1").Error; err != nil {
|
|
||||||
return model.AIQuotaConfig{DailyChat: 20, DailySymptom: 10, DailyPlan: 5, Enabled: true}
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// aiDailyLimit 某类 AI 功能的每日上限;<=0 表示不限
|
|
||||||
func (s *Service) aiDailyLimit(kind string) (int, bool) {
|
|
||||||
c := s.aiQuotaConfig()
|
|
||||||
if !c.Enabled {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
switch kind {
|
|
||||||
case aiKindChat:
|
|
||||||
return c.DailyChat, true
|
|
||||||
case aiKindSymptom:
|
|
||||||
return c.DailySymptom, true
|
|
||||||
case aiKindPlan:
|
|
||||||
return c.DailyPlan, true
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// consumeAIQuota 扣一次额度。超限返回 ErrAIQuotaExceeded,调用方要在真正
|
|
||||||
// 请求大模型之前调用它——扣完再调,失败也算用掉一次,避免刷接口空转烧钱。
|
|
||||||
func (s *Service) consumeAIQuota(userID, kind string) error {
|
|
||||||
limit, on := s.aiDailyLimit(kind)
|
|
||||||
if !on || limit <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
day := time.Now().Format("2006-01-02")
|
|
||||||
var u model.AIUsage
|
|
||||||
err := s.db.Where("user_id = ? AND day = ? AND kind = ?", userID, day, kind).First(&u).Error
|
|
||||||
if err == nil {
|
|
||||||
if u.Count >= limit {
|
|
||||||
return fmt.Errorf("%w(每天 %d 次,明天恢复)", ErrAIQuotaExceeded, limit)
|
|
||||||
}
|
|
||||||
return s.db.Model(&model.AIUsage{}).Where("id = ?", u.ID).
|
|
||||||
UpdateColumn("count", u.Count+1).Error
|
|
||||||
}
|
|
||||||
return s.db.Create(&model.AIUsage{UserID: userID, Day: day, Kind: kind, Count: 1}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// AIQuotaLeft 返回各类今日剩余次数,给小程序显示
|
|
||||||
func (s *Service) AIQuotaLeft(userID string) map[string]any {
|
|
||||||
c := s.aiQuotaConfig()
|
|
||||||
day := time.Now().Format("2006-01-02")
|
|
||||||
var rows []model.AIUsage
|
|
||||||
s.db.Where("user_id = ? AND day = ?", userID, day).Find(&rows)
|
|
||||||
used := map[string]int{}
|
|
||||||
for _, r := range rows {
|
|
||||||
used[r.Kind] = r.Count
|
|
||||||
}
|
|
||||||
left := func(limit int, kind string) int {
|
|
||||||
if !c.Enabled || limit <= 0 {
|
|
||||||
return -1 // -1 表示不限
|
|
||||||
}
|
|
||||||
if n := limit - used[kind]; n > 0 {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return map[string]any{
|
|
||||||
"enabled": c.Enabled,
|
|
||||||
"chat": left(c.DailyChat, aiKindChat),
|
|
||||||
"symptom": left(c.DailySymptom, aiKindSymptom),
|
|
||||||
"plan": left(c.DailyPlan, aiKindPlan),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAIQuotaConfig / SaveAIQuotaConfig 后台读写
|
|
||||||
func (s *Service) GetAIQuotaConfig() model.AIQuotaConfig { return s.aiQuotaConfig() }
|
|
||||||
|
|
||||||
func (s *Service) SaveAIQuotaConfig(in model.AIQuotaConfig) (model.AIQuotaConfig, error) {
|
|
||||||
c := s.aiQuotaConfig()
|
|
||||||
c.ID = "1"
|
|
||||||
c.Enabled = in.Enabled
|
|
||||||
c.DailyChat = maxInt(in.DailyChat, 0)
|
|
||||||
c.DailySymptom = maxInt(in.DailySymptom, 0)
|
|
||||||
c.DailyPlan = maxInt(in.DailyPlan, 0)
|
|
||||||
if err := s.db.Save(&c).Error; err != nil {
|
|
||||||
return c, err
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ListArticles 已发布文章
|
|
||||||
func (s *Service) ListArticles() ([]model.Article, error) {
|
|
||||||
var articles []model.Article
|
|
||||||
err := s.db.Where("published = ?", true).Order("id asc").Find(&articles).Error
|
|
||||||
return articles, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetArticle 文章详情
|
|
||||||
func (s *Service) GetArticle(id string) (*model.Article, error) {
|
|
||||||
var a model.Article
|
|
||||||
if err := s.db.First(&a, id).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &a, nil
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockLogin 开发态登录:按昵称找回或新建用户
|
|
||||||
func (s *Service) MockLogin(nickname string) (*model.User, error) {
|
|
||||||
autoName := nickname == ""
|
|
||||||
if autoName {
|
|
||||||
nickname = s.UniqueNickname()
|
|
||||||
}
|
|
||||||
var u model.User
|
|
||||||
err := s.db.Where("nickname = ?", nickname).First(&u).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
u = model.User{Nickname: nickname, NicknameAuto: autoName}
|
|
||||||
if e := s.db.Create(&u).Error; e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// code2SessionResp 微信 jscode2session 返回
|
|
||||||
type code2SessionResp struct {
|
|
||||||
OpenID string `json:"openid"`
|
|
||||||
SessionKey string `json:"session_key"`
|
|
||||||
UnionID string `json:"unionid"`
|
|
||||||
ErrCode int `json:"errcode"`
|
|
||||||
ErrMsg string `json:"errmsg"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// code2Session 调用微信换取 openid
|
|
||||||
func (s *Service) code2Session(code string) (*code2SessionResp, error) {
|
|
||||||
if s.cfg.WeChat.AppID == "" || s.cfg.WeChat.AppSecret == "" {
|
|
||||||
return nil, errors.New("微信登录未配置 app_id / app_secret")
|
|
||||||
}
|
|
||||||
q := url.Values{}
|
|
||||||
q.Set("appid", s.cfg.WeChat.AppID)
|
|
||||||
q.Set("secret", s.cfg.WeChat.AppSecret)
|
|
||||||
q.Set("js_code", code)
|
|
||||||
q.Set("grant_type", "authorization_code")
|
|
||||||
endpoint := "https://api.weixin.qq.com/sns/jscode2session?" + q.Encode()
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
var out code2SessionResp
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if out.ErrCode != 0 {
|
|
||||||
return nil, fmt.Errorf("微信登录失败(%d): %s", out.ErrCode, out.ErrMsg)
|
|
||||||
}
|
|
||||||
if out.OpenID == "" {
|
|
||||||
return nil, errors.New("微信未返回 openid")
|
|
||||||
}
|
|
||||||
return &out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatLogin 微信小程序登录:code → openid → 找回/新建用户
|
|
||||||
func (s *Service) WechatLogin(code string) (*model.User, error) {
|
|
||||||
sess, err := s.code2Session(code)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var u model.User
|
|
||||||
err = s.db.Where("open_id = ?", sess.OpenID).First(&u).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
u = model.User{OpenID: sess.OpenID, Nickname: s.UniqueNickname(), NicknameAuto: true}
|
|
||||||
if e := s.db.Create(&u).Error; e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUser 取用户
|
|
||||||
func (s *Service) GetUser(userID string) (*model.User, error) {
|
|
||||||
var u model.User
|
|
||||||
if err := s.db.First(&u, userID).Error; err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
u.AvatarURL = s.fileURL(u.AvatarFileID)
|
|
||||||
u.BgURL = s.fileURL(u.BgFileID)
|
|
||||||
if !UserThemes[u.Theme] {
|
|
||||||
u.Theme = defaultUserTheme // 老用户 theme 是空串,别让前端拿空 key 去查表
|
|
||||||
}
|
|
||||||
return &u, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateUser 更新用户资料(昵称/头像/手机号)
|
|
||||||
func (s *Service) UpdateUser(userID string, fields map[string]any) (*model.User, error) {
|
|
||||||
if err := s.db.Model(&model.User{}).Where("id = ?", userID).Updates(fields).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return s.GetUser(userID)
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// BreedGroup 同一首字母下的品种。小程序端的 A-Z 索引直接照这个渲染
|
|
||||||
type BreedGroup struct {
|
|
||||||
Initial string `json:"initial"`
|
|
||||||
Items []model.Breed `json:"items"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBreeds 某个物种的品种,按首字母分组。只返回启用的
|
|
||||||
func (s *Service) ListBreeds(species string) []BreedGroup {
|
|
||||||
if species != model.SpeciesCat && species != model.SpeciesDog {
|
|
||||||
return []BreedGroup{}
|
|
||||||
}
|
|
||||||
var all []model.Breed
|
|
||||||
s.db.Where("species = ? AND enabled = ?", species, true).
|
|
||||||
Order("initial asc, sort asc, id asc").Find(&all)
|
|
||||||
if len(all) == 0 {
|
|
||||||
// 不硬编码兜底一份:那等于把「后台可配」又变回代码常量,
|
|
||||||
// 改了配置的人会发现改了没用。宁可让选择器明显是空的
|
|
||||||
log.Printf("[warn] %s 没有任何启用的品种,建档第二步会选不了品种", species)
|
|
||||||
return []BreedGroup{}
|
|
||||||
}
|
|
||||||
byInitial := map[string][]model.Breed{}
|
|
||||||
for _, b := range all {
|
|
||||||
k := strings.ToUpper(b.Initial)
|
|
||||||
if k == "" {
|
|
||||||
k = "#" // 没填首字母的兜到最后,别让它凭空消失
|
|
||||||
}
|
|
||||||
byInitial[k] = append(byInitial[k], b)
|
|
||||||
}
|
|
||||||
keys := make([]string, 0, len(byInitial))
|
|
||||||
for k := range byInitial {
|
|
||||||
keys = append(keys, k)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
out := make([]BreedGroup, 0, len(keys))
|
|
||||||
for _, k := range keys {
|
|
||||||
out = append(out, BreedGroup{Initial: k, Items: byInitial[k]})
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminListBreeds 后台用:连停用的一起返回
|
|
||||||
func (s *Service) AdminListBreeds(species string) []model.Breed {
|
|
||||||
q := s.db.Order("species asc, initial asc, sort asc, id asc")
|
|
||||||
if species == model.SpeciesCat || species == model.SpeciesDog {
|
|
||||||
q = q.Where("species = ?", species)
|
|
||||||
}
|
|
||||||
var all []model.Breed
|
|
||||||
q.Find(&all)
|
|
||||||
if all == nil {
|
|
||||||
return []model.Breed{}
|
|
||||||
}
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveBreed 新增或更新
|
|
||||||
func (s *Service) SaveBreed(in *model.Breed) error {
|
|
||||||
in.Name = strings.TrimSpace(in.Name)
|
|
||||||
in.Initial = strings.ToUpper(strings.TrimSpace(in.Initial))
|
|
||||||
if in.Name == "" {
|
|
||||||
return ErrInvalidParam
|
|
||||||
}
|
|
||||||
if in.Species != model.SpeciesCat && in.Species != model.SpeciesDog {
|
|
||||||
return errors.New("物种只能是 cat 或 dog")
|
|
||||||
}
|
|
||||||
if len(in.Initial) != 1 || in.Initial[0] < 'A' || in.Initial[0] > 'Z' {
|
|
||||||
return errors.New("首字母要填一个 A-Z 的字母(用来做侧边索引)")
|
|
||||||
}
|
|
||||||
// 同物种下不允许同名:品种是拿来做统计口径的,重名会把同一种猫劈成两半
|
|
||||||
var n int64
|
|
||||||
q := s.db.Model(&model.Breed{}).Where("species = ? AND name = ?", in.Species, in.Name)
|
|
||||||
if in.ID != "" {
|
|
||||||
q = q.Where("id <> ?", in.ID)
|
|
||||||
}
|
|
||||||
q.Count(&n)
|
|
||||||
if n > 0 {
|
|
||||||
return errors.New("这个物种下已经有「" + in.Name + "」了")
|
|
||||||
}
|
|
||||||
|
|
||||||
if in.ID == "" {
|
|
||||||
return s.db.Create(in).Error
|
|
||||||
}
|
|
||||||
return s.db.Model(&model.Breed{}).Where("id = ?", in.ID).Updates(map[string]any{
|
|
||||||
"species": in.Species, "name": in.Name, "initial": in.Initial,
|
|
||||||
"sort": in.Sort, "enabled": in.Enabled,
|
|
||||||
}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteBreed 已经有宠物在用的品种不许删——删了那些档案的品种就变成一个
|
|
||||||
// 查不到的字符串。想让它不再被选到应该用 enabled=false
|
|
||||||
func (s *Service) DeleteBreed(id string) error {
|
|
||||||
var b model.Breed
|
|
||||||
if err := s.db.First(&b, "id = ?", id).Error; err != nil {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
var used int64
|
|
||||||
s.db.Model(&model.Pet{}).Where("breed = ?", b.Name).Count(&used)
|
|
||||||
if used > 0 {
|
|
||||||
return errors.New("已经有 " + strconv.FormatInt(used, 10) + " 个档案填了「" +
|
|
||||||
b.Name + "」,删了它们的品种就查不到了。想隐藏请改成停用")
|
|
||||||
}
|
|
||||||
return s.db.Delete(&model.Breed{}, "id = ?", id).Error
|
|
||||||
}
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 套用养护方案:把模板里的重复规则展开成某个月的计划节点。
|
|
||||||
//
|
|
||||||
// 为什么要展开成一条条实体行、而不是「存规则、读时展开」:
|
|
||||||
// 勾选状态是按天的。存规则的话得再开一张表记「哪天打过勾」,
|
|
||||||
// 而且用户改一条(比如把「每周二梳毛」这周挪到周三)就要引入例外记录。
|
|
||||||
// 一次展开成行,增删改和打勾全都免费。
|
|
||||||
//
|
|
||||||
// 代价是套用之后模板和计划就断开了:后台改模板不会影响已套用的宠物。
|
|
||||||
// 这是有意的——用户已经在自己的计划上勾了几十个勾,后台一改就被覆盖才是灾难。
|
|
||||||
|
|
||||||
// TemplateOption 建档/计划页上给用户挑的一套方案
|
|
||||||
type TemplateOption struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
// 按重复方式分开报数。只给一个总数会骗人:10 条明细里有 4 条是「每天」,
|
|
||||||
// 套用一整月会展开成 130 条节点,用户看到「10 条」再看到 130 条会以为出 bug 了
|
|
||||||
Daily int `json:"daily"` // 每天做的
|
|
||||||
Weekly int `json:"weekly"` // 每周做的
|
|
||||||
Once int `json:"once"` // 一次性的
|
|
||||||
Monthly int `json:"monthly"` // 每月做的
|
|
||||||
Reminds int `json:"reminders"` // 会建的提醒
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTemplateOptions 某物种阶段下可选的方案。空列表意味着后台没配,
|
|
||||||
// 前端要显示「暂无方案,可以自己排」而不是转圈
|
|
||||||
func (s *Service) ListTemplateOptions(species, stage string) []TemplateOption {
|
|
||||||
var tpls []model.CareTemplate
|
|
||||||
s.db.Preload("Items").
|
|
||||||
Where("species = ? AND stage = ? AND enabled = ?", species, stage, true).
|
|
||||||
Order("sort asc, id asc").Find(&tpls)
|
|
||||||
out := make([]TemplateOption, 0, len(tpls))
|
|
||||||
for _, t := range tpls {
|
|
||||||
o := TemplateOption{ID: t.ID, Name: t.Name, Summary: t.Summary}
|
|
||||||
if o.Name == "" {
|
|
||||||
o.Name = t.Stage + "养护方案" // 老数据没名字,兜一个别显示空白
|
|
||||||
}
|
|
||||||
for _, it := range t.Items {
|
|
||||||
switch {
|
|
||||||
case it.Kind == model.CareKindReminder:
|
|
||||||
o.Reminds++
|
|
||||||
case it.Repeat == model.RepeatDaily:
|
|
||||||
o.Daily++
|
|
||||||
case it.Repeat == model.RepeatWeekly:
|
|
||||||
o.Weekly++
|
|
||||||
case it.Repeat == model.RepeatMonthly:
|
|
||||||
o.Monthly++
|
|
||||||
default:
|
|
||||||
o.Once++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out = append(out, o)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// weekdaysOf 解析 "1,3,5" → {1:true,3:true,5:true},1=周一
|
|
||||||
func weekdaysOf(s string) map[int]bool {
|
|
||||||
out := map[int]bool{}
|
|
||||||
for _, p := range strings.Split(s, ",") {
|
|
||||||
n, err := strconv.Atoi(strings.TrimSpace(p))
|
|
||||||
if err == nil && n >= 1 && n <= 7 {
|
|
||||||
out[n] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// isoWeekday 把 Go 的 Sunday=0 换成 1=周一 … 7=周日
|
|
||||||
func isoWeekday(t time.Time) int {
|
|
||||||
d := int(t.Weekday())
|
|
||||||
if d == 0 {
|
|
||||||
return 7
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// expandItem 把一条明细展开成若干个日期。
|
|
||||||
// from/to 是套用窗口(通常是「今天」到「本月最后一天」)
|
|
||||||
func expandItem(it model.CareTemplateItem, from, to time.Time) []time.Time {
|
|
||||||
var out []time.Time
|
|
||||||
switch it.Repeat {
|
|
||||||
case model.RepeatDaily:
|
|
||||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
case model.RepeatWeekly:
|
|
||||||
wd := weekdaysOf(it.Weekdays)
|
|
||||||
if len(wd) == 0 {
|
|
||||||
return nil // 配了每周却没选星期几,展开不出东西,跳过比排错天好
|
|
||||||
}
|
|
||||||
for d := from; !d.After(to); d = d.AddDate(0, 0, 1) {
|
|
||||||
if wd[isoWeekday(d)] {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case model.RepeatMonthly:
|
|
||||||
md := it.MonthDay
|
|
||||||
if md < 1 {
|
|
||||||
md = 1
|
|
||||||
}
|
|
||||||
// 逐月走,因为窗口可能跨月
|
|
||||||
for cur := time.Date(from.Year(), from.Month(), 1, 0, 0, 0, 0, from.Location()); !cur.After(to); cur = cur.AddDate(0, 1, 0) {
|
|
||||||
last := cur.AddDate(0, 1, -1).Day()
|
|
||||||
day := md
|
|
||||||
if day > last {
|
|
||||||
day = last // 「每月 31 号」在 2 月按月末算,不然这个月直接没有
|
|
||||||
}
|
|
||||||
d := time.Date(cur.Year(), cur.Month(), day, 0, 0, 0, 0, from.Location())
|
|
||||||
if !d.Before(from) && !d.After(to) {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
default: // once:从窗口起点偏移 N 天
|
|
||||||
d := from.AddDate(0, 0, it.Day)
|
|
||||||
// 偏移出窗口的直接丢掉。硬塞到窗口末尾会让「到家第 14 天驱虫」
|
|
||||||
// 变成「月底驱虫」,日期错了比没有更糟
|
|
||||||
if !d.Before(from) && !d.After(to) {
|
|
||||||
out = append(out, d)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyTemplateResult 套用结果,给前端一句反馈用
|
|
||||||
type ApplyTemplateResult struct {
|
|
||||||
Plans int `json:"plans"` // 排进了几条计划
|
|
||||||
Reminders int `json:"reminders"` // 建了几条提醒
|
|
||||||
From string `json:"from"`
|
|
||||||
To string `json:"to"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplyTemplate 把一套方案套用到某只宠物的某个月。
|
|
||||||
// monthOffset: 0 本月,1 下月。本月只从今天算起——给用户排一堆昨天的事没意义
|
|
||||||
func (s *Service) ApplyTemplate(userID, petID, templateID string, monthOffset int) (*ApplyTemplateResult, error) {
|
|
||||||
pet, err := s.ownedPet(userID, petID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var tpl model.CareTemplate
|
|
||||||
if e := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("sort, id") }).
|
|
||||||
First(&tpl, "id = ?", templateID).Error; e != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
first, last := monthBounds(monthOffset)
|
|
||||||
from := first
|
|
||||||
if monthOffset == 0 {
|
|
||||||
today := time.Now()
|
|
||||||
t := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
|
|
||||||
if t.After(from) {
|
|
||||||
from = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if from.After(last) {
|
|
||||||
return nil, errors.New("这个月已经过完了")
|
|
||||||
}
|
|
||||||
|
|
||||||
plan, err := s.planOf(userID, petID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
start := time.Date(plan.StartDate.Year(), plan.StartDate.Month(), plan.StartDate.Day(),
|
|
||||||
0, 0, 0, 0, time.Local)
|
|
||||||
|
|
||||||
// 已经有的节点按「日期+标题」记下来,套用不产生重复。
|
|
||||||
// 用户可能先自己排了「每天喂药」,再套一套含同样一条的方案
|
|
||||||
var exist []model.PlanTask
|
|
||||||
s.db.Where("plan_id = ?", plan.ID).Find(&exist)
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, t := range exist {
|
|
||||||
d := start.AddDate(0, 0, t.Day)
|
|
||||||
seen[d.Format("2006-01-02")+"|"+t.Title] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows []model.PlanTask
|
|
||||||
for _, it := range tpl.Items {
|
|
||||||
if it.Kind == model.CareKindReminder {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, d := range expandItem(it, from, last) {
|
|
||||||
key := d.Format("2006-01-02") + "|" + it.Title
|
|
||||||
if seen[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[key] = true
|
|
||||||
day := int(d.Sub(start).Hours() / 24)
|
|
||||||
if day < 0 {
|
|
||||||
continue // 早于计划开始日的排不进去
|
|
||||||
}
|
|
||||||
rows = append(rows, model.PlanTask{
|
|
||||||
PlanID: plan.ID, Day: day, DayLabel: dayLabelFor(day),
|
|
||||||
Title: it.Title, Description: it.Description, SheetType: it.SheetType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res := &ApplyTemplateResult{From: from.Format("2006-01-02"), To: last.Format("2006-01-02")}
|
|
||||||
if len(rows) > 0 {
|
|
||||||
if e := s.db.Create(&rows).Error; e != nil {
|
|
||||||
return nil, e
|
|
||||||
}
|
|
||||||
res.Plans = len(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提醒只在第一次套用时建:它是「到期提醒」,按物种阶段来就一份,
|
|
||||||
// 套两次方案不该出现两条疫苗提醒
|
|
||||||
res.Reminders = s.applyTemplateReminders(pet, tpl.Items)
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyTemplateReminders 建模板里的提醒,已存在同标题的跳过
|
|
||||||
func (s *Service) applyTemplateReminders(pet *model.Pet, items []model.CareTemplateItem) int {
|
|
||||||
var exist []model.Reminder
|
|
||||||
s.db.Where("pet_id = ?", pet.ID).Find(&exist)
|
|
||||||
seen := map[string]bool{}
|
|
||||||
for _, r := range exist {
|
|
||||||
seen[r.Title] = true
|
|
||||||
}
|
|
||||||
goals := petGoals(pet)
|
|
||||||
var rows []model.Reminder
|
|
||||||
for _, it := range items {
|
|
||||||
if it.Kind != model.CareKindReminder || seen[it.Title] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !goalWantsReminder(goals, it.ReminderType) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[it.Title] = true
|
|
||||||
r := model.Reminder{
|
|
||||||
PetID: pet.ID, UserID: pet.UserID, Type: it.ReminderType,
|
|
||||||
Title: it.Title, Frequency: it.Frequency,
|
|
||||||
}
|
|
||||||
if it.OffsetDays > 0 {
|
|
||||||
// dueDaysFor 会按宠物月龄调整(幼猫的「下一针」不该按成年猫的周期算)
|
|
||||||
due := time.Now().AddDate(0, 0, dueDaysFor(it, pet, time.Now()))
|
|
||||||
r.NextDueDate = &due
|
|
||||||
}
|
|
||||||
rows = append(rows, r)
|
|
||||||
}
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
if err := s.db.Create(&rows).Error; err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return len(rows)
|
|
||||||
}
|
|
||||||
@@ -1,258 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/ai"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 阶段常量、CareStages 和判定逻辑都搬到 life_stage.go 了:
|
|
||||||
// 那边一张表既驱动判定又直接吐给前端当「划分依据」,两个用途一份数据。
|
|
||||||
// 阶段从 3 段(幼年/成年/老年)细分成 5 段,狗还按体型走不同阈值。
|
|
||||||
|
|
||||||
// CareSpecies 支持的物种(字符串与 onboarding 一致)
|
|
||||||
var CareSpecies = []string{model.SpeciesCat, model.SpeciesDog}
|
|
||||||
|
|
||||||
|
|
||||||
// monthsSince 从某天到今天的整月数(不足一月算 0)
|
|
||||||
func monthsSince(t time.Time) int {
|
|
||||||
now := time.Now()
|
|
||||||
m := (now.Year()-t.Year())*12 + int(now.Month()) - int(t.Month())
|
|
||||||
if now.Day() < t.Day() {
|
|
||||||
m--
|
|
||||||
}
|
|
||||||
if m < 0 {
|
|
||||||
m = 0
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// SpeciesOf 由宠物类型(猫猫/狗狗)映射到模板物种键
|
|
||||||
func SpeciesOf(petType string) string {
|
|
||||||
if strings.Contains(petType, "狗") {
|
|
||||||
return model.SpeciesDog
|
|
||||||
}
|
|
||||||
return model.SpeciesCat
|
|
||||||
}
|
|
||||||
|
|
||||||
func validSpecies(s string) bool { return s == model.SpeciesCat || s == model.SpeciesDog }
|
|
||||||
func validStage(s string) bool {
|
|
||||||
for _, x := range CareStages {
|
|
||||||
if x == s {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// careTemplateItems 取某组合的模板明细。模板是后台配的内容数据,代码里不再留
|
|
||||||
// 一套内置默认——没配就是没配,这里返回空,由调用方决定怎么处理(见 seedPetDefaults)。
|
|
||||||
func (s *Service) careTemplateItems(species, stage string) []model.CareTemplateItem {
|
|
||||||
var tpl model.CareTemplate
|
|
||||||
err := s.db.
|
|
||||||
Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
|
|
||||||
Where("species = ? AND stage = ?", species, stage).First(&tpl).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return tpl.Items
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCareTemplate 后台读取:返回某组合的明细(DB 优先,无则内置默认)
|
|
||||||
// AdminListTemplates 后台:全部方案(含停用),按物种阶段排
|
|
||||||
func (s *Service) AdminListTemplates() []model.CareTemplate {
|
|
||||||
var all []model.CareTemplate
|
|
||||||
s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
|
|
||||||
Order("species asc, stage asc, sort asc, id asc").Find(&all)
|
|
||||||
if all == nil {
|
|
||||||
return []model.CareTemplate{}
|
|
||||||
}
|
|
||||||
return all
|
|
||||||
}
|
|
||||||
|
|
||||||
// AdminGetTemplate 后台:取一套
|
|
||||||
func (s *Service) AdminGetTemplate(id string) (*model.CareTemplate, error) {
|
|
||||||
var tpl model.CareTemplate
|
|
||||||
if err := s.db.Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("kind, sort, id") }).
|
|
||||||
First(&tpl, "id = ?", id).Error; err != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return &tpl, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteTemplate 删一套方案。已经套用出去的计划节点不受影响——
|
|
||||||
// 套用时就已经展开成实体行、和模板断开了
|
|
||||||
func (s *Service) DeleteTemplate(id string) error {
|
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("template_id = ?", id).Delete(&model.CareTemplateItem{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Delete(&model.CareTemplate{}, "id = ?", id).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveCareTemplate 后台保存:整表替换该组合的明细
|
|
||||||
// SaveTemplate 存一套方案。id 为空则新建 —— 同一个 (物种, 阶段) 可以有多套,
|
|
||||||
// 用户建档时挑一套。原来是「按 species+stage 覆盖」,那个模型下
|
|
||||||
// 「让用户选方案」根本无从选起
|
|
||||||
func (s *Service) SaveTemplate(in *model.CareTemplate, items []model.CareTemplateItem) error {
|
|
||||||
if !validSpecies(in.Species) || !validStage(in.Stage) {
|
|
||||||
return errors.New("物种或阶段不合法")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(in.Name) == "" {
|
|
||||||
return errors.New("方案要有名字 —— 用户建档时看到的就是它")
|
|
||||||
}
|
|
||||||
// 明细先校验一遍再落库:一条配错(比如「每周」没勾星期几)会让套用时
|
|
||||||
// 静默少排几条,用户看不出来是配错了
|
|
||||||
clean := make([]model.CareTemplateItem, 0, len(items))
|
|
||||||
for i := range items {
|
|
||||||
it := items[i]
|
|
||||||
it.Title = strings.TrimSpace(it.Title)
|
|
||||||
if it.Title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if it.Kind != model.CareKindReminder {
|
|
||||||
it.Kind = model.CareKindPlan
|
|
||||||
switch it.Repeat {
|
|
||||||
case model.RepeatDaily, model.RepeatMonthly:
|
|
||||||
case model.RepeatWeekly:
|
|
||||||
if len(weekdaysOf(it.Weekdays)) == 0 {
|
|
||||||
return errors.New("「" + it.Title + "」选了每周,但没勾星期几")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
it.Repeat = model.RepeatOnce
|
|
||||||
}
|
|
||||||
if it.Repeat == model.RepeatMonthly && (it.MonthDay < 1 || it.MonthDay > 31) {
|
|
||||||
return errors.New("「" + it.Title + "」选了每月,几号要填 1-31")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
it.ID = ""
|
|
||||||
it.Sort = i
|
|
||||||
clean = append(clean, it)
|
|
||||||
}
|
|
||||||
|
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if in.ID == "" {
|
|
||||||
if err := tx.Create(in).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := tx.Model(&model.CareTemplate{}).Where("id = ?", in.ID).Updates(map[string]any{
|
|
||||||
"species": in.Species, "stage": in.Stage, "name": in.Name,
|
|
||||||
"summary": in.Summary, "sort": in.Sort, "enabled": in.Enabled,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Where("template_id = ?", in.ID).Delete(&model.CareTemplateItem{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range clean {
|
|
||||||
clean[i].TemplateID = in.ID
|
|
||||||
}
|
|
||||||
if len(clean) > 0 {
|
|
||||||
return tx.Create(&clean).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateCareTemplate 后台「AI 生成草稿」:不落库,仅返回建议明细。
|
|
||||||
// AI 未开启或失败时回退内置默认,保证总有可用草稿。
|
|
||||||
func (s *Service) GenerateCareTemplate(species, stage string) ([]model.CareTemplateItem, error) {
|
|
||||||
if !validSpecies(species) || !validStage(stage) {
|
|
||||||
return nil, errors.New("invalid species or stage")
|
|
||||||
}
|
|
||||||
if !s.ai.Enabled() {
|
|
||||||
return nil, errors.New("AI 未开启,无法生成草稿;请手动添加明细")
|
|
||||||
}
|
|
||||||
spName := "猫"
|
|
||||||
if species == model.SpeciesDog {
|
|
||||||
spName = "狗"
|
|
||||||
}
|
|
||||||
system := aiSafetyPrompt + `
|
|
||||||
|
|
||||||
你现在为后台生成一套「养护模板」,用于新宠建档时自动生成任务/计划/提醒。
|
|
||||||
只输出 JSON,结构如下(不要多余文字):
|
|
||||||
{
|
|
||||||
"tasks": [{"title":"","description":"","priority":"重要或空","sheet_type":"weight|poop|food|symptom|vaccine|medicine 或空"}],
|
|
||||||
"plan": [{"day":0,"day_label":"今天","title":"","description":"","sheet_type":"weight|vaccine|medicine|report|taskDetail 或空"}],
|
|
||||||
"reminders":[{"reminder_type":"vaccine|deworm|weight|monthlyReport","title":"","offset_days":18,"frequency":"到期用天数则填0并写频率,如每周二五"}]
|
|
||||||
}
|
|
||||||
要求:tasks 3 条、plan 4 条、reminders 3-4 条;贴合该物种与阶段的真实照护节奏(如成年比幼年疫苗/驱虫间隔更长,狗驱虫比猫更频繁)。`
|
|
||||||
user := fmt.Sprintf("为【%s · %s】生成一套养护模板。", spName, stage)
|
|
||||||
|
|
||||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("AI 生成失败:%w", err)
|
|
||||||
}
|
|
||||||
items, ok := parseCareItems(out)
|
|
||||||
if !ok || len(items) == 0 {
|
|
||||||
return nil, errors.New("AI 返回的内容解析不出可用明细,请重试或手动添加")
|
|
||||||
}
|
|
||||||
return items, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseCareItems 解析 AI 返回的分组 JSON → 扁平明细
|
|
||||||
func parseCareItems(out string) ([]model.CareTemplateItem, bool) {
|
|
||||||
var raw struct {
|
|
||||||
Tasks []struct {
|
|
||||||
Title, Description, Priority, SheetType string
|
|
||||||
} `json:"tasks"`
|
|
||||||
Plan []struct {
|
|
||||||
Day int
|
|
||||||
DayLabel, Title, Description, SheetType string
|
|
||||||
} `json:"plan"`
|
|
||||||
Reminders []struct {
|
|
||||||
ReminderType, Title, Frequency string
|
|
||||||
OffsetDays int
|
|
||||||
} `json:"reminders"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(extractJSON(out)), &raw); err != nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
var items []model.CareTemplateItem
|
|
||||||
for _, t := range raw.Tasks {
|
|
||||||
if t.Title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, careTask(t.Title, t.Description, t.Priority, t.SheetType))
|
|
||||||
}
|
|
||||||
for _, p := range raw.Plan {
|
|
||||||
if p.Title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, carePlan(p.Day, p.DayLabel, p.Title, p.Description, p.SheetType))
|
|
||||||
}
|
|
||||||
for _, r := range raw.Reminders {
|
|
||||||
if r.Title == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
items = append(items, careReminder(r.ReminderType, r.Title, r.OffsetDays, r.Frequency))
|
|
||||||
}
|
|
||||||
return items, len(items) > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 明细构造小工具(供 AI 草稿解析用)----
|
|
||||||
|
|
||||||
// careTask 原来产出 kind=task(今日任务)。task 和 plan 已经合并——
|
|
||||||
// 「每天要做的事」就是「repeat=daily 的计划节点」,AI 草稿里那部分照这个走
|
|
||||||
func careTask(title, desc, priority, sheet string) model.CareTemplateItem {
|
|
||||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatDaily,
|
|
||||||
Title: title, Description: desc, Priority: priority, SheetType: sheet}
|
|
||||||
}
|
|
||||||
func carePlan(day int, label, title, desc, sheet string) model.CareTemplateItem {
|
|
||||||
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: model.RepeatOnce,
|
|
||||||
Day: day, DayLabel: label, Title: title, Description: desc, SheetType: sheet}
|
|
||||||
}
|
|
||||||
func careReminder(rtype, title string, offset int, freq string) model.CareTemplateItem {
|
|
||||||
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype, Title: title, OffsetDays: offset, Frequency: freq}
|
|
||||||
}
|
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// attachPostImages 把帖子的 ImageFileIDs 解析为可展示的 Images(URL 数组)。
|
|
||||||
// 机器人帖 ImageFileIDs 为空,保留其 Images(emoji)不变。
|
|
||||||
func (s *Service) attachPostImages(posts []model.Post) {
|
|
||||||
perPost := make([][]string, len(posts))
|
|
||||||
all := []string{}
|
|
||||||
for i := range posts {
|
|
||||||
if len(posts[i].ImageFileIDs) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var ids []string
|
|
||||||
if json.Unmarshal(posts[i].ImageFileIDs, &ids) == nil && len(ids) > 0 {
|
|
||||||
perPost[i] = ids
|
|
||||||
all = append(all, ids...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(all) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
urlMap := s.fileURLs(all)
|
|
||||||
for i := range posts {
|
|
||||||
if len(perPost[i]) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
urls := make([]string, 0, len(perPost[i]))
|
|
||||||
for _, id := range perPost[i] {
|
|
||||||
if u := urlMap[id]; u != "" {
|
|
||||||
urls = append(urls, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if b, err := json.Marshal(urls); err == nil {
|
|
||||||
posts[i].Images = datatypes.JSON(b)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// tabTag 将 feed tab 映射为标签过滤(空表示不过滤)
|
|
||||||
func tabTag(tab string) string {
|
|
||||||
switch tab {
|
|
||||||
case "新手求助":
|
|
||||||
return "新手求助"
|
|
||||||
case "晒宠":
|
|
||||||
return "晒宠"
|
|
||||||
case "经验":
|
|
||||||
return "经验"
|
|
||||||
default: // 推荐 / 关注
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListPosts 帖子分页列表。userID 用于「关注」tab 过滤和标记关注状态(可为空)。
|
|
||||||
func (s *Service) ListPosts(userID, tab string, offset, limit int) ([]model.Post, int64, error) {
|
|
||||||
q := s.db.Model(&model.Post{}).Where("status = ?", model.PostPublished)
|
|
||||||
if tag := tabTag(tab); tag != "" {
|
|
||||||
q = q.Where("JSON_CONTAINS(tags, ?)", `"`+tag+`"`)
|
|
||||||
}
|
|
||||||
if tab == "关注" {
|
|
||||||
ids := s.followeeIDs(userID)
|
|
||||||
if len(ids) == 0 {
|
|
||||||
// 一个都没关注:返回空列表,而不是退化成「推荐」
|
|
||||||
return []model.Post{}, 0, nil
|
|
||||||
}
|
|
||||||
q = q.Where("user_id IN ?", ids)
|
|
||||||
}
|
|
||||||
var total int64
|
|
||||||
if err := q.Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
var posts []model.Post
|
|
||||||
if err := q.Order("id desc").Offset(offset).Limit(limit).Find(&posts).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
s.attachPostImages(posts)
|
|
||||||
s.markFollowed(userID, posts)
|
|
||||||
return posts, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPost 帖子详情
|
|
||||||
func (s *Service) GetPost(postID string) (*model.Post, error) {
|
|
||||||
var p model.Post
|
|
||||||
if err := s.db.First(&p, postID).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
one := []model.Post{p}
|
|
||||||
s.attachPostImages(one)
|
|
||||||
return &one[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostInput 发帖入参
|
|
||||||
type PostInput struct {
|
|
||||||
PetID *string
|
|
||||||
Identity string
|
|
||||||
Content string
|
|
||||||
Tags datatypes.JSON
|
|
||||||
Images datatypes.JSON
|
|
||||||
ImageFileIDs datatypes.JSON
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreatePost 发帖
|
|
||||||
func (s *Service) CreatePost(userID string, in PostInput) (*model.Post, error) {
|
|
||||||
// 取作者信息:统一用发布者昵称(去掉了发布身份选择),openid 给内容安全接口用
|
|
||||||
var user model.User
|
|
||||||
s.db.Select("id, open_id, nickname").First(&user, userID)
|
|
||||||
authorName := user.Nickname
|
|
||||||
if authorName == "" {
|
|
||||||
authorName = "宠友"
|
|
||||||
}
|
|
||||||
authorEmoji := "🐾"
|
|
||||||
imgURLs := s.resolveImageURLs(in.ImageFileIDs)
|
|
||||||
|
|
||||||
// 发布前过内容安全:文本同步判、图片提交异步查。检测不了或有图 → 待人工/待回调
|
|
||||||
status, _ := s.moderateInitial(in.Content, user.OpenID, 3, len(imgURLs) > 0)
|
|
||||||
|
|
||||||
p := model.Post{
|
|
||||||
UserID: userID, PetID: in.PetID, AuthorName: authorName, AuthorEmoji: authorEmoji,
|
|
||||||
Identity: in.Identity, Content: in.Content, Tags: in.Tags, Images: in.Images,
|
|
||||||
ImageFileIDs: in.ImageFileIDs, Status: status,
|
|
||||||
}
|
|
||||||
if err := s.db.Create(&p).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 文本已过、但有图 → 逐张提交异步检测,结果回调再决定放出/打回
|
|
||||||
if status == model.PostPending && len(imgURLs) > 0 {
|
|
||||||
s.submitImageChecks("post", p.ID, user.OpenID, imgURLs, 3)
|
|
||||||
}
|
|
||||||
one := []model.Post{p}
|
|
||||||
s.attachPostImages(one)
|
|
||||||
return &one[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveImageURLs 把 file id 数组(JSON)解析成可访问 URL 列表
|
|
||||||
func (s *Service) resolveImageURLs(fileIDs datatypes.JSON) []string {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var ids []string
|
|
||||||
if json.Unmarshal(fileIDs, &ids) != nil || len(ids) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
m := s.fileURLs(ids)
|
|
||||||
urls := make([]string, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
if u := m[id]; u != "" {
|
|
||||||
urls = append(urls, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return urls
|
|
||||||
}
|
|
||||||
|
|
||||||
// LikePost 点赞(幂等:已赞则不重复计数)
|
|
||||||
func (s *Service) LikePost(userID, postID string) (int, error) {
|
|
||||||
if _, err := s.GetPost(postID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
like := model.PostLike{PostID: postID, UserID: userID}
|
|
||||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).FirstOrCreate(&like)
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 1 { // 新建才计数
|
|
||||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
|
||||||
UpdateColumn("like_count", gorm.Expr("like_count + 1")).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.postLikeCount(postID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlikePost 取消点赞
|
|
||||||
func (s *Service) UnlikePost(userID, postID string) (int, error) {
|
|
||||||
if _, err := s.GetPost(postID); err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
res := tx.Where("post_id = ? AND user_id = ?", postID, userID).Delete(&model.PostLike{})
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 1 {
|
|
||||||
return tx.Model(&model.Post{}).Where("id = ? AND like_count > 0", postID).
|
|
||||||
UpdateColumn("like_count", gorm.Expr("like_count - 1")).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return s.postLikeCount(postID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) postLikeCount(postID string) (int, error) {
|
|
||||||
var p model.Post
|
|
||||||
if err := s.db.Select("like_count").First(&p, postID).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return p.LikeCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListComments 评论分页
|
|
||||||
func (s *Service) ListComments(userID, postID string, offset, limit int) ([]model.Comment, int64, error) {
|
|
||||||
base := func() *gorm.DB {
|
|
||||||
return s.db.Model(&model.Comment{}).Where("post_id = ? AND status = ?", postID, "published")
|
|
||||||
}
|
|
||||||
// total 是这条帖子的评论总数(含回复),和帖子上显示的数字对得上
|
|
||||||
var total int64
|
|
||||||
if err := base().Count(&total).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
// 只分页一级评论,回复跟着它爹一起返回
|
|
||||||
var roots []model.Comment
|
|
||||||
if err := base().Where("parent_id = ? OR parent_id IS NULL", "").
|
|
||||||
Order("id desc").Offset(offset).Limit(limit).Find(&roots).Error; err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
if len(roots) == 0 {
|
|
||||||
return []model.Comment{}, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ids := make([]string, 0, len(roots))
|
|
||||||
for i := range roots {
|
|
||||||
roots[i].IsSelf = roots[i].UserID == userID
|
|
||||||
ids = append(ids, roots[i].ID)
|
|
||||||
}
|
|
||||||
// 一次查完这一页所有一级评论的回复,不要每条一次查询
|
|
||||||
var replies []model.Comment
|
|
||||||
s.db.Where("parent_id IN ? AND status = ?", ids, "published").Order("id asc").Find(&replies)
|
|
||||||
byParent := map[string][]model.Comment{}
|
|
||||||
for i := range replies {
|
|
||||||
replies[i].IsSelf = replies[i].UserID == userID
|
|
||||||
byParent[replies[i].ParentID] = append(byParent[replies[i].ParentID], replies[i])
|
|
||||||
}
|
|
||||||
for i := range roots {
|
|
||||||
list := byParent[roots[i].ID]
|
|
||||||
if list == nil {
|
|
||||||
list = []model.Comment{} // 空切片而不是 nil,否则 JSON 出来是 null
|
|
||||||
}
|
|
||||||
roots[i].ReplyN = len(list)
|
|
||||||
// 默认只带前 3 条,其余等前端点「展开」再要,避免热门评论一次拉几百条
|
|
||||||
if len(list) > 3 {
|
|
||||||
list = list[:3]
|
|
||||||
}
|
|
||||||
roots[i].Replies = list
|
|
||||||
}
|
|
||||||
return roots, total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListReplies 展开某条一级评论下的全部回复
|
|
||||||
func (s *Service) ListReplies(userID, commentID string) ([]model.Comment, error) {
|
|
||||||
var list []model.Comment
|
|
||||||
if err := s.db.Where("parent_id = ? AND status = ?", commentID, "published").
|
|
||||||
Order("id asc").Find(&list).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i := range list {
|
|
||||||
list[i].IsSelf = list[i].UserID == userID
|
|
||||||
}
|
|
||||||
return list, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateComment 评论
|
|
||||||
func (s *Service) CreateComment(userID, postID string, content string, images []string, parentID string) (*model.Comment, error) {
|
|
||||||
if _, err := s.GetPost(postID); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var user model.User
|
|
||||||
name := "宠友"
|
|
||||||
if err := s.db.First(&user, userID).Error; err == nil && user.Nickname != "" {
|
|
||||||
name = user.Nickname
|
|
||||||
}
|
|
||||||
// 回复的回复仍然挂到同一条一级评论下,保持两级不塌成深树
|
|
||||||
replyTo := ""
|
|
||||||
if parentID != "" {
|
|
||||||
var parent model.Comment
|
|
||||||
if err := s.db.Where("id = ? AND post_id = ?", parentID, postID).First(&parent).Error; err != nil {
|
|
||||||
return nil, ErrNotFound
|
|
||||||
}
|
|
||||||
if parent.ParentID != "" {
|
|
||||||
replyTo = parent.AuthorName
|
|
||||||
parentID = parent.ParentID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 评论也过内容安全(scene=2 评论)。图片这里已经是可访问 URL
|
|
||||||
status, _ := s.moderateInitial(content, user.OpenID, 2, len(images) > 0)
|
|
||||||
|
|
||||||
comment := model.Comment{
|
|
||||||
PostID: postID, UserID: userID, AuthorName: name, Content: content,
|
|
||||||
Status: status, ParentID: parentID, ReplyToName: replyTo,
|
|
||||||
}
|
|
||||||
// 配图存 JSON 数组。空数组也要显式写,否则前端拿到 null 还要额外判空
|
|
||||||
if b, err := json.Marshal(images); err == nil && len(images) > 0 {
|
|
||||||
comment.Images = b
|
|
||||||
}
|
|
||||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Create(&comment).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 待审核/打回的评论先不计入楼主的评论数,过审后再补
|
|
||||||
if status == model.PostPublished {
|
|
||||||
return tx.Model(&model.Post{}).Where("id = ?", postID).
|
|
||||||
UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// 文本已过、有图 → 异步查图
|
|
||||||
if status == model.PostPending && len(images) > 0 {
|
|
||||||
s.submitImageChecks("comment", comment.ID, user.OpenID, images, 2)
|
|
||||||
}
|
|
||||||
return &comment, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteOwnPost 用户删除自己的帖子(软删除,保留数据可追溯)
|
|
||||||
func (s *Service) DeleteOwnPost(userID, postID string) error {
|
|
||||||
var p model.Post
|
|
||||||
if err := s.db.Where("id = ?", postID).First(&p).Error; err != nil {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if p.UserID != userID {
|
|
||||||
return ErrForbidden
|
|
||||||
}
|
|
||||||
return s.db.Model(&model.Post{}).Where("id = ?", postID).
|
|
||||||
Update("status", model.PostDeleted).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteOwnComment 用户删除自己的评论,并同步帖子评论数
|
|
||||||
func (s *Service) DeleteOwnComment(userID, commentID string) error {
|
|
||||||
var c model.Comment
|
|
||||||
if err := s.db.Where("id = ?", commentID).First(&c).Error; err != nil {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
if c.UserID != userID {
|
|
||||||
return ErrForbidden
|
|
||||||
}
|
|
||||||
if c.Status == "deleted" {
|
|
||||||
return nil // 幂等
|
|
||||||
}
|
|
||||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Model(&model.Comment{}).Where("id = ?", commentID).
|
|
||||||
Update("status", "deleted").Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// 删一级评论要连它下面的回复一起删,否则回复会变成挂在空处的孤儿
|
|
||||||
n := int64(1)
|
|
||||||
if c.ParentID == "" {
|
|
||||||
res := tx.Model(&model.Comment{}).
|
|
||||||
Where("parent_id = ? AND status = ?", commentID, "published").
|
|
||||||
Update("status", "deleted")
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
n += res.RowsAffected
|
|
||||||
}
|
|
||||||
return tx.Model(&model.Post{}).Where("id = ? AND comment_count >= ?", c.PostID, n).
|
|
||||||
UpdateColumn("comment_count", gorm.Expr("comment_count - ?", n)).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/ai"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
const botTickMinutes = 15
|
|
||||||
|
|
||||||
// 话题种子,增加生成多样性
|
|
||||||
var botTopics = []string{
|
|
||||||
"新手第一次给猫换粮的经历和注意点",
|
|
||||||
"幼犬疫苗期间的日常照护",
|
|
||||||
"猫咪软便观察和调理的经验",
|
|
||||||
"遛狗时防止捡食的小技巧",
|
|
||||||
"换季掉毛严重怎么护理",
|
|
||||||
"刚到家的猫如何度过应激期",
|
|
||||||
"给狗狗刷牙、清口腔的经验",
|
|
||||||
"记录体重时发现的小变化",
|
|
||||||
"驱虫到期提醒和实操",
|
|
||||||
"多猫家庭的相处磨合",
|
|
||||||
"老年猫饮水和肾脏健康关注点",
|
|
||||||
"幼猫社会化和玩耍引导",
|
|
||||||
}
|
|
||||||
|
|
||||||
// 默认虚拟宠友账号池(昵称 + emoji 头像)
|
|
||||||
var botSeeds = []struct{ Name, Emoji string }{
|
|
||||||
{"团子的日常", "🐱"}, {"布丁妈妈", "🐶"}, {"三花小报告", "🐱"},
|
|
||||||
{"柯基屁股蛋", "🐶"}, {"橘座驾到", "🐱"}, {"奶牛猫饲养员", "🐮"},
|
|
||||||
{"金毛暖暖", "🦮"}, {"布偶少爷", "😺"}, {"英短小灰灰", "🐱"},
|
|
||||||
{"泰迪不乖", "🐩"}, {"狸花猫巡逻队", "🐈"}, {"雪纳瑞老张", "🐶"},
|
|
||||||
{"银渐层日记", "😸"}, {"哈士奇拆家现场", "🐺"}, {"暹罗小王子", "🐱"},
|
|
||||||
{"边牧上学记", "🐕"}, {"美短起司", "🧀"}, {"柴犬麻薯", "🐕"},
|
|
||||||
{"无毛猫斯芬克斯", "🐱"}, {"比熊棉花糖", "🐶"}, {"缅因大橘", "🦁"},
|
|
||||||
{"腊肠一米五", "🌭"}, {"加菲脸圆圆", "😻"}, {"萨摩耶微笑", "🐻❄️"},
|
|
||||||
{"蓝猫布加迪", "🐱"}, {"法斗打呼噜", "🐶"}, {"折耳软软", "🐱"},
|
|
||||||
{"阿拉斯加大脸", "🐺"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 兜底文案(AI 未开启/失败时用)
|
|
||||||
var fallbackPosts = []string{
|
|
||||||
"换粮第3天,软便基本好了,7天过渡法真的有用,分享给同样在换粮的姐妹~",
|
|
||||||
"幼猫社会化真的重要,每天陪玩20分钟,现在一点都不怕生人了🐱",
|
|
||||||
"提醒大家驱虫别忘记,我差点漏了,手机日历设个提醒最保险。",
|
|
||||||
"金毛换季掉毛太夸张了,每天梳一次+补点卵磷脂,情况好多了。",
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBotConfig 取单例配置(不存在返回内置默认,不写库)
|
|
||||||
func (s *Service) getBotConfig() model.CommunityBotConfig {
|
|
||||||
var c model.CommunityBotConfig
|
|
||||||
if err := s.db.First(&c, "1").Error; err != nil {
|
|
||||||
return model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCommunityBotConfig 后台读取配置
|
|
||||||
func (s *Service) GetCommunityBotConfig() model.CommunityBotConfig {
|
|
||||||
return s.getBotConfig()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveCommunityBotConfig 后台保存配置(固定 id=1 upsert)
|
|
||||||
func (s *Service) SaveCommunityBotConfig(in model.CommunityBotConfig) (model.CommunityBotConfig, error) {
|
|
||||||
if in.DailyCount < 0 {
|
|
||||||
in.DailyCount = 0
|
|
||||||
}
|
|
||||||
if in.StartHour < 0 || in.StartHour > 23 {
|
|
||||||
in.StartHour = 9
|
|
||||||
}
|
|
||||||
if in.EndHour < 1 || in.EndHour > 24 {
|
|
||||||
in.EndHour = 21
|
|
||||||
}
|
|
||||||
if in.EndHour <= in.StartHour {
|
|
||||||
in.EndHour = in.StartHour + 1
|
|
||||||
}
|
|
||||||
in.ID = "1"
|
|
||||||
if err := s.db.Save(&in).Error; err != nil {
|
|
||||||
return in, err
|
|
||||||
}
|
|
||||||
return in, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SeedCommunityBot 启动幂等 seed:虚拟账号池(按昵称补齐新增账号)+ 配置单例
|
|
||||||
func (s *Service) SeedCommunityBot() error {
|
|
||||||
// 迁移前的旧用户 is_bot 为 NULL,回填为 false 便于筛选
|
|
||||||
if err := s.db.Model(&model.User{}).Where("is_bot IS NULL").Update("is_bot", false).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, b := range botSeeds {
|
|
||||||
var n int64
|
|
||||||
if err := s.db.Model(&model.User{}).Where("nickname = ? AND is_bot = ?", b.Name, true).Count(&n).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
u := model.User{Nickname: b.Name, Avatar: b.Emoji, IsBot: true, Onboarded: true}
|
|
||||||
if err := s.db.Create(&u).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cfgCount int64
|
|
||||||
if err := s.db.Model(&model.CommunityBotConfig{}).Count(&cfgCount).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cfgCount == 0 {
|
|
||||||
c := model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
|
|
||||||
c.ID = "1"
|
|
||||||
if err := s.db.Create(&c).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// countAIPostsToday 今天已生成的 AI 帖数
|
|
||||||
func (s *Service) countAIPostsToday() int64 {
|
|
||||||
now := time.Now()
|
|
||||||
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
var n int64
|
|
||||||
s.db.Model(&model.Post{}).Where("is_ai = ? AND created_at >= ?", true, start).Count(&n)
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateCommunityPosts 生成 n 条 AI 帖并入库,返回实际生成数
|
|
||||||
func (s *Service) GenerateCommunityPosts(n int) (int, error) {
|
|
||||||
if n <= 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
var bots []model.User
|
|
||||||
if err := s.db.Where("is_bot = ?", true).Find(&bots).Error; err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if len(bots) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
made := 0
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
bot := bots[rand.Intn(len(bots))]
|
|
||||||
content, tag := s.genOnePostContent()
|
|
||||||
if content == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tagsJSON, _ := json.Marshal([]string{tag})
|
|
||||||
imagesJSON, _ := json.Marshal(pickPhotos())
|
|
||||||
p := model.Post{
|
|
||||||
UserID: bot.ID, AuthorName: bot.Nickname, AuthorEmoji: bot.Avatar,
|
|
||||||
Identity: "petName", Content: content, Tags: datatypes.JSON(tagsJSON),
|
|
||||||
Images: datatypes.JSON(imagesJSON),
|
|
||||||
LikeCount: rand.Intn(40), Status: model.PostPublished, IsAI: true,
|
|
||||||
}
|
|
||||||
if err := s.db.Create(&p).Error; err != nil {
|
|
||||||
return made, err
|
|
||||||
}
|
|
||||||
made++
|
|
||||||
}
|
|
||||||
return made, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配图 emoji 池(App 用 emoji 当照片渲染),当作帖子的「图」
|
|
||||||
var photoPool = []string{"🐱", "🐶", "🐈", "🐕", "🍚", "🦴", "🧶", "🪀", "🏠", "🌿", "🛁", "💊", "🐾", "🍗", "🥩", "🧸", "☀️", "🛏️"}
|
|
||||||
|
|
||||||
// pickPhotos ~65% 概率随机贴 1-3 张配图,其余纯文字(更自然)
|
|
||||||
func pickPhotos() []string {
|
|
||||||
if rand.Float64() > 0.65 {
|
|
||||||
return []string{}
|
|
||||||
}
|
|
||||||
n := 1 + rand.Intn(3)
|
|
||||||
out := make([]string, 0, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
out = append(out, photoPool[rand.Intn(len(photoPool))])
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// genOnePostContent 生成一条文案 + 标签;AI 不可用/失败回退内置文案
|
|
||||||
func (s *Service) genOnePostContent() (string, string) {
|
|
||||||
tags := []string{"晒宠", "求助", "经验", "避坑"}
|
|
||||||
tag := tags[rand.Intn(len(tags))]
|
|
||||||
if !s.ai.Enabled() {
|
|
||||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
|
||||||
}
|
|
||||||
topic := botTopics[rand.Intn(len(botTopics))]
|
|
||||||
system := aiSafetyPrompt + `
|
|
||||||
|
|
||||||
你在为宠物社区「宠友圈」生成一条真实、口语化的养宠动态,像普通铲屎官发的朋友圈。
|
|
||||||
要求:40-120 字;真实有细节,可带 1-2 个 emoji;涉及异常要顺带提醒何时就医;不要标题、不要话题标签、不要营销口吻。
|
|
||||||
只输出 JSON:{"content":"","tag":"晒宠|求助|经验|避坑"}`
|
|
||||||
user := fmt.Sprintf("主题:%s。请生成一条。", topic)
|
|
||||||
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
|
|
||||||
if err != nil {
|
|
||||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
|
||||||
}
|
|
||||||
var r struct {
|
|
||||||
Content string `json:"content"`
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(extractJSON(out)), &r); err != nil || r.Content == "" {
|
|
||||||
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
|
|
||||||
}
|
|
||||||
if r.Tag != "" {
|
|
||||||
tag = r.Tag
|
|
||||||
}
|
|
||||||
return r.Content, tag
|
|
||||||
}
|
|
||||||
|
|
||||||
// RunCommunityBotTick 定时器每次调用:白天窗口内按剩余名额概率投放,天然打散在白天
|
|
||||||
func (s *Service) RunCommunityBotTick() {
|
|
||||||
cfg := s.getBotConfig()
|
|
||||||
if !cfg.Enabled || cfg.DailyCount <= 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
h := now.Hour()
|
|
||||||
if h < cfg.StartHour || h >= cfg.EndHour {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
today := s.countAIPostsToday()
|
|
||||||
if today >= int64(cfg.DailyCount) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
remaining := cfg.DailyCount - int(today)
|
|
||||||
minsLeft := (cfg.EndHour-h)*60 - now.Minute()
|
|
||||||
ticksLeft := minsLeft / botTickMinutes
|
|
||||||
if ticksLeft < 1 {
|
|
||||||
ticksLeft = 1
|
|
||||||
}
|
|
||||||
if rand.Float64() <= float64(remaining)/float64(ticksLeft) {
|
|
||||||
_, _ = s.GenerateCommunityPosts(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCommunityBot 启动后台定时投放协程
|
|
||||||
func (s *Service) StartCommunityBot() {
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
s.RunCommunityBotTick()
|
|
||||||
time.Sleep(botTickMinutes * time.Minute)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateFeedback 提交意见反馈
|
|
||||||
func (s *Service) CreateFeedback(userID, content, contact string) (*model.Feedback, error) {
|
|
||||||
content = strings.TrimSpace(content)
|
|
||||||
if content == "" {
|
|
||||||
return nil, errors.New("反馈内容不能为空")
|
|
||||||
}
|
|
||||||
if len([]rune(content)) > 1000 {
|
|
||||||
return nil, errors.New("反馈内容太长了")
|
|
||||||
}
|
|
||||||
name := ""
|
|
||||||
var u model.User
|
|
||||||
if err := s.db.Select("nickname").First(&u, "id = ?", userID).Error; err == nil {
|
|
||||||
name = u.Nickname
|
|
||||||
}
|
|
||||||
f := model.Feedback{UserID: userID, Nickname: name, Content: content, Contact: strings.TrimSpace(contact)}
|
|
||||||
if err := s.db.Create(&f).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListFeedbackAdmin 后台反馈分页
|
|
||||||
func (s *Service) ListFeedbackAdmin(offset, limit int) ([]model.Feedback, int64, error) {
|
|
||||||
var total int64
|
|
||||||
s.db.Model(&model.Feedback{}).Count(&total)
|
|
||||||
var list []model.Feedback
|
|
||||||
err := s.db.Order("id desc").Offset(offset).Limit(limit).Find(&list).Error
|
|
||||||
return list, total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetFeedbackHandled 标记反馈已处理
|
|
||||||
func (s *Service) SetFeedbackHandled(id string, handled bool) error {
|
|
||||||
res := s.db.Model(&model.Feedback{}).Where("id = ?", id).Update("handled", handled)
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
if res.RowsAffected == 0 {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 允许的图片扩展名(服务端兜底校验,前端已限制仅图片)
|
|
||||||
var imageExts = map[string]bool{
|
|
||||||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true,
|
|
||||||
".webp": true, ".bmp": true, ".heic": true, ".heif": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// UploadFile 上传文件并按 MD5 去重:内容相同直接返回已存记录,不重复占用 MinIO 空间。
|
|
||||||
func (s *Service) UploadFile(reader io.Reader, filename, contentType string) (*model.File, error) {
|
|
||||||
data, err := io.ReadAll(reader)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sum := md5.Sum(data)
|
|
||||||
md5hex := hex.EncodeToString(sum[:])
|
|
||||||
|
|
||||||
// 已有相同内容 → 直接复用(URL 按当前配置重建,避免旧地址)
|
|
||||||
var existing model.File
|
|
||||||
if err := s.db.Where("md5 = ?", md5hex).First(&existing).Error; err == nil {
|
|
||||||
existing.URL = s.storage.PublicURL(existing.ObjectName)
|
|
||||||
return &existing, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ext := strings.ToLower(path.Ext(filename))
|
|
||||||
if !imageExts[ext] && !strings.HasPrefix(contentType, "image/") {
|
|
||||||
return nil, errors.New("只允许上传图片文件")
|
|
||||||
}
|
|
||||||
if contentType == "" {
|
|
||||||
contentType = "application/octet-stream"
|
|
||||||
}
|
|
||||||
// 按上传月份分文件夹:uploads/2026-07/<md5>.jpg。文件名仍用 md5 保证唯一。
|
|
||||||
// 同内容命中上面的去重直接复用,不会因为跨月再传一份
|
|
||||||
objectName := "uploads/" + time.Now().Format("2006-01") + "/" + md5hex + ext
|
|
||||||
url, err := s.storage.Upload(objectName, bytes.NewReader(data), int64(len(data)), contentType)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
f := model.File{
|
|
||||||
MD5: md5hex, ObjectName: objectName, URL: url,
|
|
||||||
Size: int64(len(data)), ContentType: contentType, Ext: ext,
|
|
||||||
}
|
|
||||||
if err := s.db.Create(&f).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &f, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileURL 按 file_id 取 URL(由 object_name + 当前配置拼出,空 id 或不存在返回空串)
|
|
||||||
func (s *Service) fileURL(id string) string {
|
|
||||||
if id == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var f model.File
|
|
||||||
if err := s.db.Select("object_name").First(&f, "id = ?", id).Error; err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return s.storage.PublicURL(f.ObjectName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileURLs 批量按 file_id 取 URL
|
|
||||||
func (s *Service) fileURLs(ids []string) map[string]string {
|
|
||||||
out := map[string]string{}
|
|
||||||
clean := make([]string, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
if id != "" {
|
|
||||||
clean = append(clean, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(clean) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
var files []model.File
|
|
||||||
s.db.Select("id, object_name").Where("id IN ?", clean).Find(&files)
|
|
||||||
for _, f := range files {
|
|
||||||
out[f.ID] = s.storage.PublicURL(f.ObjectName)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FollowUser 关注某人(幂等;不能关注自己)
|
|
||||||
func (s *Service) FollowUser(followerID, followeeID string) error {
|
|
||||||
if followerID == "" || followeeID == "" {
|
|
||||||
return errors.New("参数不完整")
|
|
||||||
}
|
|
||||||
if followerID == followeeID {
|
|
||||||
return errors.New("不能关注自己")
|
|
||||||
}
|
|
||||||
var u model.User
|
|
||||||
if err := s.db.Select("id").First(&u, "id = ?", followeeID).Error; err != nil {
|
|
||||||
return ErrNotFound
|
|
||||||
}
|
|
||||||
f := model.Follow{FollowerID: followerID, FolloweeID: followeeID}
|
|
||||||
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
|
|
||||||
FirstOrCreate(&f).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnfollowUser 取消关注(幂等)
|
|
||||||
func (s *Service) UnfollowUser(followerID, followeeID string) error {
|
|
||||||
return s.db.Where("follower_id = ? AND followee_id = ?", followerID, followeeID).
|
|
||||||
Delete(&model.Follow{}).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// followeeIDs 我关注的所有人
|
|
||||||
func (s *Service) followeeIDs(userID string) []string {
|
|
||||||
var ids []string
|
|
||||||
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Pluck("followee_id", &ids)
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
// markFollowed 标记帖子作者是否已被我关注(用于前端显示「关注」按钮)
|
|
||||||
func (s *Service) markFollowed(userID string, posts []model.Post) {
|
|
||||||
if userID == "" || len(posts) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
authors := make([]string, 0, len(posts))
|
|
||||||
for i := range posts {
|
|
||||||
if posts[i].UserID != "" && posts[i].UserID != userID {
|
|
||||||
authors = append(authors, posts[i].UserID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(authors) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var followed []string
|
|
||||||
s.db.Model(&model.Follow{}).
|
|
||||||
Where("follower_id = ? AND followee_id IN ?", userID, authors).
|
|
||||||
Pluck("followee_id", &followed)
|
|
||||||
set := make(map[string]bool, len(followed))
|
|
||||||
for _, id := range followed {
|
|
||||||
set[id] = true
|
|
||||||
}
|
|
||||||
for i := range posts {
|
|
||||||
posts[i].IsSelf = posts[i].UserID == userID
|
|
||||||
posts[i].Followed = set[posts[i].UserID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureNotFound 供 handler 区分错误类型
|
|
||||||
var _ = gorm.ErrRecordNotFound
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/ai"
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HomeInsight 首页洞察条一项:Bold 为大号加粗值,Text 为说明
|
|
||||||
type HomeInsight struct {
|
|
||||||
Bold string `json:"bold"`
|
|
||||||
Text string `json:"text"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WeekDay 首页日期条一格
|
|
||||||
type WeekDay struct {
|
|
||||||
Weekday string `json:"weekday"` // 一二三四五六日
|
|
||||||
Day int `json:"day"`
|
|
||||||
Date string `json:"date"` // YYYY-MM-DD,供首页点击查当日任务
|
|
||||||
Active bool `json:"active"` // 是否今天
|
|
||||||
HasDot bool `json:"has_dot"` // 当天有任务/记录/提醒
|
|
||||||
}
|
|
||||||
|
|
||||||
// HomeSummary 首页所需的全部动态数据
|
|
||||||
type HomeSummary struct {
|
|
||||||
Greeting string `json:"greeting"`
|
|
||||||
Insights []HomeInsight `json:"insights"`
|
|
||||||
Week []WeekDay `json:"week"`
|
|
||||||
Advice string `json:"advice"`
|
|
||||||
HealthPct int `json:"health_pct"`
|
|
||||||
HealthStatus string `json:"health_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
var weekdayCN = []string{"日", "一", "二", "三", "四", "五", "六"}
|
|
||||||
|
|
||||||
// GetHomeSummary 计算首页汇总
|
|
||||||
func (s *Service) GetHomeSummary(userID, petID string) (*HomeSummary, error) {
|
|
||||||
pet, err := s.ownedPet(userID, petID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
res := &HomeSummary{
|
|
||||||
Greeting: greeting(now, s.userNickname(userID)),
|
|
||||||
HealthStatus: pet.HealthStatus,
|
|
||||||
Insights: s.homeInsights(petID, now),
|
|
||||||
Week: s.homeWeek(petID, now),
|
|
||||||
Advice: s.dailyAdvice(pet),
|
|
||||||
HealthPct: s.todayCompletionPct(petID, now),
|
|
||||||
}
|
|
||||||
return res, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) userNickname(userID string) string {
|
|
||||||
var u model.User
|
|
||||||
if err := s.db.Select("nickname").First(&u, userID).Error; err == nil && u.Nickname != "" {
|
|
||||||
return u.Nickname
|
|
||||||
}
|
|
||||||
return "铲屎官"
|
|
||||||
}
|
|
||||||
|
|
||||||
func greeting(t time.Time, name string) string {
|
|
||||||
h := t.Hour()
|
|
||||||
switch {
|
|
||||||
case h < 11:
|
|
||||||
return "早上好," + name
|
|
||||||
case h < 14:
|
|
||||||
return "中午好," + name
|
|
||||||
case h < 18:
|
|
||||||
return "下午好," + name
|
|
||||||
default:
|
|
||||||
return "晚上好," + name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) homeInsights(petID string, now time.Time) []HomeInsight {
|
|
||||||
insights := make([]HomeInsight, 0, 3)
|
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
|
|
||||||
// 1) 最近一条未到期的提醒
|
|
||||||
var rem model.Reminder
|
|
||||||
if err := s.db.Where("pet_id = ? AND next_due_date >= ?", petID, today).
|
|
||||||
Order("next_due_date asc").First(&rem).Error; err == nil && rem.NextDueDate != nil {
|
|
||||||
days := int(math.Ceil(rem.NextDueDate.Sub(today).Hours() / 24))
|
|
||||||
bold := fmt.Sprintf("%d天后", days)
|
|
||||||
if days <= 0 {
|
|
||||||
bold = "今天"
|
|
||||||
}
|
|
||||||
insights = append(insights, HomeInsight{Bold: bold, Text: rem.Title})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 本周体重变化
|
|
||||||
weekAgo := today.AddDate(0, 0, -7)
|
|
||||||
var latest, earliest model.HealthRecord
|
|
||||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
|
||||||
Order("occurred_at desc").First(&latest).Error == nil {
|
|
||||||
if s.db.Where("pet_id = ? AND type = ? AND occurred_at >= ?", petID, model.RecordWeight, weekAgo).
|
|
||||||
Order("occurred_at asc").First(&earliest).Error == nil {
|
|
||||||
d := latest.NumValue - earliest.NumValue
|
|
||||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%+.1fkg", d), Text: "本周体重"})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) 连续记录天数
|
|
||||||
if n := s.streakDays(petID, now); n > 0 {
|
|
||||||
insights = append(insights, HomeInsight{Bold: fmt.Sprintf("%d天", n), Text: "连续记录"})
|
|
||||||
}
|
|
||||||
return insights
|
|
||||||
}
|
|
||||||
|
|
||||||
// streakDays 从今天往前,连续有健康记录的天数
|
|
||||||
func (s *Service) streakDays(petID string, now time.Time) int {
|
|
||||||
streak := 0
|
|
||||||
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
for i := 0; i < 60; i++ {
|
|
||||||
start := day.AddDate(0, 0, -i)
|
|
||||||
end := start.AddDate(0, 0, 1)
|
|
||||||
var cnt int64
|
|
||||||
s.db.Model(&model.HealthRecord{}).
|
|
||||||
Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Count(&cnt)
|
|
||||||
if cnt == 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
streak++
|
|
||||||
}
|
|
||||||
return streak
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) homeWeek(petID string, now time.Time) []WeekDay {
|
|
||||||
// 本周一为起点
|
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
||||||
offset := (int(today.Weekday()) + 6) % 7 // 周一=0
|
|
||||||
monday := today.AddDate(0, 0, -offset)
|
|
||||||
|
|
||||||
dotSet := s.datesWithActivity(petID, monday, monday.AddDate(0, 0, 7))
|
|
||||||
|
|
||||||
week := make([]WeekDay, 7)
|
|
||||||
for i := 0; i < 7; i++ {
|
|
||||||
d := monday.AddDate(0, 0, i)
|
|
||||||
week[i] = WeekDay{
|
|
||||||
Weekday: weekdayCN[int(d.Weekday())],
|
|
||||||
Day: d.Day(),
|
|
||||||
Date: d.Format("2006-01-02"),
|
|
||||||
Active: d.Equal(today),
|
|
||||||
HasDot: dotSet[d.Format("2006-01-02")],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return week
|
|
||||||
}
|
|
||||||
|
|
||||||
// datesWithActivity 区间内有任务/记录/提醒的日期集合
|
|
||||||
func (s *Service) datesWithActivity(petID string, start, end time.Time) map[string]bool {
|
|
||||||
set := map[string]bool{}
|
|
||||||
var ts []time.Time
|
|
||||||
s.db.Model(&model.DailyTask{}).Where("pet_id = ? AND task_date >= ? AND task_date < ?", petID, start, end).Pluck("task_date", &ts)
|
|
||||||
for _, t := range ts {
|
|
||||||
set[t.Format("2006-01-02")] = true
|
|
||||||
}
|
|
||||||
var rs []time.Time
|
|
||||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND occurred_at >= ? AND occurred_at < ?", petID, start, end).Pluck("occurred_at", &rs)
|
|
||||||
for _, t := range rs {
|
|
||||||
set[t.Format("2006-01-02")] = true
|
|
||||||
}
|
|
||||||
var reminders []model.Reminder
|
|
||||||
s.db.Where("pet_id = ? AND next_due_date >= ? AND next_due_date < ?", petID, start, end).Find(&reminders)
|
|
||||||
for _, r := range reminders {
|
|
||||||
if r.NextDueDate != nil {
|
|
||||||
set[r.NextDueDate.Format("2006-01-02")] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 30 天计划路线图节点也算「有活动」,让首页周历与计划日历一致
|
|
||||||
for _, d := range s.planNodeDates(petID, start, end) {
|
|
||||||
set[d.Format("2006-01-02")] = true
|
|
||||||
}
|
|
||||||
return set
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Service) todayCompletionPct(petID string, now time.Time) int {
|
|
||||||
// 任务是 PlanTask(DailyTask 已空)。取宠物的 active 30 天计划,算今天那天的完成占比
|
|
||||||
var plan model.Plan
|
|
||||||
if s.db.Where("pet_id = ? AND kind = ? AND status = ?", petID, model.PlanThirtyDay, "active").
|
|
||||||
Order("id desc").First(&plan).Error != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
day := dayIndexOf(&plan, now)
|
|
||||||
if day < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var total, done int64
|
|
||||||
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ?", plan.ID, day).Count(&total)
|
|
||||||
if total == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
s.db.Model(&model.PlanTask{}).Where("plan_id = ? AND day = ? AND done = ?", plan.ID, day, true).Count(&done)
|
|
||||||
return int(math.Round(float64(done) / float64(total) * 100))
|
|
||||||
}
|
|
||||||
|
|
||||||
// dailyAdvice 今日建议:每宠每天缓存一条;启用模型则真调,否则规则化
|
|
||||||
func (s *Service) dailyAdvice(pet *model.Pet) string {
|
|
||||||
day := time.Now().Format("2006-01-02")
|
|
||||||
var da model.DailyAdvice
|
|
||||||
if err := s.db.Where("pet_id = ? AND day = ?", pet.ID, day).First(&da).Error; err == nil {
|
|
||||||
return da.Text
|
|
||||||
}
|
|
||||||
|
|
||||||
text := fmt.Sprintf("%s处于%s,建议今天重点观察体重、饮食和排便。若出现连续呕吐、精神明显变差或便血,应尽快就医。", pet.Name, pet.Stage)
|
|
||||||
if s.ai != nil && s.ai.Enabled() {
|
|
||||||
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
|
|
||||||
"\n请用2-3句话给出今天的养宠重点提醒,亲切简洁,直接给建议不要寒暄。"
|
|
||||||
if r, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: "今天的养宠建议"}}, ai.Options{Temperature: -1}); err == nil && r != "" {
|
|
||||||
text = r
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = s.db.Create(&model.DailyAdvice{PetID: pet.ID, Day: day, Text: text}).Error
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
|
|
||||||
// UserSummary 我的页所需计数
|
|
||||||
type UserSummary struct {
|
|
||||||
Pets int64 `json:"pets"`
|
|
||||||
Records int64 `json:"records"`
|
|
||||||
Reminders int64 `json:"reminders"`
|
|
||||||
Following int64 `json:"following"` // 我关注了多少人
|
|
||||||
Followers int64 `json:"followers"` // 多少人关注我
|
|
||||||
ProStatus string `json:"pro_status"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetUserSummary 汇总当前用户名下计数
|
|
||||||
func (s *Service) GetUserSummary(userID string) (*UserSummary, error) {
|
|
||||||
var sum UserSummary
|
|
||||||
s.db.Model(&model.Pet{}).Where("user_id = ?", userID).Count(&sum.Pets)
|
|
||||||
s.db.Model(&model.HealthRecord{}).Where("user_id = ?", userID).Count(&sum.Records)
|
|
||||||
s.db.Model(&model.Reminder{}).Where("user_id = ?", userID).Count(&sum.Reminders)
|
|
||||||
s.db.Model(&model.Follow{}).Where("follower_id = ?", userID).Count(&sum.Following)
|
|
||||||
s.db.Model(&model.Follow{}).Where("followee_id = ?", userID).Count(&sum.Followers)
|
|
||||||
|
|
||||||
sum.ProStatus = model.ProNone
|
|
||||||
var m model.ProMembership
|
|
||||||
if err := s.db.Where("user_id = ?", userID).First(&m).Error; err == nil {
|
|
||||||
sum.ProStatus = m.Status
|
|
||||||
}
|
|
||||||
return &sum, nil
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// IDCard 宠物身份卡的全部内容,前端拿去 canvas 上画
|
|
||||||
type IDCard struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Species string `json:"species"` // 猫猫 / 狗狗
|
|
||||||
Gender string `json:"gender"`
|
|
||||||
Breed string `json:"breed"`
|
|
||||||
Birthday string `json:"birthday"` // "2023 年 12 月 15 日"
|
|
||||||
Age string `json:"age"` // 年龄,如 "2岁7个月"
|
|
||||||
Days int `json:"days"` // 一起生活的天数(按到家日)
|
|
||||||
Zodiac string `json:"zodiac"` // 星座,按生日算
|
|
||||||
Weight string `json:"weight"` // 体重,如 "13.5kg"
|
|
||||||
Intro string `json:"intro"`
|
|
||||||
Color string `json:"color"` // 毛色
|
|
||||||
Personality string `json:"personality"` // 性格标签,逗号分隔
|
|
||||||
AvatarURL string `json:"avatar_url"`
|
|
||||||
IDNo string `json:"id_no"` // 玩具身份号,不入库
|
|
||||||
Validity string `json:"validity"` // "2023.12.15 - 永远"
|
|
||||||
|
|
||||||
// —— 背面:照护信息 ——
|
|
||||||
Owner string `json:"owner"` // 主人昵称
|
|
||||||
Phone string `json:"phone"` // 联系电话(脱敏)
|
|
||||||
Allergy string `json:"allergy"` // 过敏情况
|
|
||||||
Notes string `json:"notes"` // 备注
|
|
||||||
VaccineState string `json:"vaccine_state"` // 疫苗情况
|
|
||||||
DewormState string `json:"deworm_state"` // 驱虫情况
|
|
||||||
Org string `json:"org"` // 发证机构
|
|
||||||
|
|
||||||
QRURL string `json:"qr_url"` // 小程序码,未发布时为空
|
|
||||||
QRReady bool `json:"qr_ready"` // 码有没有生成出来
|
|
||||||
}
|
|
||||||
|
|
||||||
// petIDNo 造一个稳定的「身份号」。纯装饰,不入库、不做校验。
|
|
||||||
// 规律照竞品那种:一串数字,扫不出真实含义但看着像证件号。
|
|
||||||
// 用 宠物雪花 id 的数字 + 生日 拼,同一只宠物每次都一样
|
|
||||||
func petIDNo(pet *model.Pet) string {
|
|
||||||
digits := make([]byte, 0, 18)
|
|
||||||
for i := 0; i < len(pet.ID) && len(digits) < 10; i++ {
|
|
||||||
if pet.ID[i] >= '0' && pet.ID[i] <= '9' {
|
|
||||||
digits = append(digits, pet.ID[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for len(digits) < 10 {
|
|
||||||
digits = append(digits, '0')
|
|
||||||
}
|
|
||||||
bd := "00000000"
|
|
||||||
if pet.Birthday != nil && !pet.Birthday.IsZero() {
|
|
||||||
bd = pet.Birthday.Format("20060102")
|
|
||||||
}
|
|
||||||
raw := string(digits[:10]) + bd // 18 位
|
|
||||||
// 每 4 位空一下,像证件号
|
|
||||||
var b strings.Builder
|
|
||||||
for i, c := range raw {
|
|
||||||
if i > 0 && i%4 == 0 {
|
|
||||||
b.WriteByte(' ')
|
|
||||||
}
|
|
||||||
b.WriteRune(c)
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// zodiacOf 按月日算西方星座。按各星座起始日(含)顺序判定,
|
|
||||||
// 落在 12/22–1/19 的默认摩羯座
|
|
||||||
func zodiacOf(month, day int) string {
|
|
||||||
starts := []struct {
|
|
||||||
m, d int
|
|
||||||
name string
|
|
||||||
}{
|
|
||||||
{1, 20, "水瓶座"}, {2, 19, "双鱼座"}, {3, 21, "白羊座"}, {4, 20, "金牛座"},
|
|
||||||
{5, 21, "双子座"}, {6, 22, "巨蟹座"}, {7, 23, "狮子座"}, {8, 23, "处女座"},
|
|
||||||
{9, 23, "天秤座"}, {10, 24, "天蝎座"}, {11, 22, "射手座"}, {12, 22, "摩羯座"},
|
|
||||||
}
|
|
||||||
name := "摩羯座"
|
|
||||||
for _, s := range starts {
|
|
||||||
if month > s.m || (month == s.m && day >= s.d) {
|
|
||||||
name = s.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskPhone 脱敏手机号:138****8888。空或非 11 位原样返回
|
|
||||||
func maskPhone(p string) string {
|
|
||||||
if len(p) != 11 {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return p[:3] + "****" + p[7:]
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIDCard 组装身份卡。小程序码是 best-effort:拿不到(未发布/配额)也照出卡
|
|
||||||
func (s *Service) GetIDCard(userID, petID string) (*IDCard, error) {
|
|
||||||
pet, err := s.GetPet(userID, petID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
intro := "地球来了个新居民~"
|
|
||||||
switch pet.Type {
|
|
||||||
case "狗狗":
|
|
||||||
intro = "汪星人来地球啦~"
|
|
||||||
case "猫猫":
|
|
||||||
intro = "喵星人来地球啦~"
|
|
||||||
}
|
|
||||||
|
|
||||||
bd, validity := "未知", "永久有效"
|
|
||||||
if pet.Birthday != nil && !pet.Birthday.IsZero() {
|
|
||||||
bd = pet.Birthday.Format("2006 年 1 月 2 日")
|
|
||||||
validity = pet.Birthday.Format("2006.01.02") + " - 永远"
|
|
||||||
}
|
|
||||||
|
|
||||||
zodiac := ""
|
|
||||||
if pet.Birthday != nil && !pet.Birthday.IsZero() {
|
|
||||||
zodiac = zodiacOf(int(pet.Birthday.Month()), pet.Birthday.Day())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 主人昵称 + 脱敏电话
|
|
||||||
var user model.User
|
|
||||||
s.db.First(&user, userID)
|
|
||||||
owner := user.Nickname
|
|
||||||
if owner == "" {
|
|
||||||
owner = "铲屎官"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 疫苗:有记录算已接种;驱虫:取下一次到期提醒的日期
|
|
||||||
var vaccineCnt int64
|
|
||||||
s.db.Model(&model.HealthRecord{}).Where("pet_id = ? AND type = ?", petID, model.RecordVaccine).Count(&vaccineCnt)
|
|
||||||
vaccineState := "待接种"
|
|
||||||
if vaccineCnt > 0 {
|
|
||||||
vaccineState = "已接种"
|
|
||||||
}
|
|
||||||
dewormState := "—"
|
|
||||||
var dw model.Reminder
|
|
||||||
if err := s.db.Where("pet_id = ? AND type = ?", petID, model.ReminderDeworm).Order("next_due_date asc").First(&dw).Error; err == nil && dw.NextDueDate != nil {
|
|
||||||
dewormState = dw.NextDueDate.Format("2006.01.02")
|
|
||||||
}
|
|
||||||
|
|
||||||
allergy := pet.Allergy
|
|
||||||
if allergy == "" {
|
|
||||||
allergy = "无"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 一起生活的天数:按到家日算,没填就 0
|
|
||||||
days := 0
|
|
||||||
if pet.ArrivedAt != nil && !pet.ArrivedAt.IsZero() {
|
|
||||||
days = int(time.Since(*pet.ArrivedAt).Hours()/24) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
card := &IDCard{
|
|
||||||
Name: pet.Name, Species: pet.Type, Gender: pet.Gender, Breed: pet.Breed,
|
|
||||||
Birthday: bd, Age: pet.Age, Days: days, Zodiac: zodiac, Weight: pet.Weight, Intro: intro,
|
|
||||||
Color: pet.Color, Personality: pet.Personality,
|
|
||||||
AvatarURL: pet.AvatarURL, IDNo: petIDNo(pet), Validity: validity,
|
|
||||||
Owner: owner, Phone: user.Phone, Allergy: allergy, Notes: pet.Notes,
|
|
||||||
VaccineState: vaccineState, DewormState: dewormState, Org: "肉垫计划",
|
|
||||||
}
|
|
||||||
|
|
||||||
// 小程序码取不到不算错——未发布/配额用尽时卡照样出,只是没码
|
|
||||||
if url, e := s.PetQRCode(petID); e == nil {
|
|
||||||
card.QRURL = url
|
|
||||||
card.QRReady = true
|
|
||||||
} else {
|
|
||||||
log.Printf("[info] 宠物 %s 的小程序码暂不可用(多为小程序未发布):%v", petID, e)
|
|
||||||
}
|
|
||||||
return card, nil
|
|
||||||
}
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Insight 一条健康洞察。
|
|
||||||
// 这是「记录」这件事的兑现方式:用户记了几十条数据,之前只换回一条折线,
|
|
||||||
// 数据没有回到他身上。洞察要说出他自己看不出来的东西。
|
|
||||||
type Insight struct {
|
|
||||||
Kind string `json:"kind"` // trend | correlation | overdue | benchmark | habit
|
|
||||||
Level string `json:"level"` // info | warn | alert
|
|
||||||
Title string `json:"title"` // 一句话结论
|
|
||||||
Detail string `json:"detail"` // 依据 + 建议
|
|
||||||
Action string `json:"action"` // 关联的记录弹层类型,前端据此给按钮
|
|
||||||
Evidence string `json:"evidence"` // 支撑这条结论的具体数据
|
|
||||||
}
|
|
||||||
|
|
||||||
const (
|
|
||||||
insightInfo = "info"
|
|
||||||
insightWarn = "warn"
|
|
||||||
insightAlert = "alert"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PetInsights 汇总一只宠物当前值得说的事。按严重度排序,最多 6 条。
|
|
||||||
func (s *Service) PetInsights(userID, petID string) ([]Insight, error) {
|
|
||||||
pet, err := s.ownedPet(userID, petID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// 只看最近 90 天。更早的数据对「现在怎么样」没有参考价值,
|
|
||||||
// 还会让关联分析找出一堆巧合。
|
|
||||||
since := time.Now().AddDate(0, 0, -90)
|
|
||||||
var records []model.HealthRecord
|
|
||||||
if err := s.db.Where("pet_id = ? AND occurred_at >= ?", petID, since).
|
|
||||||
Order("occurred_at asc").Find(&records).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []Insight
|
|
||||||
out = appendIf(out, s.weightTrendInsight(records))
|
|
||||||
out = appendIf(out, s.foodPoopCorrelation(records))
|
|
||||||
out = appendIf(out, s.symptomClusterInsight(records))
|
|
||||||
out = appendIf(out, s.overdueReminderInsight(petID))
|
|
||||||
out = appendIf(out, s.weightBenchmark(pet, records))
|
|
||||||
out = appendIf(out, s.recordHabitInsight(records))
|
|
||||||
|
|
||||||
// alert > warn > info,同级保持原顺序
|
|
||||||
rank := map[string]int{insightAlert: 0, insightWarn: 1, insightInfo: 2}
|
|
||||||
sort.SliceStable(out, func(i, j int) bool { return rank[out[i].Level] < rank[out[j].Level] })
|
|
||||||
if len(out) > 6 {
|
|
||||||
out = out[:6]
|
|
||||||
}
|
|
||||||
return out, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func appendIf(list []Insight, in *Insight) []Insight {
|
|
||||||
if in != nil {
|
|
||||||
list = append(list, *in)
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
func byType(records []model.HealthRecord, t string) []model.HealthRecord {
|
|
||||||
var out []model.HealthRecord
|
|
||||||
for _, r := range records {
|
|
||||||
if r.Type == t {
|
|
||||||
out = append(out, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// weightTrendInsight 体重趋势。单次数字没意义,连续同向变化才有。
|
|
||||||
func (s *Service) weightTrendInsight(records []model.HealthRecord) *Insight {
|
|
||||||
w := byType(records, model.RecordWeight)
|
|
||||||
if len(w) < 3 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
first, last := w[0], w[len(w)-1]
|
|
||||||
if first.NumValue <= 0 || last.NumValue <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
days := int(last.OccurredAt.Sub(first.OccurredAt).Hours() / 24)
|
|
||||||
if days < 14 {
|
|
||||||
return nil // 时间跨度太短,波动多半是称重误差
|
|
||||||
}
|
|
||||||
pct := (last.NumValue - first.NumValue) / first.NumValue * 100
|
|
||||||
ev := fmt.Sprintf("%d 天内 %.2fkg → %.2fkg(%d 次记录)", days, first.NumValue, last.NumValue, len(w))
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case pct <= -10:
|
|
||||||
return &Insight{
|
|
||||||
Kind: "trend", Level: insightAlert, Action: "symptom",
|
|
||||||
Title: fmt.Sprintf("体重掉了 %.0f%%,建议就医检查", -pct),
|
|
||||||
Detail: "短期内明显掉秤,常见于甲亢、慢性肾病、糖尿病、寄生虫和口腔问题。即使精神食欲看起来正常,也建议做一次血检。",
|
|
||||||
Evidence: ev,
|
|
||||||
}
|
|
||||||
case pct >= 15:
|
|
||||||
return &Insight{
|
|
||||||
Kind: "trend", Level: insightWarn, Action: "food",
|
|
||||||
Title: fmt.Sprintf("体重涨了 %.0f%%,注意控制", pct),
|
|
||||||
Detail: "超重是绝大多数慢性病的起点。摸得到肋骨但看不见明显轮廓才是标准体型,先从减少 10-20% 的喂食量开始。",
|
|
||||||
Evidence: ev,
|
|
||||||
}
|
|
||||||
case pct <= -5:
|
|
||||||
return &Insight{
|
|
||||||
Kind: "trend", Level: insightWarn, Action: "weight",
|
|
||||||
Title: fmt.Sprintf("体重在缓慢下降(%.0f%%)", -pct),
|
|
||||||
Detail: "还不到紧急程度,但值得继续盯。保持每周称重,如果一个月内继续下降就去做次体检。",
|
|
||||||
Evidence: ev,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "trend", Level: insightInfo, Action: "weight",
|
|
||||||
Title: "体重保持稳定",
|
|
||||||
Detail: "这段时间的波动在正常范围内,继续保持现在的喂食量和运动量。",
|
|
||||||
Evidence: ev,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// foodPoopCorrelation 换粮/饮食变化 → 排便异常的时间关联。
|
|
||||||
// 这是用户自己最看不出来的一类:两条记录隔了两三天,翻时间轴翻不出规律。
|
|
||||||
func (s *Service) foodPoopCorrelation(records []model.HealthRecord) *Insight {
|
|
||||||
foods := byType(records, model.RecordFood)
|
|
||||||
poops := byType(records, model.RecordPoop)
|
|
||||||
if len(foods) == 0 || len(poops) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, p := range poops {
|
|
||||||
if p.Category == "" || p.Category == "正常" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// 往前找 3 天内的饮食记录
|
|
||||||
for i := len(foods) - 1; i >= 0; i-- {
|
|
||||||
f := foods[i]
|
|
||||||
gap := p.OccurredAt.Sub(f.OccurredAt)
|
|
||||||
if gap < 0 || gap > 72*time.Hour {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "correlation", Level: insightWarn, Action: "food",
|
|
||||||
Title: fmt.Sprintf("这次「%s」之前 %d 天有过饮食变化", p.Category, int(gap.Hours()/24)),
|
|
||||||
Detail: "换粮或加新食物引起的软便很常见。回到原来的粮,之后按 7 天过渡:每天替换约 1/7,软便就放慢速度。持续超过 3 天或带血要就医。",
|
|
||||||
Evidence: fmt.Sprintf("%s 记录「%s」→ %s 出现「%s」",
|
|
||||||
f.OccurredAt.Format("1月2日"), f.Title, p.OccurredAt.Format("1月2日"), p.Category),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// symptomClusterInsight 异常记录扎堆。单次异常不说明什么,短期内反复才是信号。
|
|
||||||
func (s *Service) symptomClusterInsight(records []model.HealthRecord) *Insight {
|
|
||||||
sym := byType(records, model.RecordSymptom)
|
|
||||||
if len(sym) < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
recent := 0
|
|
||||||
cut := time.Now().AddDate(0, 0, -14)
|
|
||||||
var last time.Time
|
|
||||||
for _, r := range sym {
|
|
||||||
if r.OccurredAt.After(cut) {
|
|
||||||
recent++
|
|
||||||
last = r.OccurredAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if recent < 2 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "habit", Level: insightAlert, Action: "symptom",
|
|
||||||
Title: fmt.Sprintf("两周内记了 %d 次异常", recent),
|
|
||||||
Detail: "反复出现的异常和偶发一次完全不是一回事。把这些记录导出成就医摘要带去医院,比口头描述有用得多。",
|
|
||||||
Evidence: fmt.Sprintf("最近一次:%s", last.Format("1月2日")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// overdueReminderInsight 已经过期的提醒。设了不看等于没设。
|
|
||||||
func (s *Service) overdueReminderInsight(petID string) *Insight {
|
|
||||||
var rems []model.Reminder
|
|
||||||
if err := s.db.Where("pet_id = ? AND next_due_date IS NOT NULL AND next_due_date < ?",
|
|
||||||
petID, time.Now()).Order("next_due_date asc").Find(&rems).Error; err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(rems) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
r := rems[0]
|
|
||||||
days := int(time.Since(*r.NextDueDate).Hours() / 24)
|
|
||||||
action := "vaccine"
|
|
||||||
if r.Type == model.ReminderDeworm {
|
|
||||||
action = "medicine"
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "overdue", Level: insightAlert, Action: action,
|
|
||||||
Title: fmt.Sprintf("「%s」已经过期 %d 天", r.Title, days),
|
|
||||||
Detail: "疫苗和驱虫拖久了保护力会断档,尤其驱虫,遛弯和接触其它动物的风险是持续的。补上之后记得把下次日期也设好。",
|
|
||||||
Evidence: fmt.Sprintf("原定 %s", r.NextDueDate.Format("2006年1月2日")),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// weightBenchmark 同龄同物种对比。
|
|
||||||
// 这是单机数据做不到、只有平台侧能给的价值。样本不足就不给结论——
|
|
||||||
// 拿 3 只猫算出来的「中位数」比不给还糟。
|
|
||||||
func (s *Service) weightBenchmark(pet *model.Pet, records []model.HealthRecord) *Insight {
|
|
||||||
w := byType(records, model.RecordWeight)
|
|
||||||
if len(w) == 0 || pet.Birthday == nil || pet.Birthday.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
mine := w[len(w)-1].NumValue
|
|
||||||
if mine <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
months := monthsSince(*pet.Birthday)
|
|
||||||
|
|
||||||
// 同物种、月龄相差 2 个月以内的其它宠物,取各自最近一次体重
|
|
||||||
lo := time.Now().AddDate(0, -(months + 2), 0)
|
|
||||||
hi := time.Now().AddDate(0, -maxInt(months-2, 0), 0)
|
|
||||||
var peers []float64
|
|
||||||
rows, err := s.db.Raw(`
|
|
||||||
SELECT r.num_value FROM sundynix_health_records r
|
|
||||||
JOIN (
|
|
||||||
SELECT pet_id, MAX(occurred_at) AS t FROM sundynix_health_records
|
|
||||||
WHERE type = ? GROUP BY pet_id
|
|
||||||
) latest ON latest.pet_id = r.pet_id AND latest.occurred_at = latest.t
|
|
||||||
JOIN sundynix_pets p ON p.id = r.pet_id
|
|
||||||
WHERE r.type = ? AND r.num_value > 0 AND p.id <> ?
|
|
||||||
AND p.type = ? AND p.birthday BETWEEN ? AND ?`,
|
|
||||||
model.RecordWeight, model.RecordWeight, pet.ID, pet.Type, lo, hi).Rows()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var v float64
|
|
||||||
if rows.Scan(&v) == nil && v > 0 {
|
|
||||||
peers = append(peers, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 样本太少给不出可信的参考值,宁可不显示
|
|
||||||
if len(peers) < 8 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
sort.Float64s(peers)
|
|
||||||
med := peers[len(peers)/2]
|
|
||||||
diff := (mine - med) / med * 100
|
|
||||||
|
|
||||||
level, title := insightInfo, "体重和同龄伙伴差不多"
|
|
||||||
if diff >= 20 {
|
|
||||||
level, title = insightWarn, fmt.Sprintf("比同龄伙伴重 %.0f%%", diff)
|
|
||||||
} else if diff <= -20 {
|
|
||||||
level, title = insightWarn, fmt.Sprintf("比同龄伙伴轻 %.0f%%", -diff)
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "benchmark", Level: level, Action: "weight",
|
|
||||||
Title: title,
|
|
||||||
Detail: "这只是同月龄的横向参考,品种和体型差异很大,不能当成标准。真正要看的还是它自己的体重曲线是否平稳。",
|
|
||||||
Evidence: fmt.Sprintf("%d 月龄 · 它 %.2fkg,%d 只同龄%s的中位数 %.2fkg",
|
|
||||||
months, mine, len(peers), pet.Type, med),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// recordHabitInsight 记录习惯。没数据就没洞察,这条负责把用户拉回来记。
|
|
||||||
func (s *Service) recordHabitInsight(records []model.HealthRecord) *Insight {
|
|
||||||
if len(records) >= 5 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &Insight{
|
|
||||||
Kind: "habit", Level: insightInfo, Action: "weight",
|
|
||||||
Title: "再记几条,这里就能看出规律了",
|
|
||||||
Detail: "体重、便便、饮食三类各记几次之后,能自动帮你发现「换粮第几天开始软便」这种自己翻记录看不出来的关联。",
|
|
||||||
Evidence: fmt.Sprintf("目前共 %d 条记录", len(records)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func maxInt(a, b int) int {
|
|
||||||
if a > b {
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 生命阶段的划分标准。
|
|
||||||
//
|
|
||||||
// 这张表既用来判定阶段,也直接吐给前端当「划分依据」展示 —— 一份数据两个用途,
|
|
||||||
// 而不是「代码里判定 + 文档里写标准」。后者一定会漂:改了阈值忘了改文档,
|
|
||||||
// 用户看到的说明和实际行为对不上,比没有说明更糟。
|
|
||||||
//
|
|
||||||
// ── 为什么猫狗不能共用阈值 ──
|
|
||||||
//
|
|
||||||
// 猫的发育在品种间差别不大。狗完全取决于体型:
|
|
||||||
// 吉娃娃 8 个月已经性成熟、可以换成犬粮;大丹犬 8 个月还在长骨头,
|
|
||||||
// 这时候喂成犬粮(钙磷比和热量密度都不同)会加重关节负担。
|
|
||||||
// 吉娃娃 7 岁还在成年期(8 岁才进中年,能活 13-16 年);
|
|
||||||
// 大丹犬 7 岁已经是老年(平均寿命 8-10 年)。
|
|
||||||
//
|
|
||||||
// 原来代码里是一刀切「<12 月幼年,>=7 岁老年」,对猫勉强,对狗两头都错。
|
|
||||||
//
|
|
||||||
// 体型不能靠当前体重判断 —— 幼犬的体重说明不了它成年后多大。只能靠品种,
|
|
||||||
// 所以 breeds 表带 size_class。
|
|
||||||
|
|
||||||
// 五个阶段。标签故意做成物种中立(不分「幼猫期/幼犬期」):
|
|
||||||
// 物种本来就是另一个字段,分开会让 (species, stage) 的组合和模板配置都翻倍。
|
|
||||||
const (
|
|
||||||
StageJuvenile = "幼年期"
|
|
||||||
StageYoung = "青年期"
|
|
||||||
StageAdult = "成年期"
|
|
||||||
StageMature = "中年期"
|
|
||||||
StageSenior = "老年期"
|
|
||||||
|
|
||||||
// StageNewHome 和年龄正交:刚接回家的成年猫两套照护都需要。
|
|
||||||
// 它不参与年龄判定,是叠加上去的一层。
|
|
||||||
StageNewHome = "刚到家 0-30 天"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CareStages 后台配方案时可选的阶段。刚到家放最前面,它是所有宠物都会经历的
|
|
||||||
var CareStages = []string{StageNewHome, StageJuvenile, StageYoung, StageAdult, StageMature, StageSenior}
|
|
||||||
|
|
||||||
// 犬类体型。分界取自成年体重,是行业通行的分法
|
|
||||||
const (
|
|
||||||
SizeSmall = "small" // < 10kg
|
|
||||||
SizeMedium = "medium" // 10-25kg
|
|
||||||
SizeLarge = "large" // 25-40kg
|
|
||||||
SizeGiant = "giant" // > 40kg
|
|
||||||
)
|
|
||||||
|
|
||||||
// SizeLabels 体型的中文名和依据,后台和小程序都用它
|
|
||||||
var SizeLabels = map[string]string{
|
|
||||||
SizeSmall: "小型犬(成年 <10kg)",
|
|
||||||
SizeMedium: "中型犬(成年 10-25kg)",
|
|
||||||
SizeLarge: "大型犬(成年 25-40kg)",
|
|
||||||
SizeGiant: "巨型犬(成年 >40kg)",
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageRule 一个阶段的月龄区间和依据
|
|
||||||
type StageRule struct {
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
FromMon int `json:"from_month"` // 含
|
|
||||||
ToMon int `json:"to_month"` // 不含;0 = 无上限
|
|
||||||
Range string `json:"range"` // 展示用,如「6-12 月」
|
|
||||||
Basis string `json:"basis"` // 划分依据,直接展示给用户
|
|
||||||
Focus string `json:"focus"` // 这个阶段的养护重点
|
|
||||||
}
|
|
||||||
|
|
||||||
// 猫:AAFP/AAHA 猫生命阶段指南那套,在「成年」里细分出中年
|
|
||||||
var catStages = []StageRule{
|
|
||||||
{StageJuvenile, 0, 6, "0-6 月", "断奶到六月龄是生长最快的阶段,三针核心疫苗和首次驱虫都在这里完成",
|
|
||||||
"疫苗序列、驱虫、社会化、少量多餐"},
|
|
||||||
{StageYoung, 6, 12, "6-12 月", "六月龄前后性成熟,绝育窗口;一岁前后换成猫粮",
|
|
||||||
"绝育安排、换成猫粮、开始控体重"},
|
|
||||||
{StageAdult, 12, 84, "1-7 岁", "身体状态最稳定的时期,重点是把基线数据记下来",
|
|
||||||
"每月称重、年度体检、口腔护理"},
|
|
||||||
{StageMature, 84, 132, "7-11 岁", "七岁起代谢变慢,年度血检开始能查出早期问题",
|
|
||||||
"年度血检、体重和牙结石重点看"},
|
|
||||||
{StageSenior, 132, 0, "11 岁以上", "十一岁后肾脏和甲状腺问题高发,体检频率要加倍",
|
|
||||||
"半年一次体检、肾和甲状腺、饮水量重点记"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// 狗:同样五段,阈值按体型走。越大的犬种生长期越长、老得越早
|
|
||||||
var dogStages = map[string][]StageRule{
|
|
||||||
SizeSmall: {
|
|
||||||
{StageJuvenile, 0, 8, "0-8 月", "小型犬生长期短,八月龄基本定型", "疫苗序列、驱虫、社会化"},
|
|
||||||
{StageYoung, 8, 12, "8-12 月", "性成熟早,绝育窗口也早", "绝育安排、换成犬粮"},
|
|
||||||
{StageAdult, 12, 96, "1-8 岁", "小型犬寿命长,成年期也长", "每月称重、年度体检、牙结石"},
|
|
||||||
{StageMature, 96, 132, "8-11 岁", "八岁起进入中年,心脏和牙齿是重点", "年度血检、心脏听诊、洁牙"},
|
|
||||||
{StageSenior, 132, 0, "11 岁以上", "小型犬普遍能活到 13-16 岁,十一岁后算老年", "半年一次体检、关节和心脏"},
|
|
||||||
},
|
|
||||||
SizeMedium: {
|
|
||||||
{StageJuvenile, 0, 12, "0-12 月", "一岁前后完成生长", "疫苗序列、驱虫、社会化"},
|
|
||||||
{StageYoung, 12, 18, "12-18 月", "骨骼刚闭合,可以开始正常运动量", "换成犬粮、运动量上调"},
|
|
||||||
{StageAdult, 18, 84, "1.5-7 岁", "维持期", "每月称重、年度体检、牙结石"},
|
|
||||||
{StageMature, 84, 120, "7-10 岁", "七岁起体重和关节要重点看", "年度血检、体重控制、关节"},
|
|
||||||
{StageSenior, 120, 0, "10 岁以上", "中型犬平均寿命 10-13 岁", "半年一次体检、关节和肿瘤筛查"},
|
|
||||||
},
|
|
||||||
SizeLarge: {
|
|
||||||
{StageJuvenile, 0, 15, "0-15 月", "大型犬生长期长到 15 个月,这期间不能过量补钙或过早剧烈运动",
|
|
||||||
"控制生长速度、疫苗序列、避免跳跃"},
|
|
||||||
{StageYoung, 15, 24, "15-24 月", "两岁前骨骼才完全闭合", "换成犬粮、逐步加运动量"},
|
|
||||||
{StageAdult, 24, 72, "2-6 岁", "维持期较短", "每月称重、年度体检、髋关节"},
|
|
||||||
{StageMature, 72, 96, "6-8 岁", "六岁起就要按中年管,比中小型犬早", "年度血检、髋关节、体重控制"},
|
|
||||||
{StageSenior, 96, 0, "8 岁以上", "大型犬平均寿命 8-12 岁,八岁进老年", "半年一次体检、关节和心脏"},
|
|
||||||
},
|
|
||||||
SizeGiant: {
|
|
||||||
{StageJuvenile, 0, 18, "0-18 月", "巨型犬生长期最长,18 个月还在长;这阶段最忌过度补钙",
|
|
||||||
"严格控制生长速度、疫苗序列、限制跳跃"},
|
|
||||||
{StageYoung, 18, 24, "18-24 月", "两岁才算成年", "换成犬粮、关节保养开始"},
|
|
||||||
{StageAdult, 24, 60, "2-5 岁", "成年期很短", "每月称重、年度体检、心脏和关节"},
|
|
||||||
{StageMature, 60, 84, "5-7 岁", "五岁就要按中年管 —— 巨型犬老得最快", "半年一次血检、心脏、体重"},
|
|
||||||
{StageSenior, 84, 0, "7 岁以上", "巨型犬平均寿命 7-10 岁", "每半年体检、心脏和骨科肿瘤"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageRules 某个物种(狗还要看体型)的完整划分标准,给前端展示用
|
|
||||||
func StageRules(species, sizeClass string) []StageRule {
|
|
||||||
if species == model.SpeciesDog {
|
|
||||||
if rs, ok := dogStages[sizeClass]; ok {
|
|
||||||
return rs
|
|
||||||
}
|
|
||||||
return dogStages[SizeMedium] // 品种没标体型时按中型算,最不容易错得离谱
|
|
||||||
}
|
|
||||||
return catStages
|
|
||||||
}
|
|
||||||
|
|
||||||
// stageOfMonths 按月龄在规则表里落位
|
|
||||||
func stageOfMonths(rules []StageRule, months int) string {
|
|
||||||
for _, r := range rules {
|
|
||||||
if months >= r.FromMon && (r.ToMon == 0 || months < r.ToMon) {
|
|
||||||
return r.Stage
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return StageAdult
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageFromAge 由物种、体型、月龄推断阶段
|
|
||||||
func StageFromAge(species, sizeClass string, months int) string {
|
|
||||||
if months < 0 {
|
|
||||||
return StageAdult // 没有生日只能按成年算,它是最不容易出错的默认
|
|
||||||
}
|
|
||||||
return stageOfMonths(StageRules(species, sizeClass), months)
|
|
||||||
}
|
|
||||||
|
|
||||||
// sizeClassOfBreed 查品种的体型。查不到返回空,上层按中型兜底
|
|
||||||
func (s *Service) sizeClassOfBreed(species, breed string) string {
|
|
||||||
if species != model.SpeciesDog || breed == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
var b model.Breed
|
|
||||||
if err := s.db.Where("species = ? AND name = ?", species, breed).First(&b).Error; err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return b.SizeClass
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageOf 由生日推断阶段(狗会查品种体型)
|
|
||||||
func (s *Service) StageOf(species, breed string, birthday *time.Time) string {
|
|
||||||
if birthday == nil || birthday.IsZero() {
|
|
||||||
return StageAdult
|
|
||||||
}
|
|
||||||
return StageFromAge(species, s.sizeClassOfBreed(species, breed), monthsSince(*birthday))
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeStage 定下最终使用的阶段。
|
|
||||||
// 用户手选的优先(领养的猫生日是估的,主人按体型判断更准;而模型里的月龄阈值
|
|
||||||
// 对混种和边界体型都是一刀切);没选或选了不认识的值才按生日推。
|
|
||||||
func (s *Service) NormalizeStage(species, breed, picked string, birthday *time.Time) string {
|
|
||||||
for _, v := range CareStages {
|
|
||||||
if picked == v {
|
|
||||||
return picked
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return s.StageOf(species, breed, birthday)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AgeLabel 月龄的中文展示。不入库 —— 存下来第二天就过期了
|
|
||||||
func AgeLabel(birthday *time.Time) string {
|
|
||||||
if birthday == nil || birthday.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
m := monthsSince(*birthday)
|
|
||||||
if m < 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if m < 24 {
|
|
||||||
return fmt.Sprintf("%d个月", m)
|
|
||||||
}
|
|
||||||
if m%12 == 0 {
|
|
||||||
return fmt.Sprintf("%d岁", m/12)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d岁%d个月", m/12, m%12)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageDrift 「档案里存的阶段」和「按生日算出来的阶段」不一致。
|
|
||||||
//
|
|
||||||
// 为什么不自动改:
|
|
||||||
// 1. 用户可能手改过 —— 领养的猫生日是估的,主人按体型判断比月龄阈值准
|
|
||||||
// 2. 已经排好的这个月计划不该被推翻。套用方案是「往里加」,不是「替换」
|
|
||||||
// 3. 偷偷换会让用户莫名发现计划变了,找不到原因
|
|
||||||
//
|
|
||||||
// 所以系统只负责发现,用户负责决定:小程序端提示「它已经进入成年期了,
|
|
||||||
// 要不要换一套方案」,点了才套用。
|
|
||||||
type StageDrift struct {
|
|
||||||
Drifted bool `json:"drifted"`
|
|
||||||
Stored string `json:"stored"` // 档案里现在存的
|
|
||||||
Computed string `json:"computed"` // 按生日算出来的
|
|
||||||
AgeLabel string `json:"age_label"` // 「1岁3个月」
|
|
||||||
Basis string `json:"basis"` // 新阶段的划分依据,一并给出来
|
|
||||||
Focus string `json:"focus"` // 新阶段的养护重点
|
|
||||||
Size string `json:"size"` // 犬类的体型说明,猫为空
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckStageDrift 检查某只宠物的阶段有没有漂
|
|
||||||
func (s *Service) CheckStageDrift(pet *model.Pet) *StageDrift {
|
|
||||||
out := &StageDrift{Stored: pet.Stage, AgeLabel: AgeLabel(pet.Birthday)}
|
|
||||||
if pet.Birthday == nil || pet.Birthday.IsZero() {
|
|
||||||
return out // 没生日算不了,不提示
|
|
||||||
}
|
|
||||||
// 「刚到家」是叠加层不是年龄段,处在这个状态时不谈漂移
|
|
||||||
if pet.Stage == StageNewHome {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
species := SpeciesOf(pet.Type)
|
|
||||||
size := s.sizeClassOfBreed(species, pet.Breed)
|
|
||||||
out.Computed = StageFromAge(species, size, monthsSince(*pet.Birthday))
|
|
||||||
if species == model.SpeciesDog {
|
|
||||||
if l, ok := SizeLabels[size]; ok {
|
|
||||||
out.Size = l
|
|
||||||
} else {
|
|
||||||
out.Size = SizeLabels[SizeMedium] + "(品种未标体型,按中型算)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if out.Computed == out.Stored || out.Computed == "" {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
out.Drifted = true
|
|
||||||
for _, r := range StageRules(species, size) {
|
|
||||||
if r.Stage == out.Computed {
|
|
||||||
out.Basis = r.Basis
|
|
||||||
out.Focus = r.Focus
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"fmt"
|
|
||||||
"math/big"
|
|
||||||
|
|
||||||
"github.com/sundynix/pets-be/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// 新用户默认昵称的前缀。微信登录拿不到昵称(要用户主动授权),
|
|
||||||
// 全都叫「微信用户」的话,社区里一屏全是同名,后台也没法区分。
|
|
||||||
var nickPrefixes = []string{
|
|
||||||
"铲屎官", "猫奴", "狗子家长", "毛孩子家长", "养宠新手",
|
|
||||||
"肉垫爱好者", "撸猫达人", "遛狗选手", "猫窝管理员", "狗窝管理员",
|
|
||||||
}
|
|
||||||
|
|
||||||
// UniqueNickname 生成一个库里没有的昵称,形如「铲屎官12138」。
|
|
||||||
// 5 位数字 × 10 个前缀 = 百万级空间,撞了就重试;连撞 20 次说明
|
|
||||||
// 随机源出问题了,退回带更长数字的形式,绝不返回空。
|
|
||||||
func (s *Service) UniqueNickname() string {
|
|
||||||
for i := 0; i < 20; i++ {
|
|
||||||
name := fmt.Sprintf("%s%05d", pick(nickPrefixes), randInt(100000))
|
|
||||||
var count int64
|
|
||||||
if err := s.db.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
|
|
||||||
return name // 查不了就直接用,重名也比登录失败强
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%s%09d", pick(nickPrefixes), randInt(1000000000))
|
|
||||||
}
|
|
||||||
|
|
||||||
func pick(list []string) string {
|
|
||||||
return list[randInt(int64(len(list)))]
|
|
||||||
}
|
|
||||||
|
|
||||||
func randInt(n int64) int64 {
|
|
||||||
v, err := rand.Int(rand.Reader, big.NewInt(n))
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return v.Int64()
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user