Compare commits

..
39 Commits
Author SHA1 Message Date
Blizzard 3175936266 feat: support shared pet care tasks and smooth daily page return
ci-cd / verify (push) Successful in 2m18s
ci-cd / deploy (push) Skipped
2026-10-05 20:20:05 +08:00
Blizzard fbc1eda561 fix: ignore cancelled pet photo selection
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-05 20:13:10 +08:00
Blizzard 9d7cf720a5 feat: add multi-source breed encyclopedia and two-hour refresh sessions
ci-cd / verify (push) Successful in 1m59s
ci-cd / deploy (push) Skipped
2026-10-05 13:38:24 +08:00
Blizzard 607d709100 Avoid reloading tasks when already viewing today
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 12:01:37 +08:00
Blizzard d6cb582ffe Rename home tab to daily and add calendar sunshine icon
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 11:43:10 +08:00
Blizzard b1439b0fe5 Move pet card actions into menu and use circular brand logos
ci-cd / verify (push) Successful in 1m39s
ci-cd / deploy (push) Skipped
2026-10-05 11:38:15 +08:00
Blizzard cb4ffa13e9 Add one-way task completion and exportable pet identity cards
ci-cd / verify (push) Successful in 1m31s
ci-cd / deploy (push) Skipped
2026-10-05 11:32:32 +08:00
Blizzard f0ee63e642 Cache home location and weather across page navigation
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-05 11:08:11 +08:00
Blizzard 42aea10101 Add immediate startup login and loading states for mini program home
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-05 11:03:33 +08:00
Blizzard 4916c31edd Generate unique Mao Hai parent nicknames for new users
ci-cd / verify (push) Successful in 1m31s
ci-cd / deploy (push) Skipped
2026-10-04 11:53:53 +08:00
Blizzard 6c37785ce1 Complete admin dashboard with paginated user pet trees and walk relations
ci-cd / verify (push) Successful in 1m40s
ci-cd / deploy (push) Skipped
2026-10-04 11:45:15 +08:00
Blizzard 3655a6a89e Shorten location permission description for WeChat upload
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 11:05:50 +08:00
Blizzard 7fc50f9212 Add AMap location and weather header with warm mini program theme
ci-cd / verify (push) Successful in 1m54s
ci-cd / deploy (push) Skipped
2026-10-04 11:03:34 +08:00
Blizzard c2b51f96e0 Fix capsule-aligned navigation and refresh mini program typography
ci-cd / verify (push) Successful in 6s
ci-cd / deploy (push) Skipped
2026-10-04 10:30:17 +08:00
Blizzard adfd0574cc Hide scrollbars across mini program pages
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-04 09:58:07 +08:00
Blizzard 46ac8f8a47 Respect mini program status bar and menu safe area
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:53:07 +08:00
Blizzard 8a3036f0db Redesign mini program navigation and add back controls
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:48:12 +08:00
Blizzard 87dfc4f335 Keep corgi artwork fully visible in mini program banners
ci-cd / verify (push) Successful in 4s
ci-cd / deploy (push) Skipped
2026-10-04 09:37:17 +08:00
Blizzard c72cf24f56 Complete core mini program flows
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-04 09:26:58 +08:00
Blizzard 5688db4d63 Serve embedded admin assets from root
ci-cd / deploy (push) Skipped
ci-cd / verify (push) Successful in 1m31s
2026-10-01 14:23:50 +08:00
Blizzard fd4f7708ba Point mini program API to production domain
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-01 14:14:54 +08:00
Blizzard 8de48cb44d Serve admin console at root path
ci-cd / verify (push) Successful in 1m48s
ci-cd / deploy (push) Skipped
2026-10-01 14:13:04 +08:00
Blizzard e9828d6149 gitignore
ci-cd / verify (push) Successful in 7s
ci-cd / deploy (push) Successful in 2m51s
2026-10-01 13:41:36 +08:00
Blizzard 6f065e3783 ci: run pipeline only for branch pushes
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-01 13:36:15 +08:00
Blizzard 78ab1917f3 ci: verify every push before deploying main
ci-cd / verify (push) Successful in 5s
ci-cd / deploy (push) Skipped
2026-10-01 13:34:33 +08:00
Blizzard e102f5c610 ci: fetch source archive from Gitea
ci / verify (push) Successful in 2m6s
2026-10-01 13:30:13 +08:00
Blizzard abc7ad3c84 ci: verify pet app build on development branch
ci / verify (push) Failing after 4s
2026-10-01 13:26:34 +08:00
Blizzard ed2095950d gitignore 2026-10-01 13:17:34 +08:00
Blizzard bfcf8788a4 feat: rebuild pet app and backend around core care flows 2026-10-01 13:11:30 +08:00
BlizzardandClaude Opus 5 51ead8f411 style(fe): 身份卡只留截图字段 — 去掉性格标签,高度压到 cardH=510
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:41:38 +08:00
BlizzardandClaude Opus 5 1544151e5c style(fe): 身份卡降到 cardH=540,反面去掉备注行并压紧版式
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:39:09 +08:00
BlizzardandClaude Opus 5 c07ed03539 style(fe): 身份卡正反两张等高(都用 cardH=630),正面中间内容垂直居中
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:37:17 +08:00
BlizzardandClaude Opus 5 473c9f9200 style(fe): 照护卡改证件版式 — 大字「X星人地球移民/居民身份卡」左对齐
- 去掉蓝色标题条,照护卡整体换淡紫底
- 标题两行左对齐:小字带字距「汪星人/喵星人/宠星人地球移民」+ 大字「居民身份卡」
- 按物种动态取字:狗→汪星人、猫→喵星人、其它→宠星人
- 发证机构→签发机构、有效期→有效期限;小程序码落到右下角淡紫底上
- 新增 spacedText 逐字字距工具

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:35:11 +08:00
BlizzardandClaude Opus 5 59412010dc feat(fe+be): 身份卡加公章、修性别符号、电话显示全、照护卡标题两行
- 修正性别 ♂/♀:用名字字号量宽度再定位,不再怼到名字上(之前用小字号量偏了)
- 正反两张卡右上角各盖一枚「肉垫计划 · 官方认证」红色公章(双圈+五角星,微旋转半透明)
- 照护卡标题改成两行「X星地球移民 / 居民身份卡」(按猫狗取 喵/汪/宠)
- 正面「物ID」改为「ID」
- 后端 IDCard.Phone 返回完整手机号,不再脱敏

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:32:09 +08:00
BlizzardandClaude Opus 5 4e3859227e style(fe): 身份卡再精简 — 去掉 logo/徽章/图标 emoji 和多余招呼语
- 正面去掉「宠星人」logo、「宠物身份卡」盾牌徽章、爪印水印,头像上移
- 照护卡标题去掉 🌿,各行去掉 👤📞📝🏛📅 图标只留文字
- 走失框去掉「🔔 走失时请联系主人」标题和「或联系主人」,只留扫码说明
- 去掉页面底部「小程序码发布后出现」占位提示

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:23:42 +08:00
BlizzardandClaude Opus 5 d9a80d7f8a style(fe): 身份卡按参考图收拾 — 去标题/正反面标签/疫苗,卡高按内容自适应
- 去掉顶部「宠物身份卡」大标题和「· 正面 ·/· 背面 ·」灰字,只留两张卡
- 正面卡高度按性格/介绍有无动态算,消掉中间大块空白
- 性格标签为空时整段不画
- 照护卡去掉「疫苗情况」行
- 导出只裁到实际内容底部,卡片下面不再拖空白

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 18:15:45 +08:00
BlizzardandClaude Opus 5 747e7c9d3a feat(fe): 身份卡手机号门槛加「手动填写」兜底
getPhoneNumber 需企业认证 + 手机号验证权限,个人/未认证小程序唤不起。
门槛卡加「手动填写手机号」入口:填 11 位号 → updateProfile 存库 → 重取卡生成。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:52:31 +08:00
BlizzardandClaude Opus 5 586875f8c5 feat: 身份卡改上下两张卡(正面/背面) + 手机号硬门槛
- canvas 重画成上下两张:正面(暖黄,头像/名字性别/性别品种毛色年龄/性格
  彩色标签/介绍/物ID+一起生活天数)、背面(蓝色照护信息条 + 主人/电话/疫苗/
  备注/发证机构/有效期 + 走失联系框&小程序码)
- 生成前查手机号:没有则唤起 getPhoneNumber 组件,验证成功才生成;
  拒绝则提示「无法生成」不出卡
- IDCard 增 age/days;ic-canvas 高度改 1480 否则导出被裁

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:48:39 +08:00
BlizzardandClaude Opus 5 15adfb76ac feat(fe): 身份卡重绘成正面+照护信息+证件三段式 + 手机号验证
- 正面:头像/名字/物种性别 + 品种·毛色 / 出生·星座 / 体重 + 性格标签chips
- 照护信息:主人 / 联系电话 / 疫苗情况 / 备注(去掉驱虫、过敏)
- 证件区:居民身份卡 + 身份ID / 发证机构 / 有效期 + 小程序码
- 未验证手机号时页面出现「用微信验证手机号」(getPhoneNumber),
  拿到 code 换手机号后重取卡重画;表单去掉过敏输入

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:28:26 +08:00
407 changed files with 36246 additions and 25128 deletions
@@ -1,30 +1,41 @@
name: deploy
name: ci-cd
# 只认 main(含 PR 合并入 main)。
# runner 上构建单个应用镜像 → docker save 打包 → scp 镜像包 + compose 到服务器
# → 服务器 docker load + docker compose up -d(用预构建镜像,不在服务器上 build)。
# 服务器上只有 compose + .env(+ 临时镜像包),没有源码。
on:
push:
branches: [main]
workflow_dispatch:
on: [push]
env:
REMOTE_DIR: /home/workspace/sundynix-pet
IMAGE: sundynix/pets-be:latest
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: 检出代码
env:
GITEA_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
"https://git.sundynix.cn/${{ github.repository }}/archive/${{ github.sha }}.tar.gz" \
| tar -xz --strip-components=1
- name: 测试并构建后端与管理端
run: docker build -f pets-be/Dockerfile -t "sundynix/pets-be:ci-${{ github.sha }}" pets-be
deploy:
needs: verify
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest # 须匹配已注册 runner 的标签
steps:
# 不用 actions/checkout(它要去 github.com 下载 action,内网 runner 连不上会 EOF);
# 直接从内网 Gitea 用工作流自带 token 拉本仓库当前分支。
# Gitea 的 Git HTTP pack 当前不可用,改用带 job token 的归档接口检出当前提交。
- name: 检出代码(内网 Gitea)
env:
GITEA_TOKEN: ${{ github.token }}
run: |
git init -q .
git remote add origin "https://token:${{ github.token }}@git.sundynix.cn/${{ github.repository }}.git"
git fetch -q --depth 1 origin "${{ github.ref_name }}"
git checkout -q FETCH_HEAD
set -euo pipefail
curl -fLsS -H "Authorization: token ${GITEA_TOKEN}" \
"https://git.sundynix.cn/${{ github.repository }}/archive/${{ github.sha }}.tar.gz" \
| tar -xz --strip-components=1
# 单镜像:内部先用 node 构建管理后台前端,再 Go 编译内嵌进二进制。
# 构建上下文是 pets-be/(go.mod 和 web/admin 都在里面)。
+1 -3
View File
@@ -9,6 +9,7 @@ pets-be/bin/
*.exe
*.out
./pets-be/.idea
# Node
pets-be/web/admin/node_modules/
npm-debug.log*
@@ -17,8 +18,5 @@ yarn-error.log*
# Logs / runtime
*.log
# Secrets — real config stays local; copy from config.example.yaml
pets-be/configs/config.yaml
# 服务器环境变量(含密钥)
deploy/.env
+10
View File
@@ -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
+10
View File
@@ -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>
+8
View File
@@ -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>
+9
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+16 -59
View File
@@ -1,67 +1,24 @@
# 肉垫计划 · 服务器环境变量
# 用法:cp .env.example .env && vi .env
# .env 只放在服务器 /home/workspace/sundynix-pet 下,不进 git,CI 也不会覆盖它。
POSTGRES_HOST=192.168.100.127
POSTGRES_PORT=5432
POSTGRES_USER=root
POSTGRES_PASSWORD=root
POSTGRES_DATABASE=sundynix-pets
POSTGRES_SSL_MODE=disable
# ── 外部 MySQL ──
# 注意:容器里的 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_ENDPOINT=https://s3.sundynix.cn
MINIO_ACCESS_KEY=sundynix
MINIO_SECRET_KEY=sundynix
MINIO_BUCKET=sundynix-pet
# 图片对外地址,小程序直接加载它。正式版必须 https。
MINIO_PUBLIC_BASE_URL=https://res.sundynix.cn
MINIO_USE_SSL=true
# ── 登录鉴权 ──
JWT_SECRET=改成随机长字符串
# access token 有效期(小时)。短一点更安全,过期由前端拿 refresh token 静默续期
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) ──
JWT_REFRESH_HOURS=2
JWT_SECRET=
WECHAT_APP_ID=wx52dfc635739a9c19
WECHAT_APP_SECRET=
ADMIN_USERNAME=sundynix
ADMIN_PASSWORD=改成强密码
# ── 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) ──
ADMIN_PASSWORD=sundynix
TAG=latest
# 高德 Web 服务 Key
AMAP_KEY=
+12 -48
View File
@@ -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:
app:
image: sundynix/pets-be:${TAG:-latest}
@@ -17,54 +5,30 @@ services:
ports:
- "4000:9090"
environment:
PETS_AMAP_KEY: ${AMAP_KEY:-}
PETS_SERVER_PORT: "9090"
PETS_SERVER_MODE: release
# ── 外部 MySQL ──
PETS_MYSQL_HOST: ${MYSQL_HOST:?}
PETS_MYSQL_PORT: ${MYSQL_PORT:-3306}
PETS_MYSQL_USER: ${MYSQL_USER:?}
PETS_MYSQL_PASSWORD: ${MYSQL_PASSWORD:?}
PETS_MYSQL_DATABASE: ${MYSQL_DATABASE:-pets}
PETS_MYSQL_CHARSET: utf8mb4
# ── 外部 MinIO ──
PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:?}
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
PETS_POSTGRES_HOST: ${POSTGRES_HOST:-192.168.100.127}
PETS_POSTGRES_PORT: ${POSTGRES_PORT:-5432}
PETS_POSTGRES_USER: ${POSTGRES_USER:-root}
PETS_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?}
PETS_POSTGRES_DATABASE: ${POSTGRES_DATABASE:-sundynix-pets}
PETS_POSTGRES_SSL_MODE: ${POSTGRES_SSL_MODE:-disable}
PETS_MINIO_ENDPOINT: ${MINIO_ENDPOINT:-https://s3.sundynix.cn}
PETS_MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?}
PETS_MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:?}
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-pets}
# 图片对外访问地址,小程序直接加载;正式版必须 https
PETS_MINIO_PUBLIC_BASE_URL: ${MINIO_PUBLIC_BASE_URL:?}
PETS_MINIO_BUCKET: ${MINIO_BUCKET:-sundynix-pet}
PETS_MINIO_USE_SSL: ${MINIO_USE_SSL:-true}
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_JWT_REFRESH_HOURS: ${JWT_REFRESH_HOURS:-2}
PETS_WECHAT_APP_ID: ${WECHAT_APP_ID:?}
PETS_WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:?}
# 内容安全图片异步检测的结果回调校验 Token(消息推送服务器配置里的 Token)。
# 可选:留空则回调不校验签名;配了才和微信后台的 Token 对上
PETS_WECHAT_MSG_TOKEN: ${WECHAT_MSG_TOKEN:-}
# 生产必须 false,否则任何人都能用昵称直接登录
PETS_AUTH_DEV_LOGIN: ${AUTH_DEV_LOGIN:-false}
PETS_AUTH_DEV_LOGIN: "false"
PETS_ADMIN_USERNAME: ${ADMIN_USERNAME:-sundynix}
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:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9090/api/ping"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
+163
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+1
View File
@@ -24,6 +24,7 @@ RUN go mod download
COPY . .
# 用真实构建产物覆盖 embed 目录(internal/admin/embed.go 里 //go:embed all:dist)
COPY --from=admin /app/dist ./internal/admin/dist
RUN go test ./...
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pets-be ./cmd/server
# ── 2. 运行镜像 ──
+2 -9
View File
@@ -1,15 +1,8 @@
.PHONY: env-up env-down admin-install admin-build admin-dev build run tidy
# 起/停 MySQL + MinIO
env-up:
cd deployments && docker compose up -d
env-down:
cd deployments && docker compose down
.PHONY: admin-install admin-build admin-dev build run tidy
# 后台前端
admin-install:
cd web/admin && npm install
cd web/admin && npm ci
admin-build:
cd web/admin && npm run build
+42 -55
View File
@@ -1,64 +1,51 @@
# 毛孩子计划 · 后端 (pets-be)
# 肉垫计划
Gin + GORM + MySQL + MinIO 的宠物小程序后端,内嵌 React(Vite + Tailwind + shadcn/ui) 后台管理。
## 技术栈与约定
- 模块路径 `github.com/sundynix/pets-be`,标准 Go 布局(`cmd/ internal/ pkg/ configs/ deployments/ web/`)
- 所有数据库表带前缀 `sundynix_`
- 统一响应 `{code, message, data}`(`pkg/response`),列表接口统一分页参数 `page` / `page_size`(`response.PageQuery` + `PageResult`)
- MySQL `root/root` 库 `pets`;MinIO `sundynix/sundynix` 桶 `pets`
- 服务端口 `:8080`;后台在 `http://localhost:8080/admin`(seed 管理员 `sundynix/sundynix`)
## 快速开始
```bash
# 1. 起依赖(MySQL + MinIO)
make env-up # 或 cd deployments && docker compose up -d
# 2. 构建后台前端 + 后端单二进制
make build # 产物 bin/pets-be(已 embed 后台)
./bin/pets-be
# 或开发期直接运行(使用现有 internal/admin/dist)
make run
```
### 后台前端开发(热更新)
```bash
make admin-dev # vite dev server :5173,已代理 /api → :8080
# 改完执行 make admin-build 让产物落到 internal/admin/dist,再 go build 即 embed
```
核心功能:多宠物档案、日常照顾任务、多宠物遛狗与主动位置分享、约遛。后端用 Gin + GORM + PostgreSQL,管理端用 React + Vite + shadcn/ui + Tailwind,构建后通过 `go:embed` 随服务提供 `/`。
## 目录
- `cmd/server` 入口:载配置→连库→迁移→seed→建桶→路由→启动
- `internal/config` 配置(viper,支持 `PETS_` 环境变量覆盖)
- `internal/model` GORM 模型(`sundynix_` 前缀)
- `internal/database` 连接/迁移/seed
- `internal/storage` MinIO 封装(自动建桶 + 上传)
- `internal/service` 业务逻辑;`internal/handler` HTTP 处理器;`internal/router` 路由
- `internal/middleware` CORS / 用户鉴权 / 管理员鉴权
- `internal/admin` `go:embed` 内嵌后台产物 + SPA fallback
- `pkg/response` 统一响应与分页;`pkg/jwt` 令牌;`pkg/errcode` 错误码
- `web/admin` 后台前端源码
## 接口
- 小程序 `/api/*`(用户 JWT):auth / user / pets / onboarding / records / tasks / plan / reminders / report / bill / community / articles / pro / ai / upload
- 后台 `/api/admin/*`(管理员 JWT):login / stats / users / pets / posts / comments / articles / memberships
- `cmd/server`:服务入口
- `internal/config`:配置
- `internal/model`:数据库模型
- `internal/database`:PostgreSQL 连接和迁移
- `internal/storage`:MinIO 文件存储
- `internal/httpapi`:用户与管理端 API
- `web/admin`:管理端源码
- `internal/admin/dist`:嵌入的管理端构建产物
## 登录鉴权
默认 `auth.dev_login: true`,`POST /api/auth/login {"nickname":"xxx"}` 即发用户 JWT。
接真微信:在 `configs/config.yaml` 填 `wechat.app_secret` 并把 `dev_login` 置 false,实现 `/api/auth/wechat`(code2session)。
## 本地启动
### access token + refresh token
登录接口返回 `token`(access) 与 `refresh_token` 两个令牌:
1. `cp configs/config.example.yaml configs/config.yaml`,设置 PostgreSQL、MinIO、JWT 和微信参数。
2. `cd web/admin && npm ci && npm run build`。
3. 若数据库还不存在,在 `pets-be` 目录执行一次 `go run ./cmd/dbinit`。
4. 执行 `go run ./cmd/server`。
- **access token**:JWT,默认 **2 小时**(`jwt.expire_hours`),放 `Authorization: Bearer`,服务端无状态校验。
- **refresh token**:随机串,落库 `sundynix_refresh_tokens`(**只存 sha256**)。小程序 30 天、后台 7 天。
- 续期:`POST /api/auth/refresh` / `POST /api/admin/refresh`,入参 `{"refresh_token":"..."}`,返回新的一对令牌。
- 退出:`POST /api/auth/logout` / `POST /api/admin/logout` 作废该刷新令牌。
默认业务接口 `http://localhost:9090/api`,管理端 `http://localhost:9090/`。小程序生产 API 地址是 `https://pet.sundynix.cn/api`。
安全约定:
- **每次续期都轮换**刷新令牌,旧的立即作废;作废后 60 秒内再到达算并发重试(放行),超过则判定泄露,**吊销该账号全部会话**。
- 主动退出、被连坐吊销的令牌不享受宽限期。
- 禁用用户时一并吊销其刷新令牌,最多 2 小时后彻底失去访问。
配置可用 `PETS_` 前缀的环境变量覆盖,例如 `PETS_POSTGRES_PASSWORD`。生产模式必须设置 `PETS_ADMIN_PASSWORD`,微信登录需要 App ID 和 App Secret。`auth.dev_login` 只能在 debug 模式开启。
前端两端都做了单飞续期:并发请求同时 401 只会发一次 refresh(否则刷新令牌会被并发轮换掉)。小程序续期失败会回退到 `wx.login` 重新登录。
## 数据
GORM 使用 `sundynix_` 表前缀、单数 snake_case 表名和字段名,例如 `sundynix_user`。主键均为 PostgreSQL `uuid`,Go 字段为 `string`。文件内容存 MinIO;`sundynix_file` 存 bucket、object_key、md5、url 和元信息。头像与约遛封面只通过 `*_file_id` 关联文件表。上传会先按 MD5 查重。
现有 MySQL 数据未自动迁移。新的 PostgreSQL 表在服务启动时自动创建。正式切换旧数据前,应单独设计数据迁移映射并备份。
## 部署
`../deploy/docker-compose.yml` 使用外部 PostgreSQL 和 MinIO;把 `../deploy/.env.example` 复制为 `.env` 后设置凭据。`pet.sundynix.cn` 用于业务 API;`s3.sundynix.cn` 用于文件上传和图片直链。URL 由 MinIO endpoint、bucket 和 object key 拼接,在上传时写入文件表,格式为 `https://s3.sundynix.cn/sundynix-pet/<object_key>`。`res.sundynix.cn/browser/...` 是 MinIO 控制台页面,不能作为图片直链。
## 管理端路由与犬种百科
菜单使用独立路由:`/admin/overview`、`/admin/users`、`/admin/pets`、`/admin/breeds`、`/admin/walks`、`/admin/meetups`、`/admin/tasks`、`/admin/files`;登录页 `/admin/login`。根路径自动进入工作台或登录页;Go 的 SPA 回退支持刷新和直达链接。业务接口仍在 `/api`,无需额外 API 前缀。
犬种目录自动导入 350 项(CKU 去重 347 项,另补充三种实用分类),按名称拼音 A–Z 排序。选品种使用 `breed_id`,手动填写保留 `breed` 名称并清空 `breed_id`。后台提供新增、编辑、启用、停用、搜索及每页十条分页。停用品种不影响已有宠物档案。
CKU 当前返回完整详情的 88 个犬种已整理基本资料、简短改写介绍和来源链接;另三项补充分类提供原创介绍。资料缺失时显示待补充,后台可以编辑百科字段。图片与长篇犬种标准未转载。
## 登录刷新
管理端和小程序的访问 token、refresh token 都为 **2 小时**。请求前发现不足一分钟即刷新;401 也会尝试刷新并仅重试一次。刷新凭证每次轮换,数据库只存 SHA-256 摘要;两个角色的凭证不能混用,停用账号、过期及退出会话都不能继续刷新。后台退出会撤销会话和其访问 token。
刷新成功后,两种凭证重新计时两小时;超过两小时未使用则重新登录。小程序自动重新微信登录,管理端回到登录页并保留原目标路由。并发接口共用刷新请求,支持 Web Locks 的浏览器还会跨标签页串行刷新;网络失败保留凭证以便重试。
接口:`POST /api/auth/refresh`、`POST /api/admin/refresh`,请求体为 `{ "refresh_token": "..." }`。登录与刷新响应包含 `token`、`refresh_token`、`expires_in: 7200`、`expires_at`、`refresh_expires_at`(Unix 秒)。旧七天 token 会要求重新登录。时长按业务约束固定两小时,旧环境变量不会把期限拉长。
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"fmt"
"log"
"regexp"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
)
var databaseName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// dbinit creates the configured PostgreSQL database once. It does not change other databases.
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
name := cfg.Postgres.Database
if !databaseName.MatchString(name) {
log.Fatal("invalid database name")
}
maintenance := cfg.Postgres
maintenance.Database = "postgres"
db, err := gorm.Open(postgres.Open(maintenance.DSN()), &gorm.Config{})
if err != nil {
log.Fatal(err)
}
var count int64
if err := db.Raw("SELECT count(*) FROM pg_database WHERE datname = ?", name).Scan(&count).Error; err != nil {
log.Fatal(err)
}
if count > 0 {
log.Printf("database %s already exists", name)
return
}
if err := db.Exec(fmt.Sprintf("CREATE DATABASE \"%s\"", name)).Error; err != nil {
log.Fatal(err)
}
log.Printf("created database %s", name)
}
+36
View File
@@ -0,0 +1,36 @@
// seedbreeds imports the embedded CKU breed catalog without overwriting administrator edits.
package main
import (
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
log.Fatal(err)
}
// Only the schema needed by these features, leaving unrelated application records intact.
if err := db.AutoMigrate(&model.Breed{}, &model.BreedReference{}, &model.Pet{}, &model.RefreshSession{}); err != nil {
log.Fatal(err)
}
added, err := breeds.Seed(db)
if err != nil {
log.Fatal(err)
}
var total int64
if err := db.Model(&model.Breed{}).Count(&total).Error; err != nil {
log.Fatal(err)
}
var sources int64
db.Model(&model.BreedReference{}).Count(&sources)
log.Printf("breed catalog imported: added=%d total=%d references=%d", added, total, sources)
}
+18 -34
View File
@@ -2,56 +2,40 @@ package main
import (
"fmt"
"log"
"github.com/sundynix/pets-be/internal/ai"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/admin"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/handler"
"github.com/sundynix/pets-be/internal/router"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/internal/httpapi"
"github.com/sundynix/pets-be/internal/storage"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
log.Fatal(err)
}
db, err := database.New(cfg)
db, err := database.Open(cfg.Postgres, cfg.Server.Mode == "release")
if err != nil {
log.Fatalf("connect mysql: %v", err)
log.Fatal(err)
}
if err := database.Migrate(db); err != nil {
log.Fatalf("migrate: %v", err)
log.Fatal(err)
}
if err := database.Seed(db, cfg); err != nil {
log.Fatalf("seed: %v", err)
}
st, err := storage.New(cfg.MinIO)
if err != nil {
log.Fatalf("connect minio: %v", err)
log.Fatal(err)
}
engine := ai.New(cfg.AI)
log.Printf("AI provider: %s", engine.ProviderName())
svc := service.New(db, st, cfg, engine)
if err := svc.SeedCommunityBot(); err != nil {
log.Fatalf("seed community bot: %v", err)
api := httpapi.New(db, st, cfg)
if err := api.SeedAdmin(); err != nil {
log.Fatal(err)
}
svc.StartCommunityBot()
jm := appjwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
h := handler.New(svc, jm, cfg)
r := router.New(h, jm, cfg.Server.Mode)
gin.SetMode(cfg.Server.Mode)
r := gin.New()
api.Register(r)
admin.Register(r)
addr := fmt.Sprintf(":%d", cfg.Server.Port)
log.Printf("pets-be listening on %s (admin: http://localhost%s/admin)", addr, addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server run: %v", err)
}
log.Printf("肉垫计划 listening on %s", addr)
log.Fatal(r.Run(addr))
}
+19 -24
View File
@@ -1,43 +1,38 @@
server:
port: 9090
mode: debug # debug | release
mode: release
mysql:
host: 127.0.0.1
port: 3306
postgres:
host: 192.168.100.127
port: 5432
user: root
password: root
database: pets
charset: utf8mb4
database: sundynix-pets
ssl_mode: disable
minio:
endpoint: 127.0.0.1:9000
endpoint: https://s3.sundynix.cn
access_key: sundynix
secret_key: sundynix
bucket: pets
use_ssl: false
public_base_url: http://127.0.0.1:9000 # 拼接可访问的对象 URL
bucket: sundynix-pet
use_ssl: true
jwt:
secret: change-me-to-a-long-random-string # 生产用 PETS_JWT_SECRET 覆盖
secret: "" # 用 PETS_JWT_SECRET 注入
expire_hours: 2
refresh_hours: 2
wechat:
app_id: your-wechat-app-id
app_secret: your-wechat-app-secret # 敏感凭证:生产用 PETS_WECHAT_APP_SECRET 环境变量覆盖
app_id: ""
app_secret: ""
auth:
dev_login: true # 仍保留 Mock 登录便于本地 curl 调试;上线可置 false 只留微信登录
dev_login: false
admin:
username: sundynix
password: change-me # 启动时若无同名管理员则 seed 此账号
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
# 高德 Web 服务:首页所在城市与实况天气
amap:
key: ""
+38
View File
@@ -0,0 +1,38 @@
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:
expire_hours: 2
refresh_hours: 2
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
# 高德 Web 服务:首页所在城市与实况天气
amap:
key: "c6e0887a6cd0aeaf8f1fd3dd5704b8c3"
-45
View File
@@ -1,45 +0,0 @@
services:
mysql:
image: mysql:8.0
container_name: pets-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pets
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-authentication-plugin=mysql_native_password
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-proot"]
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:latest
container_name: pets-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: sundynix
MINIO_ROOT_PASSWORD: sundynix
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 5s
retries: 20
volumes:
mysql_data:
minio_data:
+9 -6
View File
@@ -1,20 +1,20 @@
module github.com/sundynix/pets-be
go 1.26.4
go 1.26.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/minio/minio-go/v7 v7.2.1
github.com/mozillazg/go-pinyin v0.21.0
github.com/spf13/viper v1.21.0
golang.org/x/crypto v0.53.0
gorm.io/datatypes v1.2.7
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -27,11 +27,13 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
@@ -63,6 +65,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
+11 -24
View File
@@ -1,5 +1,3 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -33,8 +31,6 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -43,10 +39,6 @@ github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -54,12 +46,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -83,8 +75,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
@@ -96,6 +86,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mozillazg/go-pinyin v0.21.0 h1:Wo8/NT45z7P3er/9YSLHA3/kjZzbLz5hR7i+jGeIGao=
github.com/mozillazg/go-pinyin v0.21.0/go.mod h1:iR4EnMMRXkfpFVV5FMi4FNB6wGq9NV6uDWbUuPhP4Yc=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
@@ -127,6 +119,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
@@ -174,15 +167,9 @@ gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk=
gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/postgres v1.5.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc=
gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

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

Before

Width:  |  Height:  |  Size: 4.9 KiB

+5 -5
View File
@@ -1,12 +1,12 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/admin/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/brand-icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<script type="module" crossorigin src="/admin/assets/index-f13soFkd.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-pepKB4Fe.css">
<title>肉垫计划 · 管理后台</title>
<script type="module" crossorigin src="/assets/index-N2FSM0pD.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CkSlqiFn.css">
</head>
<body>
<div id="root"></div>
+14 -6
View File
@@ -12,7 +12,7 @@ import (
//go:embed all:dist
var distFS embed.FS
// Register 把内嵌的后台 SPA 挂到 /admin 下,index.html 做 SPA fallback
// Register 把内嵌的后台 SPA 挂到根路径,保留 /api 给业务接口。
func Register(r *gin.Engine) {
sub, err := fs.Sub(distFS, "dist")
if err != nil {
@@ -21,9 +21,7 @@ func Register(r *gin.Engine) {
fileServer := http.FileServer(http.FS(sub))
handler := func(c *gin.Context) {
// 去掉前缀 /admin,交给内嵌文件服务器
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/admin")
reqPath = strings.TrimPrefix(reqPath, "/")
reqPath := strings.TrimPrefix(c.Request.URL.Path, "/")
// 若请求的是真实存在的静态资源,直出;否则回退 index.html(前端路由)
if reqPath != "" {
@@ -33,6 +31,10 @@ func Register(r *gin.Engine) {
fileServer.ServeHTTP(c.Writer, c.Request)
return
}
if strings.HasPrefix(reqPath, "assets/") {
c.Status(http.StatusNotFound)
return
}
}
// SPA fallback
data, err := fs.ReadFile(sub, "index.html")
@@ -43,6 +45,12 @@ func Register(r *gin.Engine) {
c.Data(http.StatusOK, "text/html; charset=utf-8", data)
}
r.GET("/admin", func(c *gin.Context) { c.Redirect(http.StatusFound, "/admin/") })
r.GET("/admin/*filepath", handler)
r.GET("/", handler)
r.NoRoute(func(c *gin.Context) {
if (c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead) || strings.HasPrefix(c.Request.URL.Path, "/api/") || c.Request.URL.Path == "/api" {
c.Status(http.StatusNotFound)
return
}
handler(c)
})
}
+56
View File
@@ -0,0 +1,56 @@
package admin
import (
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/httpapi"
)
func TestAdminAtRootAndAPIRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
httpapi.New(nil, nil, &config.Config{}).Register(r)
Register(r)
assets, err := fs.ReadDir(distFS, "dist/assets")
if err != nil {
t.Fatal(err)
}
var script string
for _, asset := range assets {
if strings.HasSuffix(asset.Name(), ".js") {
script = "/assets/" + asset.Name()
break
}
}
if script == "" {
t.Fatal("admin bundle has no JavaScript asset")
}
for _, tc := range []struct {
path string
status int
body string
}{
{path: "/", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/breeds", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/walks", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: "/admin/files", status: http.StatusOK, body: "<div id=\"root\"></div>"},
{path: script, status: http.StatusOK},
{path: "/brand-logo.png", status: http.StatusOK},
{path: "/api/ping", status: http.StatusOK, body: "pong"},
{path: "/api/missing", status: http.StatusNotFound},
{path: "/assets/missing.js", status: http.StatusNotFound},
} {
recorder := httptest.NewRecorder()
r.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, tc.path, nil))
if recorder.Code != tc.status || (tc.body != "" && !strings.Contains(recorder.Body.String(), tc.body)) {
t.Errorf("GET %s: got %d %q, want %d containing %q", tc.path, recorder.Code, recorder.Body.String(), tc.status, tc.body)
}
}
}
-73
View File
@@ -1,73 +0,0 @@
// Package ai 提供 provider 中立的大模型调用能力。
// 目前内置 openai 兼容 provider(适配 DeepSeek / Qwen 兼容模式 / Gemini 兼容端点等),
// 以及规则化 mock。新增其它厂商只需实现 Provider 接口。
package ai
import (
"github.com/sundynix/pets-be/internal/config"
)
// Message 一条对话消息
type Message struct {
Role string // system / user / assistant
Content string
}
// Options 单次调用参数
type Options struct {
JSON bool // 要求返回 JSON(结构化输出)
MaxTokens int // 0 用配置默认
Temperature float64 // <0 用配置默认
}
// Provider 大模型供应商接口
type Provider interface {
Name() string
// Complete 传入 system 提示与多轮消息,返回助手回复文本
Complete(system string, messages []Message, opts Options) (string, error)
}
// Engine 对外统一入口,持有当前 provider 与开关
type Engine struct {
provider Provider
cfg config.AIConfig
}
// New 根据配置构建引擎。未启用或缺 key 时 Enabled()=false,业务侧回退规则化文案。
func New(cfg config.AIConfig) *Engine {
e := &Engine{cfg: cfg}
if !cfg.Enabled || cfg.APIKey == "" {
return e
}
switch cfg.Provider {
case "", "openai":
e.provider = newOpenAICompatProvider(cfg)
default:
// 未知 provider 视为未启用,回退规则化
}
return e
}
// Enabled 是否可用真实模型
func (e *Engine) Enabled() bool {
return e.provider != nil
}
// ProviderName 当前供应商名(诊断用)
func (e *Engine) ProviderName() string {
if e.provider == nil {
return "disabled"
}
return e.provider.Name()
}
// Complete 代理到当前 provider(调用前请先判断 Enabled)
func (e *Engine) Complete(system string, messages []Message, opts Options) (string, error) {
if opts.MaxTokens == 0 {
opts.MaxTokens = e.cfg.MaxTokens
}
if opts.Temperature < 0 {
opts.Temperature = e.cfg.Temperature
}
return e.provider.Complete(system, messages, opts)
}
-122
View File
@@ -1,122 +0,0 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/sundynix/pets-be/internal/config"
)
// openaiCompatProvider 走 OpenAI 兼容的 /chat/completions 协议。
// DeepSeek、Qwen(dashscope 兼容模式)、Moonshot、Gemini(OpenAI 兼容端点) 等均可用。
type openaiCompatProvider struct {
baseURL string
apiKey string
model string
client *http.Client
}
func newOpenAICompatProvider(cfg config.AIConfig) *openaiCompatProvider {
timeout := time.Duration(cfg.TimeoutSec) * time.Second
if timeout <= 0 {
timeout = 30 * time.Second
}
return &openaiCompatProvider{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
apiKey: cfg.APIKey,
model: cfg.Model,
client: &http.Client{Timeout: timeout},
}
}
func (p *openaiCompatProvider) Name() string { return "openai-compat:" + p.model }
type chatReq struct {
Model string `json:"model"`
Messages []chatMsg `json:"messages"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
ResponseFormat *respFormat `json:"response_format,omitempty"`
}
type chatMsg struct {
Role string `json:"role"`
Content string `json:"content"`
}
type respFormat struct {
Type string `json:"type"`
}
type chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func (p *openaiCompatProvider) Complete(system string, messages []Message, opts Options) (string, error) {
msgs := make([]chatMsg, 0, len(messages)+1)
if system != "" {
msgs = append(msgs, chatMsg{Role: "system", Content: system})
}
for _, m := range messages {
msgs = append(msgs, chatMsg{Role: m.Role, Content: m.Content})
}
body := chatReq{
Model: p.model,
Messages: msgs,
Temperature: opts.Temperature,
MaxTokens: opts.MaxTokens,
}
if opts.JSON {
body.ResponseFormat = &respFormat{Type: "json_object"}
}
raw, _ := json.Marshal(body)
ctx, cancel := context.WithTimeout(context.Background(), p.client.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/chat/completions", bytes.NewReader(raw))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
var out chatResp
if err := json.Unmarshal(data, &out); err != nil {
return "", fmt.Errorf("ai 响应解析失败: %s", truncate(string(data), 200))
}
if out.Error != nil {
return "", fmt.Errorf("ai 供应商错误: %s", out.Error.Message)
}
if resp.StatusCode >= 400 || len(out.Choices) == 0 {
return "", fmt.Errorf("ai 调用失败(%d): %s", resp.StatusCode, truncate(string(data), 200))
}
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
+33
View File
@@ -0,0 +1,33 @@
# 犬种目录与百科
## 数据结构
- `sundynix_breed`:UUID 主键、中文名、英文名、别名、拼音与首字母、启用状态;起源地、FCI 编号和分组、肩高、体重、寿命、介绍、性格、运动、毛发护理、相处笔记、指数。`field_sources` 使用 PostgreSQL JSONB 保存展示字段的来源机构。
- `sundynix_breed_reference`:UUID 主键,`breed_id` 关联犬种;机构、机构内编号、原始页面链接、核对日期、事实字段(JSONB)。每个犬种与机构唯一。参考尺寸和指数按机构分别保存,不合并为统一标准。
- `sundynix_pet.breed_id`:可空。选目录品种时关联 UUID;手动填写时仅保留 `breed` 文本。
- `editorial_locked`:管理员保存资料后锁定展示内容,重复导入不会覆盖人工修改或清空的字段。来源资料只读,导入仅在其事实内容发生变化时更新。
## 展示
管理端 `/admin/breeds` 支持每页 10 条分页、名称/别名/拼音搜索、A–Z 筛选、启停与新增编辑。编辑窗切换“展示资料 / 官方来源”,可以对照各机构标准与评分。展示字段可手动完善,缺失字段留空。
小程序“知识”提供搜索、中文拼音 A–Z 筛选和详情;详情分为基本信息、性格指数、散步与护理、资料出处,字段注明来源,引用链接可复制。所有文案为简短中文整理,不保留来源长篇文章或图片。品种选择组件复用于新增/编辑档案,也允许手填。
## 采集与导入
2026-10-05:CKU 公开列表 348 项,按中文名称去重后 347 项。补充中华田园犬、混种犬、贵宾犬三项实用分类,合计 350 项。
- CKU:https://www.cku.org.cn/oa-web/api/v1/open/getFciDogAll
88 个详情接口有完整资料,整理基本事实与简短性格标签。
- FCI:https://www.fci.be/en/nomenclature/
使用编号准确关联目录,保存官方名称、原产地和分组;变种保留自己的中文档案,来源标明基础标准范围。分组页面归属国字段用于起源地参考,尺寸不从目录推测。
- AKC:https://www.akc.org/dog-breeds/
只使用准确英文名或已核对的等价名称匹配,提取结构化尺寸、寿命和指数。尺寸展示统一换算 cm/kg,原始单位保留在来源资料。变种不套用父犬种的 AKC 展示数据;父犬种参考资料明确标注标准范围。
- 英国皇家犬业俱乐部:https://www.royalkennelclub.com/breeds-a-z
本机直连超时,先从官方页面核对 8 个犬种的运动、护理和寿命信息,记录于 `rkc_facts.json`。
`data.json` 保存中文目录与已有摘要;`enrichment.json` 保存多来源的事实快照和展示补充。`rkc_facts.json` 是人工核对的事实记录。
从仓库根目录执行 `python3 pets-be/scripts/collect_breed_facts.py` 可重新生成补充数据。采集脚本仅用 Python 标准库,缓存置于 `/private/tmp/pawprint-breed-sources`;机构连接失败不伪造数据。
服务器迁移时自动导入;也可在 `pets-be` 目录执行 `go run ./cmd/seedbreeds`。内容无变化时跳过资料更新。导入不会恢复管理员改名或停用的品种,人工编辑后的展示内容不会被刷新覆盖。
+102
View File
@@ -0,0 +1,102 @@
package breeds
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"unicode"
"github.com/mozillazg/go-pinyin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
//go:embed data.json
var data []byte
// SortFields provides both full pinyin and initials, retaining Latin breed names.
func SortFields(name string) (string, string, string) {
args := pinyin.NewArgs()
args.Fallback = func(r rune, _ pinyin.Args) []string {
if unicode.IsLetter(r) || unicode.IsDigit(r) {
return []string{strings.ToLower(string(r))}
}
return nil
}
words := pinyin.LazyPinyin(name, args)
// Breed-name phrases with common alternative pronunciations.
if strings.HasPrefix(name, "重庆") && len(words) > 0 {
words[0] = "chong"
}
if strings.HasPrefix(name, "长毛") && len(words) > 0 {
words[0] = "chang"
}
full := strings.Join(words, "")
var initials strings.Builder
for _, word := range words {
if word != "" {
initials.WriteByte(word[0])
}
}
initial := "#"
if len(full) > 0 && full[0] >= 'a' && full[0] <= 'z' {
initial = strings.ToUpper(full[:1])
}
return full, initials.String(), initial
}
func Defaults() ([]model.Breed, error) {
var rows []struct {
Origin string `json:"origin"`
BreedGroup string `json:"breed_group"`
FCINumber string `json:"fci_number"`
EnglishName string `json:"english_name"`
Height string `json:"height"`
Weight string `json:"weight"`
Summary string `json:"summary"`
Temperament string `json:"temperament"`
Traits string `json:"traits"`
CareNotes string `json:"care_notes"`
EncyclopediaUpdatedAt string `json:"encyclopedia_updated_at"`
Name string `json:"name"`
Aliases string `json:"aliases"`
Source string `json:"source"`
SourceKey string `json:"source_key"`
SourceURL string `json:"source_url"`
}
if err := json.Unmarshal(data, &rows); err != nil {
return nil, err
}
result := make([]model.Breed, 0, len(rows))
for _, row := range rows {
full, short, initial := SortFields(row.Name)
key := row.SourceKey
result = append(result, model.Breed{Origin: row.Origin, BreedGroup: row.BreedGroup, FCINumber: row.FCINumber, EnglishName: row.EnglishName, Height: row.Height, Weight: row.Weight, Summary: row.Summary, Temperament: row.Temperament, Traits: row.Traits, CareNotes: row.CareNotes, EncyclopediaUpdatedAt: row.EncyclopediaUpdatedAt, Name: row.Name, Aliases: row.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: true, Source: row.Source, SourceKey: &key, SourceURL: row.SourceURL})
}
return result, nil
}
// Existing entries (including edits and disabled breeds) are never overwritten.
func Seed(db *gorm.DB) (int64, error) {
rows, err := Defaults()
if err != nil {
return 0, fmt.Errorf("load breeds: %w", err)
}
result := db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(rows, 100)
if result.Error != nil {
return result.RowsAffected, result.Error
}
// Backfill newly introduced encyclopedia fields exactly once on untouched seed rows.
// Future starts never overwrite administrator changes.
for _, row := range rows {
if row.Summary == "" {
continue
}
if err := db.Model(&model.Breed{}).Where("source_key = ? AND name = ? AND (encyclopedia_updated_at IS NULL OR encyclopedia_updated_at = '') AND updated_at = created_at", row.SourceKey, row.Name).Updates(map[string]any{"origin": row.Origin, "breed_group": row.BreedGroup, "fci_number": row.FCINumber, "english_name": row.EnglishName, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "traits": row.Traits, "care_notes": row.CareNotes, "encyclopedia_updated_at": row.EncyclopediaUpdatedAt}).Error; err != nil {
return result.RowsAffected, err
}
}
return result.RowsAffected, enrich(db)
}
+59
View File
@@ -0,0 +1,59 @@
package breeds
import (
"strings"
"testing"
)
func TestCatalogAndPinyin(t *testing.T) {
rows, err := Defaults()
if err != nil {
t.Fatal(err)
}
if len(rows) != 350 {
t.Fatalf("catalog count %d", len(rows))
}
names, sources := map[string]bool{}, map[string]bool{}
for _, row := range rows {
if names[row.Name] || row.SourceKey == nil || sources[*row.SourceKey] || row.Pinyin == "" || row.Initial == "" {
t.Fatalf("invalid catalog row: %s", row.Name)
}
names[row.Name] = true
sources[*row.SourceKey] = true
}
for name, want := range map[string]string{"柯基": "K", "重庆犬": "C", "长毛吉娃娃": "C", "中华田园犬": "Z", "Border Collie": "B"} {
full, short, initial := SortFields(name)
if initial != want || full == "" || short == "" {
t.Fatalf("%s: %s/%s/%s", name, full, short, initial)
}
}
}
func TestOfficialSources(t *testing.T) {
rows, err := Enrichments()
if err != nil {
t.Fatal(err)
}
counts := map[string]int{}
for _, row := range rows {
seen := map[string]bool{}
for _, ref := range row.References {
if seen[ref.Provider] || len(ref.Facts) == 0 || ref.URL == "" || ref.RetrievedAt == "" {
t.Fatalf("invalid reference for %s", row.Name)
}
seen[ref.Provider] = true
counts[ref.Provider]++
if ref.Provider == "fci" && "cku:"+ref.ExternalID != strings.TrimRight(row.SourceKey, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
t.Fatalf("wrong FCI mapping %s/%s", row.SourceKey, ref.ExternalID)
}
}
for key, provider := range row.FieldSources {
if provider != "editorial" && !seen[provider] {
t.Fatalf("%s %s has missing source %s", row.Name, key, provider)
}
}
}
if len(rows) != 350 || counts["fci"] != 347 || counts["akc"] < 160 || counts["rkc"] != 8 {
t.Fatalf("incomplete import: %v", counts)
}
}
File diff suppressed because it is too large Load Diff
+100
View File
@@ -0,0 +1,100 @@
package breeds
import (
_ "embed"
"encoding/json"
"fmt"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"reflect"
)
//go:embed enrichment.json
var enrichmentData []byte
type Enrichment struct {
SourceKey string `json:"source_key"`
Name string `json:"name"`
Fields map[string]string `json:"fields"`
FieldSources map[string]string `json:"field_sources"`
References []model.BreedReference `json:"references"`
}
func Enrichments() ([]Enrichment, error) {
var rows []Enrichment
err := json.Unmarshal(enrichmentData, &rows)
return rows, err
}
// Reference facts remain available even when an editor locks the display content.
func enrich(db *gorm.DB) error {
rows, err := Enrichments()
if err != nil {
return fmt.Errorf("load breed sources: %w", err)
}
defaults, err := Defaults()
if err != nil {
return err
}
baseline := map[string]model.Breed{}
for _, breed := range defaults {
baseline[*breed.SourceKey] = breed
}
for _, item := range rows {
var breed model.Breed
if err := db.Where("source_key = ?", item.SourceKey).First(&breed).Error; err != nil {
return err
}
for _, source := range item.References {
source.BreedID = breed.ID
// Source snapshots are read-only in admin; update only when factual metadata changes.
if err := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "breed_id"}, {Name: "provider"}}, DoUpdates: clause.AssignmentColumns([]string{"external_id", "url", "retrieved_at", "facts"}), Where: clause.Where{Exprs: []clause.Expression{clause.Expr{SQL: "sundynix_breed_reference.facts IS DISTINCT FROM EXCLUDED.facts OR sundynix_breed_reference.url IS DISTINCT FROM EXCLUDED.url OR sundynix_breed_reference.retrieved_at IS DISTINCT FROM EXCLUDED.retrieved_at"}}}}).Create(&source).Error; err != nil {
return err
}
}
if breed.EditorialLocked || breed.Name != item.Name {
continue
}
original := baseline[item.SourceKey]
updates := map[string]any{"english_name": original.EnglishName, "origin": original.Origin, "breed_group": original.BreedGroup, "fci_number": original.FCINumber, "height": original.Height, "weight": original.Weight, "summary": original.Summary, "temperament": original.Temperament, "traits": original.Traits, "care_notes": original.CareNotes, "life_span": "", "exercise": "", "grooming": ""}
for field, value := range item.Fields {
switch field {
case "english_name", "origin", "breed_group", "fci_number", "height", "weight", "life_span", "exercise", "grooming", "temperament", "traits", "summary", "care_notes":
updates[field] = value
default:
return fmt.Errorf("unsupported breed field: %s", field)
}
}
origins, err := json.Marshal(item.FieldSources)
if err != nil {
return err
}
updates["field_sources"] = string(origins)
latest := original.EncyclopediaUpdatedAt
for _, ref := range item.References {
if ref.RetrievedAt > latest {
latest = ref.RetrievedAt
}
}
updates["encyclopedia_updated_at"] = latest
// Skip unchanged columns so ordinary starts do not rewrite every breed.
current := map[string]string{"english_name": breed.EnglishName, "origin": breed.Origin, "breed_group": breed.BreedGroup, "fci_number": breed.FCINumber, "height": breed.Height, "weight": breed.Weight, "life_span": breed.LifeSpan, "exercise": breed.Exercise, "grooming": breed.Grooming, "temperament": breed.Temperament, "traits": breed.Traits, "summary": breed.Summary, "care_notes": breed.CareNotes, "encyclopedia_updated_at": breed.EncyclopediaUpdatedAt}
for key, value := range updates {
if key != "field_sources" && current[key] == value {
delete(updates, key)
}
}
if reflect.DeepEqual(breed.FieldSources, item.FieldSources) {
delete(updates, "field_sources")
}
if len(updates) == 0 {
continue
}
// UpdateColumns avoids advancing UpdatedAt on each application start.
if err := db.Model(&breed).Where("editorial_locked = false AND name = ?", item.Name).UpdateColumns(updates).Error; err != nil {
return err
}
}
return nil
}
File diff suppressed because it is too large Load Diff
+114
View File
@@ -0,0 +1,114 @@
[
{
"source_key": "cku:39",
"external_id": "welsh-corgi-pembroke",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-pembroke/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:38",
"external_id": "welsh-corgi-cardigan",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/welsh-corgi-cardigan/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:86",
"external_id": "yorkshire-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/toy/yorkshire-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 30 分钟",
"grooming": "每天梳理",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "长毛",
"sheds": "掉毛较少"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:162",
"external_id": "whippet",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/hound/whippet/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "短毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:209",
"external_id": "tibetan-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每天梳理",
"life_span": "通常超过 12 年",
"size": "中型",
"coat_length": "长毛",
"sheds": "掉毛较少"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:231",
"external_id": "tibetan-spaniel",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/utility/tibetan-spaniel/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "中等毛长",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:85",
"external_id": "west-highland-white-terrier",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/terrier/west-highland-white-terrier/",
"facts": {
"exercise": "每日运动量参考:不超过 1 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 12 年",
"size": "小型",
"coat_length": "中等毛长",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
},
{
"source_key": "cku:347",
"external_id": "white-swiss-shepherd-dog-imp",
"url": "https://www.royalkennelclub.com/search/breeds-a-to-z/breeds/pastoral/white-swiss-shepherd-dog-imp/",
"facts": {
"exercise": "每日运动量参考:超过 2 小时",
"grooming": "每周梳理超过一次",
"life_span": "通常超过 10 年",
"size": "大型",
"coat_length": "中长毛",
"sheds": "会掉毛"
},
"retrieved_at": "2026-10-05"
}
]
+61 -100
View File
@@ -9,28 +9,19 @@ import (
"github.com/spf13/viper"
)
// Config 全局配置
type Config struct {
Server ServerConfig `mapstructure:"server"`
MySQL MySQLConfig `mapstructure:"mysql"`
MinIO MinIOConfig `mapstructure:"minio"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Auth AuthConfig `mapstructure:"auth"`
Admin AdminConfig `mapstructure:"admin"`
AI AIConfig `mapstructure:"ai"`
type AMapConfig struct {
Key string `mapstructure:"key"`
}
// 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 Config struct {
AMap AMapConfig `mapstructure:"amap"`
Server ServerConfig `mapstructure:"server"`
Postgres PostgresConfig `mapstructure:"postgres"`
MinIO MinIOConfig `mapstructure:"minio"`
JWT JWTConfig `mapstructure:"jwt"`
WeChat WeChatConfig `mapstructure:"wechat"`
Auth AuthConfig `mapstructure:"auth"`
Admin AdminConfig `mapstructure:"admin"`
}
type ServerConfig struct {
@@ -38,47 +29,37 @@ type ServerConfig struct {
Mode string `mapstructure:"mode"`
}
type MySQLConfig struct {
type PostgresConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
Charset string `mapstructure:"charset"`
SSLMode string `mapstructure:"ssl_mode"`
}
// DSN 返回 GORM MySQL 连接串
func (m MySQLConfig) DSN() string {
return fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local",
m.User, m.Password, m.Host, m.Port, m.Database, m.Charset,
)
func (p PostgresConfig) DSN() string {
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s TimeZone=Asia/Shanghai",
p.Host, p.Port, p.User, p.Password, p.Database, p.SSLMode)
}
type MinIOConfig struct {
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
PublicBaseURL string `mapstructure:"public_base_url"`
Endpoint string `mapstructure:"endpoint"`
AccessKey string `mapstructure:"access_key"`
SecretKey string `mapstructure:"secret_key"`
Bucket string `mapstructure:"bucket"`
UseSSL bool `mapstructure:"use_ssl"`
}
// JWTConfig access token 短、refresh token 长:
// access 过期由前端拿 refresh 静默续期,refresh 每次使用都轮换。
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"` // access token 有效期
RefreshExpireHours int `mapstructure:"refresh_expire_hours"` // 小程序用户刷新令牌有效期
AdminRefreshExpireHours int `mapstructure:"admin_refresh_expire_hours"` // 后台刷新令牌有效期(更短)
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
RefreshHours int `mapstructure:"refresh_hours"`
}
type WeChatConfig struct {
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
// MsgToken 消息推送(服务器配置)校验用的 Token。图片异步安全检测的结果
// 由微信 push 到我们的回调地址,用它校验来源。留空则回调不做校验(仅本地调试)
MsgToken string `mapstructure:"msg_token"`
}
type AuthConfig struct {
@@ -90,79 +71,59 @@ type AdminConfig struct {
Password string `mapstructure:"password"`
}
// setDefaults 为每个配置项登记默认值。
// 必须登记:viper 的 AutomaticEnv 只对「已知的 key」生效,未登记的 key 即使设了
// PETS_XXX 环境变量,Unmarshal 时也绑不上(容器里纯 .env 部署会静默取到空值)。
func setDefaults(v *viper.Viper) {
v.SetDefault("server.port", 9090)
v.SetDefault("server.mode", "release")
v.SetDefault("mysql.host", "127.0.0.1")
v.SetDefault("mysql.port", 3306)
v.SetDefault("mysql.user", "root")
v.SetDefault("mysql.password", "")
v.SetDefault("mysql.database", "pets")
v.SetDefault("mysql.charset", "utf8mb4")
v.SetDefault("minio.endpoint", "127.0.0.1:9000")
v.SetDefault("minio.access_key", "")
v.SetDefault("minio.secret_key", "")
v.SetDefault("minio.bucket", "pets")
v.SetDefault("minio.use_ssl", false)
v.SetDefault("minio.public_base_url", "")
v.SetDefault("jwt.secret", "")
v.SetDefault("jwt.expire_hours", 2)
v.SetDefault("jwt.refresh_expire_hours", 720) // 30 天
v.SetDefault("jwt.admin_refresh_expire_hours", 168)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
v.SetDefault("wechat.msg_token", "") // 用 PETS_WECHAT_MSG_TOKEN 覆盖
v.SetDefault("auth.dev_login", false)
v.SetDefault("admin.username", "sundynix")
v.SetDefault("admin.password", "")
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.base_url", "")
v.SetDefault("ai.api_key", "")
v.SetDefault("ai.model", "")
v.SetDefault("ai.temperature", 0.6)
v.SetDefault("ai.max_tokens", 1024)
v.SetDefault("ai.timeout_sec", 30)
}
// Load 载入配置:默认值 → configs/config.yaml(可选)→ 环境变量覆盖。
// 环境变量用 PETS_ 前缀、点转下划线,例如 PETS_MYSQL_PASSWORD 覆盖 mysql.password。
// 容器部署不带 config.yaml,全部走 .env 注入的环境变量。
func Load() (*Config, error) {
v := viper.New()
setDefaults(v)
v.SetDefault("amap.key", "")
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", 2)
v.SetDefault("jwt.refresh_hours", 2)
v.SetDefault("wechat.app_id", "")
v.SetDefault("wechat.app_secret", "")
v.SetDefault("auth.dev_login", false)
v.SetDefault("admin.username", "sundynix")
v.SetDefault("admin.password", "")
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.AddConfigPath("../../configs")
v.AddConfigPath(".")
v.SetEnvPrefix("PETS")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// 配置文件可选:找不到就只用默认值 + 环境变量;文件存在但解析失败仍要报错。
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) && !os.IsNotExist(err) {
var missing viper.ConfigFileNotFoundError
if !errors.As(err, &missing) && !os.IsNotExist(err) {
return nil, fmt.Errorf("read config: %w", err)
}
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
return nil, fmt.Errorf("decode config: %w", err)
}
if cfg.Postgres.Password == "" || cfg.JWT.Secret == "" {
return nil, errors.New("postgres.password and jwt.secret are required")
}
if cfg.MinIO.AccessKey == "" || cfg.MinIO.SecretKey == "" {
return nil, errors.New("minio access_key and secret_key are required")
}
if cfg.Server.Mode == "release" && cfg.Auth.DevLogin {
return nil, errors.New("dev login must be disabled in release mode")
}
// Access tokens have the same fixed two-hour lifetime for both clients.
cfg.JWT.ExpireHours = 2
cfg.JWT.RefreshHours = 2
return &cfg, nil
}
-233
View File
@@ -1,233 +0,0 @@
package database
import (
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/model"
)
// 养护方案预置:5 个阶段 × 2 物种,各一套「标准照护」。
//
// 内容按各阶段的真实生理重点配,不是把同一份东西换个名字:
// 幼年期 疫苗序列 + 驱虫 + 社会化,大型犬还要控制生长速度
// 青年期 绝育窗口 + 换成年粮
// 成年期 维持期,重点是把基线数据记下来(体重趋势、年度体检)
// 中年期 年度血检开始有意义,体重和牙结石重点看
// 老年期 体检频率加倍,猫看肾和甲状腺、狗看关节和心脏
//
// 和 record_types / breeds 一样只在表为空时铺一次,之后一切以数据库为准。
//
// 这些是「结构」不是「内容」:方案表空着的话建档最后一步没东西可挑,
// 用户建完档只能自己从零排计划。
func plan(title, desc, repeat, sheet string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindPlan, Repeat: repeat,
Title: title, Description: desc, SheetType: sheet}
}
func weekly(title, desc, weekdays, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatWeekly, sheet)
it.Weekdays = weekdays
return it
}
func monthly(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatMonthly, sheet)
it.MonthDay = day
return it
}
func once(title, desc string, day int, sheet string) model.CareTemplateItem {
it := plan(title, desc, model.RepeatOnce, sheet)
it.Day = day
return it
}
func remind(rtype, title string, offset int, freq string) model.CareTemplateItem {
return model.CareTemplateItem{Kind: model.CareKindReminder, ReminderType: rtype,
Title: title, OffsetDays: offset, Frequency: freq}
}
func important(it model.CareTemplateItem) model.CareTemplateItem {
it.Priority = "重要"
return it
}
// TplDef 一套方案的定义(迁移程序要读它)
type tplDef struct {
Species string
Stage string
Name string
Summary string
Items []model.CareTemplateItem
}
// CareTemplateDefs 导出给一次性迁移程序用
func CareTemplateDefs() []tplDef { return careTemplateDefs() }
// SeedCareTemplatesForce 不检查表空,直接铺。只给迁移程序用
func SeedCareTemplatesForce(db *gorm.DB) error { return insertCareTemplates(db) }
func careTemplateDefs() []tplDef {
const (
J = "幼年期"
Y = "青年期"
A = "成年期"
M = "中年期"
S = "老年期"
)
return []tplDef{
// ───────────────── 猫 ─────────────────
{model.SpeciesCat, J, "幼猫标准照护", "少量多餐、疫苗序列和社会化,六月龄前把基础打好", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼猫胃容量小,一天分 3-4 顿,用幼猫粮", model.RepeatDaily, "food")),
plan("清理猫砂盆", "每天铲一次,顺手看便便形状和颜色", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "逗猫棒模拟狩猎,消耗精力也是社会化", model.RepeatDaily, ""),
weekly("称一次体重", "幼猫每周都在长,体重曲线比单次数值有用", "1", "weight"),
weekly("检查耳朵和眼睛", "幼猫耳螨和眼部分泌物高发", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,三针核心疫苗要在六月龄前打完", 2, "vaccine"),
once("首次体内驱虫", "幼猫驱虫比成猫频繁,按体重给药", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼猫期每 3-4 周一针,共三针"),
remind("deworm", "体内外驱虫", 30, "幼猫每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesCat, Y, "青年猫标准照护", "绝育窗口、换成猫粮,开始把体重控住", []model.CareTemplateItem{
plan("喂食 2 次", "六月龄后可以改成早晚两顿", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次", model.RepeatDaily, "litterbox"),
plan("陪玩 15 分钟", "性成熟后精力旺盛,玩够了才不拆家", model.RepeatDaily, ""),
weekly("称一次体重", "绝育前后代谢会变,体重要盯紧", "1", "weight"),
weekly("梳毛", "换毛期减少毛球", "4", "brush"),
once("咨询绝育时间", "多数猫 6-8 月龄可做,术前要空腹和体检", 3, "vet"),
once("换成猫粮", "一岁前后用 7 天时间按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesCat, A, "成年猫标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("清理猫砂盆", "每天铲一次,便便异常是最早的信号", model.RepeatDaily, "litterbox"),
weekly("梳毛", "长毛猫每天,短毛猫每周两次", "2,5", "brush"),
weekly("刷牙", "牙结石是成年猫最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("洗猫砂盆", "整盆倒空清洗一次", 15, "litterbox"),
once("安排年度体检", "成年期一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesCat, M, "中年猫标准照护", "七岁起代谢变慢,年度血检开始能查出早期问题", []model.CareTemplateItem{
plan("喂食和换水", "中年后容易发胖,按体重控制热量", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿量和尿块大小要留意,肾病最早从这里看出来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "自我梳理会减少,需要帮忙", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "不明原因掉体重是中年猫要警惕的信号", 1, "weight"),
monthly("检查口腔", "看牙龈颜色和有没有结石", 15, ""),
once("安排年度体检", "血检 + 尿检,重点看肾功能和甲状腺", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesCat, S, "老年猫标准照护", "体检半年一次,肾和甲状腺是重点", []model.CareTemplateItem{
important(plan("记录饮水量", "多饮多尿是慢性肾病最典型的早期表现", model.RepeatDaily, "water")),
plan("喂食", "老年猫可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("清理猫砂盆", "尿块变大或变多要立刻记下来", model.RepeatDaily, "litterbox"),
weekly("梳毛", "老年猫自己舔不到的地方要帮它梳", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯,不是按月", "1", "weight"),
monthly("检查关节活动", "跳不上原来能跳的地方就是信号", 1, ""),
once("安排半年体检", "血检 + 尿检 + 甲状腺,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
// ───────────────── 狗 ─────────────────
{model.SpeciesDog, J, "幼犬标准照护", "疫苗序列、社会化,大型犬还要控制生长速度", []model.CareTemplateItem{
important(plan("喂食 3-4 次", "幼犬一天分 3-4 顿;大型犬别过量补钙,长太快伤关节", model.RepeatDaily, "food")),
plan("外出排便和散步", "疫苗打齐前避免去流浪动物多的地方", model.RepeatDaily, ""),
plan("基础训练 10 分钟", "坐、等待、唤回。幼犬期是社会化窗口,错过很难补", model.RepeatDaily, ""),
weekly("称一次体重", "幼犬长得快,体重曲线也是喂食量的依据", "1", "weight"),
weekly("检查爪垫和耳朵", "散步后看有没有扎伤,垂耳犬要重点看耳道", "4", "ear"),
once("核对疫苗进度", "对照月龄看第几针,狂犬疫苗按当地规定单独安排", 2, "vaccine"),
once("首次体内驱虫", "按体重给药,幼犬比成犬频繁", 5, "deworm"),
remind("vaccine", "下一针疫苗", 21, "幼犬期每 3-4 周一针"),
remind("deworm", "体内外驱虫", 30, "幼犬每月一次"),
remind("weight", "记录体重", 7, "每周一次"),
}},
{model.SpeciesDog, Y, "青年犬标准照护", "骨骼刚闭合,可以加运动量了;绝育窗口也在这里", []model.CareTemplateItem{
plan("喂食 2 次", "改成早晚两顿", model.RepeatDaily, "food"),
plan("散步 30 分钟以上", "骨骼闭合后可以正常运动,之前不宜长距离跑跳", model.RepeatDaily, ""),
plan("训练 10 分钟", "把幼犬期学的固化下来", model.RepeatDaily, ""),
weekly("称一次体重", "换成犬粮期间容易长胖", "1", "weight"),
weekly("梳毛", "换毛期每天,平时每周两次", "2,5", "brush"),
once("咨询绝育时间", "大型犬建议等骨骼闭合后再做", 3, "vet"),
once("换成犬粮", "用 7 天按比例过渡,别一次换完", 14, "food"),
remind("vaccine", "年度加强疫苗", 60, "一年一次"),
remind("deworm", "体内外驱虫", 60, "每 2-3 个月一次"),
}},
{model.SpeciesDog, A, "成年犬标准照护", "维持期,重点是把基线数据记下来", []model.CareTemplateItem{
plan("喂食和换水", "定时定量,水每天换", model.RepeatDaily, "food"),
plan("散步 30-60 分钟", "运动量不够是成年犬发胖和行为问题的主因", model.RepeatDaily, ""),
weekly("梳毛", "顺手摸一遍有没有肿块", "2,5", "brush"),
weekly("刷牙", "牙结石是成年犬最常被忽略的问题", "6", "tooth"),
monthly("称一次体重", "成年后体重该是平的,涨跌超过 5% 要留意", 1, "weight"),
monthly("剪指甲", "听到走路哒哒响就该剪了", 15, "nail"),
once("安排年度体检", "一年一次,做基础血检建立基线", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
{model.SpeciesDog, M, "中年犬标准照护", "体重和关节是重点,年度血检开始有意义", []model.CareTemplateItem{
plan("喂食和换水", "代谢变慢,按体重下调热量", model.RepeatDaily, "food"),
plan("散步 30 分钟", "保持规律但降低强度,避免急停急转", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "中年后肿块发生率上升,每周摸一遍是最便宜的筛查", "2,5", "brush"),
weekly("刷牙", "牙结石到这个年龄已经开始影响进食", "6", "tooth"),
monthly("称一次体重", "中年犬超重会直接加重关节负担", 1, "weight"),
monthly("检查关节活动", "起身变慢、不愿上楼就是信号", 15, ""),
once("安排年度体检", "血检 + 关节检查,大型犬加拍髋关节", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("weight", "记录体重", 30, "每月一次"),
}},
{model.SpeciesDog, S, "老年犬标准照护", "体检半年一次,关节和心脏是重点", []model.CareTemplateItem{
important(plan("记录精神和食欲", "老年犬变化来得快,每天一句话记下来", model.RepeatDaily, "")),
plan("喂食", "老年犬可能需要处方粮,少量多次更好消化", model.RepeatDaily, "food"),
plan("散步 15-20 分钟", "短而多次,比一次走久对关节友好", model.RepeatDaily, ""),
weekly("梳毛并摸一遍身体", "重点摸有没有新长的肿块", "2,5", "brush"),
weekly("称一次体重", "老年期体重变化要按周盯", "1", "weight"),
monthly("检查关节和牙齿", "起身、上楼、咀嚼有没有变吃力", 1, ""),
once("安排半年体检", "血检 + 心脏听诊 + 关节,老年期半年一次", 7, "checkup"),
remind("vaccine", "年度加强疫苗", 90, "一年一次(可与医生商量减免)"),
remind("deworm", "体内外驱虫", 90, "每 3 个月一次"),
remind("monthlyReport", "看看这个月的记录", 30, "每月一次"),
}},
}
}
// seedCareTemplates 只在方案表为空时铺一次
func seedCareTemplates(db *gorm.DB) error {
var count int64
if err := db.Model(&model.CareTemplate{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
return insertCareTemplates(db)
}
func insertCareTemplates(db *gorm.DB) error {
for i, d := range careTemplateDefs() {
tpl := model.CareTemplate{
Species: d.Species, Stage: d.Stage, Name: d.Name,
Summary: d.Summary, Sort: (i + 1) * 10, Enabled: true,
}
if err := db.Create(&tpl).Error; err != nil {
return err
}
items := make([]model.CareTemplateItem, 0, len(d.Items))
for j, it := range d.Items {
it.TemplateID = tpl.ID
it.Sort = j
items = append(items, it)
}
if len(items) > 0 {
if err := db.Create(&items).Error; err != nil {
return err
}
}
}
return nil
}
+16 -21
View File
@@ -4,49 +4,44 @@ import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/gorm/schema"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
)
// New 建立 GORM 连接,所有表名统一加 sundynix_ 前缀
func New(cfg *config.Config) (*gorm.DB, error) {
func Open(cfg config.PostgresConfig, release bool) (*gorm.DB, error) {
logLevel := logger.Info
if cfg.Server.Mode == "release" {
if release {
logLevel = logger.Warn
}
db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: "sundynix_",
SingularTable: false,
},
// 关联在 Go 层管,不靠库级外键。迁移期建外键时 MySQL 会为外键列自动
// 补一个索引,和字段上的 `index` tag 撞成同名索引,父子表同迁时第二次
// 建索引报 1061 duplicate。关掉外键约束创建,索引就只由 index tag 建一次
DisableForeignKeyConstraintWhenMigrating: true,
Logger: logger.Default.LogMode(logLevel),
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "sundynix_", SingularTable: true},
Logger: logger.Default.LogMode(logLevel),
})
if err != nil {
return nil, fmt.Errorf("gorm open: %w", err)
return nil, fmt.Errorf("open postgres: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(time.Hour)
return db, nil
}
// Migrate 自动建表
func Migrate(db *gorm.DB) error {
return db.AutoMigrate(model.AllModels()...)
if err := db.AutoMigrate(model.All()...); err != nil {
return err
}
if _, err := breeds.Seed(db); err != nil {
return err
}
return db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_sundynix_walk_one_active_per_user ON sundynix_walk (user_id) WHERE status IN ('active', 'paused')").Error
}
-224
View File
@@ -1,224 +0,0 @@
package database
import (
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/model"
)
// Seed 初始化种子数据:管理员账号 + 记录类型。
//
// 文章、养护模板这类**内容**数据不放代码里——正文几百字塞进 Go 源文件,既看不清
// 也没法在后台改完再同步回来。上线时直接同步数据库。
//
// 记录类型是另一码事,它是**结构**:表空着的话记录页一个可记事项都没有,
// App 直接不能用。所以和管理员账号一样,只在表完全为空时铺一次底,
// 之后一切以数据库为准,代码里这份不再参与。
func Seed(db *gorm.DB, cfg *config.Config) error {
if err := seedAdmin(db, cfg); err != nil {
return err
}
if err := seedRecordTypes(db); err != nil {
return err
}
if err := seedBreeds(db); err != nil {
return err
}
return seedCareTemplates(db)
}
// seedBreeds 品种预置。同 record_types:只在表完全为空时铺一次。
//
// 这也是「结构」不是「内容」——品种列表空着,建档第二步就选不了品种。
// 首字母存字段不运行时算:Go 没有标准拼音库,而多音字(藏獒、柴犬)
// 自动转常出错,人来定比库来猜靠谱。
func seedBreeds(db *gorm.DB) error {
var count int64
if err := db.Model(&model.Breed{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
breeds := []model.Breed{
// 猫 34 种
{Species: model.SpeciesCat, Initial: "A", Name: "阿比西尼亚猫", Sort: 10},
{Species: model.SpeciesCat, Initial: "A", Name: "奥西猫", Sort: 20},
{Species: model.SpeciesCat, Initial: "B", Name: "巴厘猫", Sort: 30},
{Species: model.SpeciesCat, Initial: "B", Name: "波斯猫", Sort: 40},
{Species: model.SpeciesCat, Initial: "B", Name: "布偶猫", Sort: 50},
{Species: model.SpeciesCat, Initial: "B", Name: "伯曼猫", Sort: 60},
{Species: model.SpeciesCat, Initial: "C", Name: "长毛折耳猫", Sort: 70},
{Species: model.SpeciesCat, Initial: "D", Name: "德文卷毛猫", Sort: 80},
{Species: model.SpeciesCat, Initial: "D", Name: "东方短毛猫", Sort: 90},
{Species: model.SpeciesCat, Initial: "D", Name: "东奇尼猫", Sort: 100},
{Species: model.SpeciesCat, Initial: "E", Name: "俄罗斯蓝猫", Sort: 110},
{Species: model.SpeciesCat, Initial: "H", Name: "哈瓦那棕猫", Sort: 120},
{Species: model.SpeciesCat, Initial: "K", Name: "科拉特猫", Sort: 130},
{Species: model.SpeciesCat, Initial: "L", Name: "拉波猫", Sort: 140},
{Species: model.SpeciesCat, Initial: "M", Name: "缅因猫", Sort: 150},
{Species: model.SpeciesCat, Initial: "M", Name: "美国短毛猫", Sort: 160},
{Species: model.SpeciesCat, Initial: "M", Name: "美国卷耳猫", Sort: 170},
{Species: model.SpeciesCat, Initial: "M", Name: "曼赤肯猫", Sort: 180},
{Species: model.SpeciesCat, Initial: "M", Name: "孟买猫", Sort: 190},
{Species: model.SpeciesCat, Initial: "M", Name: "孟加拉豹猫", Sort: 200},
{Species: model.SpeciesCat, Initial: "N", Name: "挪威森林猫", Sort: 210},
{Species: model.SpeciesCat, Initial: "R", Name: "日本短尾猫", Sort: 220},
{Species: model.SpeciesCat, Initial: "S", Name: "苏格兰折耳猫", Sort: 230},
{Species: model.SpeciesCat, Initial: "S", Name: "索马里猫", Sort: 240},
{Species: model.SpeciesCat, Initial: "S", Name: "塞尔凯克卷毛猫", Sort: 250},
{Species: model.SpeciesCat, Initial: "S", Name: "斯芬克斯无毛猫", Sort: 260},
{Species: model.SpeciesCat, Initial: "S", Name: "沙特尔猫", Sort: 270},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其安哥拉猫", Sort: 280},
{Species: model.SpeciesCat, Initial: "T", Name: "土耳其梵猫", Sort: 290},
{Species: model.SpeciesCat, Initial: "X", Name: "暹罗猫", Sort: 300},
{Species: model.SpeciesCat, Initial: "X", Name: "新加坡猫", Sort: 310},
{Species: model.SpeciesCat, Initial: "Y", Name: "异国短毛猫", Sort: 320},
{Species: model.SpeciesCat, Initial: "Y", Name: "英国短毛猫", Sort: 330},
{Species: model.SpeciesCat, Initial: "Z", Name: "中华田园猫", Sort: 340},
// 狗 46 种
{Species: model.SpeciesDog, Initial: "A", Name: "阿拉斯加雪橇犬", SizeClass: "large", Sort: 10},
{Species: model.SpeciesDog, Initial: "A", Name: "阿富汗猎犬", SizeClass: "large", Sort: 20},
{Species: model.SpeciesDog, Initial: "A", Name: "澳洲牧羊犬", SizeClass: "medium", Sort: 30},
{Species: model.SpeciesDog, Initial: "B", Name: "比熊犬", SizeClass: "small", Sort: 40},
{Species: model.SpeciesDog, Initial: "B", Name: "比格犬", SizeClass: "medium", Sort: 50},
{Species: model.SpeciesDog, Initial: "B", Name: "边境牧羊犬", SizeClass: "medium", Sort: 60},
{Species: model.SpeciesDog, Initial: "B", Name: "巴哥犬", SizeClass: "small", Sort: 70},
{Species: model.SpeciesDog, Initial: "B", Name: "博美犬", SizeClass: "small", Sort: 80},
{Species: model.SpeciesDog, Initial: "B", Name: "巴吉度猎犬", SizeClass: "medium", Sort: 90},
{Species: model.SpeciesDog, Initial: "B", Name: "北京犬", SizeClass: "small", Sort: 100},
{Species: model.SpeciesDog, Initial: "C", Name: "柴犬", SizeClass: "medium", Sort: 110},
{Species: model.SpeciesDog, Initial: "C", Name: "川东猎犬", SizeClass: "medium", Sort: 120},
{Species: model.SpeciesDog, Initial: "D", Name: "德国牧羊犬", SizeClass: "large", Sort: 130},
{Species: model.SpeciesDog, Initial: "D", Name: "杜宾犬", SizeClass: "large", Sort: 140},
{Species: model.SpeciesDog, Initial: "D", Name: "大丹犬", SizeClass: "giant", Sort: 150},
{Species: model.SpeciesDog, Initial: "D", Name: "斗牛犬", SizeClass: "medium", Sort: 160},
{Species: model.SpeciesDog, Initial: "F", Name: "法国斗牛犬", SizeClass: "medium", Sort: 170},
{Species: model.SpeciesDog, Initial: "G", Name: "贵宾犬(泰迪)", SizeClass: "small", Sort: 180},
{Species: model.SpeciesDog, Initial: "G", Name: "古代牧羊犬", SizeClass: "medium", Sort: 190},
{Species: model.SpeciesDog, Initial: "H", Name: "哈士奇", SizeClass: "large", Sort: 200},
{Species: model.SpeciesDog, Initial: "H", Name: "蝴蝶犬", SizeClass: "small", Sort: 210},
{Species: model.SpeciesDog, Initial: "J", Name: "金毛寻回犬", SizeClass: "large", Sort: 220},
{Species: model.SpeciesDog, Initial: "J", Name: "吉娃娃", SizeClass: "small", Sort: 230},
{Species: model.SpeciesDog, Initial: "K", Name: "柯基犬", SizeClass: "small", Sort: 240},
{Species: model.SpeciesDog, Initial: "K", Name: "可卡犬", SizeClass: "medium", Sort: 250},
{Species: model.SpeciesDog, Initial: "K", Name: "苏格兰柯利犬", SizeClass: "medium", Sort: 260},
{Species: model.SpeciesDog, Initial: "L", Name: "拉布拉多寻回犬", SizeClass: "large", Sort: 270},
{Species: model.SpeciesDog, Initial: "L", Name: "罗威纳犬", SizeClass: "large", Sort: 280},
{Species: model.SpeciesDog, Initial: "L", Name: "灵缇犬", SizeClass: "large", Sort: 290},
{Species: model.SpeciesDog, Initial: "L", Name: "腊肠犬", SizeClass: "small", Sort: 300},
{Species: model.SpeciesDog, Initial: "M", Name: "马尔济斯犬", SizeClass: "small", Sort: 310},
{Species: model.SpeciesDog, Initial: "M", Name: "迷你雪纳瑞", SizeClass: "small", Sort: 320},
{Species: model.SpeciesDog, Initial: "N", Name: "牛头梗", SizeClass: "medium", Sort: 330},
{Species: model.SpeciesDog, Initial: "Q", Name: "秋田犬", SizeClass: "large", Sort: 340},
{Species: model.SpeciesDog, Initial: "S", Name: "萨摩耶", SizeClass: "large", Sort: 350},
{Species: model.SpeciesDog, Initial: "S", Name: "松狮犬", SizeClass: "medium", Sort: 360},
{Species: model.SpeciesDog, Initial: "S", Name: "圣伯纳犬", SizeClass: "giant", Sort: 370},
{Species: model.SpeciesDog, Initial: "X", Name: "雪纳瑞", SizeClass: "medium", Sort: 390},
{Species: model.SpeciesDog, Initial: "X", Name: "西高地白梗", SizeClass: "small", Sort: 400},
{Species: model.SpeciesDog, Initial: "X", Name: "喜乐蒂牧羊犬", SizeClass: "small", Sort: 410},
{Species: model.SpeciesDog, Initial: "Y", Name: "约克夏梗", SizeClass: "small", Sort: 420},
{Species: model.SpeciesDog, Initial: "Y", Name: "银狐犬", SizeClass: "medium", Sort: 430},
{Species: model.SpeciesDog, Initial: "Z", Name: "中华田园犬", SizeClass: "medium", Sort: 440},
{Species: model.SpeciesDog, Initial: "Z", Name: "中亚牧羊犬", SizeClass: "large", Sort: 450},
{Species: model.SpeciesDog, Initial: "Z", Name: "藏獒", SizeClass: "giant", Sort: 460},
}
for i := range breeds {
breeds[i].Enabled = true
}
return db.Create(&breeds).Error
}
// seedRecordTypes 只在表为空时跑。已经有数据(哪怕只有一条)就完全不碰,
// 否则后台删掉的类型会在每次重启后自己长回来
func seedRecordTypes(db *gorm.DB) error {
var count int64
if err := db.Model(&model.RecordType{}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
// locked=true 的这 9 种在 record.go / insight.go / report.go / home.go 里有真实
// 代码分支(体重回写档案、食便相关性、消费统计、疫苗完成度…),并且被
// care_template_items.sheet_type 引用,后台不许删,只能停用。
// form=full 表示 bottom-sheet 里有它专用的 wx:elif 分支。
// fields 是表单额外字段的配置,一行一个(写法见 model.RecordType.Fields 的注释)。
// 空 = 只要「时间 + 描述 + 照片」。
//
// 原有 9 种的配置必须和现在的统计口径严丝合缝:
// weight number → num_value,CreateRecord 靠它回写宠物档案
// cost 金额→num_value + 类别→category,账单是按 category 分组聚合的
// poop 状态→category,食便相关性分析排除 category='正常'
// symptom 两个单选:症状不占 category,严重程度占——周报按 category='高' 数高风险
mk := func(code, label, icon, group string, sort int, locked bool, fields string) model.RecordType {
form := model.RecordFormSimple
if locked {
form = model.RecordFormFull
}
return model.RecordType{Code: code, Label: label, Icon: icon, GroupKey: group,
Sort: sort, Enabled: true, Form: form, Locked: locked, Fields: fields}
}
full := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, true, fields)
}
simple := func(code, label, icon, group string, sort int, fields string) model.RecordType {
return mk(code, label, icon, group, sort, false, fields)
}
d, h, c, cl := model.RecordGroupDaily, model.RecordGroupHealth, model.RecordGroupCare, model.RecordGroupClean
types := []model.RecordType{
// 日常
full("weight", "体重", "weight", d, 10, "number:体重:kg"),
full("poop", "排便", "poop", d, 20, "options:状态:正常|软便|拉稀"),
full("food", "饮食", "food", d, 30, "text:吃了什么\noptions:食欲:正常|偏少|不吃"),
simple("water", "喝水", "water", d, 40, "number:饮水量:ml"),
full("cost", "记账", "cost", d, 50, "number:金额:元\noptions:类别:医疗|食品|用品|美容|其他"),
full("photo", "照片", "photo", d, 60, ""),
// 健康
full("symptom", "异常", "symptom", h, 10,
"options:发生了什么:呕吐|拉稀|精神差|皮肤红|其他:-\noptions:严重程度:低|中|高"),
full("vaccine", "疫苗", "vaccine", h, 20, "text:疫苗名称"),
full("deworm", "驱虫", "deworm", h, 30, "options:类型:体内|体外|体内外同驱"),
simple("checkup", "体检", "checkup", h, 40, "text:在哪做的"),
simple("vet", "看病", "vet", h, 50, "text:诊断\nnumber:花费:元"),
full("medicine", "给药", "medicine", h, 60, "text:药品名称\ntext:剂量"),
simple("supplement", "保健品", "supplement", h, 70, "text:名称\ntext:剂量"),
// 洗护
simple("bath", "洗澡", "bath", c, 10, ""),
simple("nail", "剪指甲", "nail", c, 20, ""),
simple("ear", "洗耳朵", "ear", c, 30, ""),
simple("tooth", "刷牙", "tooth", c, 40, ""),
simple("brush", "梳毛", "brush", c, 50, ""),
simple("groom", "美容", "groom", c, 60, "number:花费:元"),
// 清洁
simple("litter", "换猫砂", "litter", cl, 10, ""),
simple("litterbox", "洗猫砂盆", "litterbox", cl, 20, ""),
simple("bowl", "洗食盆", "bowl", cl, 30, ""),
simple("waterbowl", "洗水盆", "waterbowl", cl, 40, ""),
simple("clean", "消毒", "clean", cl, 50, ""),
}
return db.Create(&types).Error
}
func seedAdmin(db *gorm.DB, cfg *config.Config) error {
var count int64
if err := db.Model(&model.Admin{}).Where("username = ?", cfg.Admin.Username).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return nil
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Admin.Password), bcrypt.DefaultCost)
if err != nil {
return err
}
return db.Create(&model.Admin{
Username: cfg.Admin.Username,
PasswordHash: string(hash),
Role: "admin",
}).Error
}
+146
View File
@@ -0,0 +1,146 @@
package environment
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
var ErrNotConfigured = errors.New("位置和天气服务暂未配置")
var ErrCoordinates = errors.New("定位坐标无效")
type Weather struct {
Description string `json:"description"`
Temperature string `json:"temperature"`
ReportedAt string `json:"reported_at"`
}
type Result struct {
LocationName string `json:"location_name"`
City string `json:"city"`
District string `json:"district"`
Weather *Weather `json:"weather"`
}
type cached struct {
result Result
expires time.Time
}
type Client struct {
key string
baseURL string
http *http.Client
mu sync.Mutex
cache map[string]cached
}
func New(key string) *Client {
return &Client{key: key, baseURL: "https://restapi.amap.com", http: &http.Client{Timeout: 5 * time.Second}, cache: make(map[string]cached)}
}
// AMap returns [] instead of an empty string for some address components.
func addressText(raw json.RawMessage) string {
var text string
_ = json.Unmarshal(raw, &text)
return text
}
func (c *Client) get(ctx context.Context, path string, params url.Values, target any) error {
params.Set("key", c.key)
params.Set("output", "JSON")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path+"?"+params.Encode(), nil)
if err != nil {
return errors.New("位置服务请求失败")
}
res, err := c.http.Do(req)
// Never return the transport error: it may contain the URL and API key.
if err != nil {
return errors.New("位置服务暂时不可用")
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return errors.New("位置服务暂时不可用")
}
if err := json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target); err != nil {
return errors.New("位置服务响应无效")
}
return nil
}
func (c *Client) Lookup(ctx context.Context, latitude, longitude float64) (Result, error) {
if math.IsNaN(latitude) || math.IsNaN(longitude) || math.IsInf(latitude, 0) || math.IsInf(longitude, 0) || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180 {
return Result{}, ErrCoordinates
}
if c.key == "" {
return Result{}, ErrNotConfigured
}
cacheKey := fmt.Sprintf("%.3f,%.3f", longitude, latitude)
c.mu.Lock()
hit, found := c.cache[cacheKey]
c.mu.Unlock()
if found && time.Now().Before(hit.expires) {
return hit.result, nil
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var geo struct {
Status string `json:"status"`
Regeocode struct {
Address struct {
Province json.RawMessage `json:"province"`
City json.RawMessage `json:"city"`
District json.RawMessage `json:"district"`
Adcode string `json:"adcode"`
} `json:"addressComponent"`
} `json:"regeocode"`
}
err := c.get(ctx, "/v3/geocode/regeo", url.Values{"location": {fmt.Sprintf("%.6f,%.6f", longitude, latitude)}, "extensions": {"base"}}, &geo)
if err != nil {
return Result{}, err
}
if geo.Status != "1" {
return Result{}, errors.New("所在城市获取失败,请稍后重试")
}
address := geo.Regeocode.Address
city := addressText(address.City)
if city == "" {
city = addressText(address.Province)
}
district := addressText(address.District)
if city == "" || address.Adcode == "" {
return Result{}, errors.New("当前位置暂不支持天气查询")
}
result := Result{City: city, District: district, LocationName: city}
if district != "" && district != city {
result.LocationName += " · " + district
}
var weather struct {
Status string `json:"status"`
Lives []struct {
Description string `json:"weather"`
Temperature string `json:"temperature"`
ReportedAt string `json:"reporttime"`
} `json:"lives"`
}
err = c.get(ctx, "/v3/weather/weatherInfo", url.Values{"city": {address.Adcode}, "extensions": {"base"}}, &weather)
if err == nil && weather.Status == "1" && len(weather.Lives) > 0 {
live := weather.Lives[0]
if strings.TrimSpace(live.Description) != "" && live.Temperature != "" {
result.Weather = &Weather{live.Description, live.Temperature, live.ReportedAt}
}
}
// Keep the city even if the weather provider fails; retry missing weather next time.
if result.Weather != nil {
c.mu.Lock()
if len(c.cache) >= 256 {
clear(c.cache)
}
c.cache[cacheKey] = cached{result, time.Now().Add(15 * time.Minute)}
c.mu.Unlock()
}
return result, nil
}
+86
View File
@@ -0,0 +1,86 @@
package environment
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestLookupMunicipalityAndCache(t *testing.T) {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if r.URL.Query().Get("key") != "test-key" {
t.Error("key missing")
}
switch r.URL.Path {
case "/v3/geocode/regeo":
if r.URL.Query().Get("location") != "121.473700,31.230400" {
t.Errorf("wrong coordinate order: %s", r.URL.Query().Get("location"))
}
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"上海市","city":[],"district":"黄浦区","adcode":"310101"}}}`)
case "/v3/weather/weatherInfo":
if r.URL.Query().Get("city") != "310101" {
t.Error("weather must use adcode")
}
fmt.Fprint(w, `{"status":"1","lives":[{"weather":"晴","temperature":"24","reporttime":"2026-10-04 10:00:00"}]}`)
default:
t.Error("unexpected path")
}
}))
defer srv.Close()
client := New("test-key")
client.baseURL = srv.URL
for range 2 {
result, err := client.Lookup(context.Background(), 31.2304, 121.4737)
if err != nil {
t.Fatal(err)
}
if result.LocationName != "上海市 · 黄浦区" || result.Weather == nil || result.Weather.Temperature != "24" {
t.Fatalf("unexpected result: %+v", result)
}
}
if calls.Load() != 2 {
t.Errorf("cache did not reduce calls: %d", calls.Load())
}
}
func TestPartialWeatherFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v3/geocode/regeo" {
fmt.Fprint(w, `{"status":"1","regeocode":{"addressComponent":{"province":"浙江省","city":"杭州市","district":"西湖区","adcode":"330106"}}}`)
} else {
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
}
}))
defer srv.Close()
client := New("test-key")
client.baseURL = srv.URL
result, err := client.Lookup(context.Background(), 30.2, 120.1)
if err != nil || result.LocationName != "杭州市 · 西湖区" || result.Weather != nil {
t.Fatalf("must retain city without fabricated weather: %+v %v", result, err)
}
}
func TestInvalidInputsAndProviderError(t *testing.T) {
client := New("")
if _, err := client.Lookup(context.Background(), 30, 120); err != ErrNotConfigured {
t.Fatal(err)
}
for _, latitude := range []float64{91, math.NaN(), math.Inf(1)} {
if _, err := client.Lookup(context.Background(), latitude, 120); err != ErrCoordinates {
t.Fatal(err)
}
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `{"status":"0","info":"INVALID_USER_KEY"}`)
}))
defer srv.Close()
client = New("secret-key")
client.baseURL = srv.URL
if _, err := client.Lookup(context.Background(), 30, 120); err == nil {
t.Fatal("provider rejection accepted")
}
}
-558
View File
@@ -1,558 +0,0 @@
package handler
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
type adminLoginReq struct {
Username string `json:"username"`
Password string `json:"password"`
}
// AdminLogin POST /api/admin/login
func (h *Handler) AdminLogin(c *gin.Context) {
var req adminLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
admin, err := h.svc.AdminLogin(req.Username, req.Password)
if err != nil {
response.Fail(c, 40100, "账号或密码错误")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// adminTokens 签发后台的「access + refresh」令牌对。access token 2 小时。
func (h *Handler) adminTokens(admin *model.Admin) (gin.H, error) {
token, err := h.jwt.Generate(admin.ID, appjwt.KindAdmin, admin.Username, admin.Role)
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindAdmin), admin.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"admin": admin,
}, nil
}
// AdminRefresh POST /api/admin/refresh
func (h *Handler) AdminRefresh(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
adminID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
admin, err := h.svc.GetAdmin(adminID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if admin.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.adminTokens(admin)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// AdminLogout POST /api/admin/logout 作废当前刷新令牌
func (h *Handler) AdminLogout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindAdmin))
response.OK(c, gin.H{"ok": true})
}
// AdminMe GET /api/admin/me
func (h *Handler) AdminMe(c *gin.Context) {
admin, err := h.svc.GetAdmin(middleware.AdminID(c))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, admin)
}
// AdminStats GET /api/admin/stats
func (h *Handler) AdminStats(c *gin.Context) {
stats, err := h.svc.Stats()
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, stats)
}
// AdminAnalytics GET /api/admin/analytics?days=30 看板的活跃/新增/留存
func (h *Handler) AdminAnalytics(c *gin.Context) {
days, _ := strconv.Atoi(c.Query("days"))
data, err := h.svc.AdminAnalytics(days)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type adminListReq struct {
response.PageQuery
Keyword string `form:"keyword"`
Status string `form:"status"`
Type string `form:"type"`
}
// AdminListUsers GET /api/admin/users
func (h *Handler) AdminListUsers(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
users, total, err := h.svc.ListUsers(req.Keyword, req.Type, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(users, total, req.PageQuery))
}
type disableReq struct {
Disabled bool `json:"disabled"`
}
// AdminSetUserDisabled PUT /api/admin/users/:id/disabled
func (h *Handler) AdminSetUserDisabled(c *gin.Context) {
var req disableReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetUserDisabled(idParam(c, "id"), req.Disabled); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPets GET /api/admin/pets
func (h *Handler) AdminListPets(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
pets, total, err := h.svc.ListPetsAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(pets, total, req.PageQuery))
}
// AdminListPosts GET /api/admin/posts
func (h *Handler) AdminListPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPostsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
type postStatusReq struct {
Status string `json:"status"`
}
// AdminSetPostStatus PUT /api/admin/posts/:id/status
func (h *Handler) AdminSetPostStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetPostStatus(idParam(c, "id"), req.Status); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListComments GET /api/admin/comments
func (h *Handler) AdminListComments(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListCommentsAdmin(req.Status, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
// AdminSetCommentStatus PUT /api/admin/comments/:id/status
func (h *Handler) AdminSetCommentStatus(c *gin.Context) {
var req postStatusReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetCommentStatus(idParam(c, "id"), req.Status); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminDeleteComment DELETE /api/admin/comments/:id
func (h *Handler) AdminDeleteComment(c *gin.Context) {
if err := h.svc.DeleteCommentAdmin(idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListArticles GET /api/admin/articles
func (h *Handler) AdminListArticles(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
articles, total, err := h.svc.ListArticlesAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(articles, total, req.PageQuery))
}
type articleReq struct {
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
Content string `json:"content"`
Category string `json:"category"`
RelatedSheetType string `json:"related_sheet_type"`
Published bool `json:"published"`
}
// AdminSaveArticle POST /api/admin/articles(id>0 为更新)
func (h *Handler) AdminSaveArticle(c *gin.Context) {
var req articleReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
article := &model.Article{
Icon: req.Icon, Title: req.Title, Description: req.Description, Content: req.Content,
Category: req.Category, RelatedSheetType: req.RelatedSheetType, Published: req.Published,
}
article.ID = req.ID
if err := h.svc.SaveArticle(article); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, article)
}
// AdminDeleteArticle DELETE /api/admin/articles/:id
func (h *Handler) AdminDeleteArticle(c *gin.Context) {
if err := h.svc.DeleteArticle(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// AdminListPro GET /api/admin/memberships
func (h *Handler) AdminListPro(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListProAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
// AdminCareTemplateMeta GET /api/admin/care-templates/meta
// 给后台下拉用:物种、阶段、可选关联记录/弹层类型、提醒类型
// ── 品种 ──
type breedReq struct {
ID string `json:"id"`
Species string `json:"species"`
Name string `json:"name"`
Initial string `json:"initial"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
}
// AdminListBreeds GET /api/admin/breeds?species=cat 连停用的一起返回
func (h *Handler) AdminListBreeds(c *gin.Context) {
response.OK(c, h.svc.AdminListBreeds(c.Query("species")))
}
// AdminSaveBreed POST /api/admin/breeds
func (h *Handler) AdminSaveBreed(c *gin.Context) {
var req breedReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
b := &model.Breed{Species: req.Species, Name: req.Name,
Initial: req.Initial, Sort: req.Sort, Enabled: req.Enabled}
b.ID = req.ID
if err := h.svc.SaveBreed(b); err != nil {
respondErr(c, err)
return
}
response.OK(c, b)
}
// AdminDeleteBreed DELETE /api/admin/breeds/:id
func (h *Handler) AdminDeleteBreed(c *gin.Context) {
if err := h.svc.DeleteBreed(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// ── 记录类型 ──
type recordTypeReq struct {
ID string `json:"id"`
Code string `json:"code"`
Label string `json:"label"`
Icon string `json:"icon"`
Group string `json:"group"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Form string `json:"form"`
Fields string `json:"fields_raw"`
}
// AdminListRecordTypes GET /api/admin/record-types 连停用的一起返回
func (h *Handler) AdminListRecordTypes(c *gin.Context) {
response.OK(c, h.svc.AdminListRecordTypes())
}
// AdminSaveRecordType POST /api/admin/record-types 新增或更新
func (h *Handler) AdminSaveRecordType(c *gin.Context) {
var req recordTypeReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
t := &model.RecordType{
Code: req.Code, Label: req.Label, Icon: req.Icon,
GroupKey: req.Group, Sort: req.Sort, Enabled: req.Enabled, Form: req.Form,
Fields: req.Fields,
}
t.ID = req.ID
if err := h.svc.SaveRecordType(t); err != nil {
respondErr(c, err)
return
}
response.OK(c, t)
}
// AdminDeleteRecordType DELETE /api/admin/record-types/:id
// locked 的和已有记录的删不了,service 层硬拦
func (h *Handler) AdminDeleteRecordType(c *gin.Context) {
if err := h.svc.DeleteRecordType(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
func (h *Handler) AdminCareTemplateMeta(c *gin.Context) {
response.OK(c, gin.H{
"species": []gin.H{
{"key": model.SpeciesCat, "label": "猫猫"},
{"key": model.SpeciesDog, "label": "狗狗"},
},
"stages": service.CareStages,
// 「关联记录事项」的可选值读 record_types 表,不再写死。
// 原来是两个硬编码数组(task_sheets / plan_sheets),里头还有
// report / taskDetail 这种早就不存在的值 —— 选了等于点了没反应;
// 而后台新加的 24 种事项一个都选不到
"record_types": h.svc.AdminListRecordTypes(),
"repeats": []string{"once", "daily", "weekly", "monthly"},
"reminder_types": []string{"vaccine", "deworm", "weight", "monthlyReport"},
"priorities": []string{"", "重要"},
})
}
// AdminListCareTemplates GET /api/admin/care-templates 全部方案
func (h *Handler) AdminListCareTemplates(c *gin.Context) {
response.OK(c, h.svc.AdminListTemplates())
}
// AdminGetCareTemplate GET /api/admin/care-templates/:id 一套方案
func (h *Handler) AdminGetCareTemplate(c *gin.Context) {
tpl, err := h.svc.AdminGetTemplate(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tpl)
}
type careTemplateReq struct {
ID string `json:"id"`
Species string `json:"species"`
Stage string `json:"stage"`
Name string `json:"name"`
Summary string `json:"summary"`
Sort int `json:"sort"`
Enabled bool `json:"enabled"`
Items []model.CareTemplateItem `json:"items"`
}
// AdminSaveCareTemplate POST /api/admin/care-templates 新增或更新一套
func (h *Handler) AdminSaveCareTemplate(c *gin.Context) {
var req careTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
tpl := &model.CareTemplate{
Species: req.Species, Stage: req.Stage, Name: req.Name,
Summary: req.Summary, Sort: req.Sort, Enabled: req.Enabled,
}
tpl.ID = req.ID
if err := h.svc.SaveTemplate(tpl, req.Items); err != nil {
respondErr(c, err)
return
}
saved, _ := h.svc.AdminGetTemplate(tpl.ID)
response.OK(c, saved)
}
// AdminDeleteCareTemplate DELETE /api/admin/care-templates/:id
func (h *Handler) AdminDeleteCareTemplate(c *gin.Context) {
if err := h.svc.DeleteTemplate(idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
type careGenReq struct {
Species string `json:"species"`
Stage string `json:"stage"`
}
// AdminGenerateCareTemplate POST /api/admin/care-templates/generate
// AI 生成草稿(不落库),未开启 AI 时回退内置默认
func (h *Handler) AdminGenerateCareTemplate(c *gin.Context) {
var req careGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
items, err := h.svc.GenerateCareTemplate(req.Species, req.Stage)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, gin.H{"species": req.Species, "stage": req.Stage, "items": items})
}
// AdminGetCommunityBot GET /api/admin/community-bot
func (h *Handler) AdminGetCommunityBot(c *gin.Context) {
response.OK(c, h.svc.GetCommunityBotConfig())
}
// AdminSaveCommunityBot PUT /api/admin/community-bot
func (h *Handler) AdminSaveCommunityBot(c *gin.Context) {
var req model.CommunityBotConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
saved, err := h.svc.SaveCommunityBotConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, saved)
}
type communityGenReq struct {
Count int `json:"count"`
}
// AdminGenerateCommunityPosts POST /api/admin/community-bot/generate
func (h *Handler) AdminGenerateCommunityPosts(c *gin.Context) {
var req communityGenReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Count <= 0 {
req.Count = 1
}
if req.Count > 30 {
req.Count = 30
}
made, err := h.svc.GenerateCommunityPosts(req.Count)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"made": made})
}
// AdminGetAIQuota GET /api/admin/ai-quota
func (h *Handler) AdminGetAIQuota(c *gin.Context) {
response.OK(c, h.svc.GetAIQuotaConfig())
}
// AdminSaveAIQuota PUT /api/admin/ai-quota
func (h *Handler) AdminSaveAIQuota(c *gin.Context) {
var req model.AIQuotaConfig
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
cfg, err := h.svc.SaveAIQuotaConfig(req)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, cfg)
}
-229
View File
@@ -1,229 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
type loginReq struct {
Nickname string `json:"nickname"`
}
// userTokens 签发小程序用户的「access + refresh」令牌对。
// access token 只有 2 小时,前端过期后拿 refresh_token 静默续期。
func (h *Handler) userTokens(user *model.User) (gin.H, error) {
token, err := h.jwt.Generate(user.ID, appjwt.KindUser, user.Nickname, "")
if err != nil {
return nil, err
}
refresh, refreshExp, err := h.svc.IssueRefreshToken(string(appjwt.KindUser), user.ID)
if err != nil {
return nil, err
}
return gin.H{
"token": token,
"refresh_token": refresh,
"expires_in": h.jwt.ExpireSeconds(),
"refresh_expires_at": refreshExp,
"user": user,
}, nil
}
// Login 开发态 Mock 登录
func (h *Handler) Login(c *gin.Context) {
if !h.cfg.Auth.DevLogin {
response.Fail(c, 40300, "开发登录未开启,请使用微信登录")
return
}
var req loginReq
_ = c.ShouldBindJSON(&req)
user, err := h.svc.MockLogin(req.Nickname)
if err != nil {
response.FailErr(c, err)
return
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type wechatLoginReq struct {
Code string `json:"code"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// WechatLogin 微信小程序登录:前端 wx.login 拿 code 传入
func (h *Handler) WechatLogin(c *gin.Context) {
var req wechatLoginReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Code == "" {
response.FailParams(c, "缺少 code")
return
}
user, err := h.svc.WechatLogin(req.Code)
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
// 可选:首次登录写入用户授权的昵称/头像
if fields := map[string]any{}; true {
// 只覆盖系统生成的昵称。用户自己改过的(NicknameAuto=false)不动
if req.Nickname != "" && user.NicknameAuto {
fields["nickname"] = req.Nickname
fields["nickname_auto"] = false
}
if req.Avatar != "" && user.Avatar == "" {
fields["avatar"] = req.Avatar
}
if len(fields) > 0 {
if u, e := h.svc.UpdateUser(user.ID, fields); e == nil {
user = u
}
}
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
type refreshReq struct {
RefreshToken string `json:"refresh_token"`
}
// RefreshToken POST /api/auth/refresh
// 用刷新令牌换新的 access token;刷新令牌本身也会轮换,旧的立即失效。
func (h *Handler) RefreshToken(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
userID, err := h.svc.ConsumeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
if err != nil {
response.Fail(c, 40100, err.Error())
return
}
user, err := h.svc.GetUser(userID)
if err != nil {
response.Fail(c, 40100, "登录已失效,请重新登录")
return
}
if user.Disabled {
response.Fail(c, 40300, "账号已被禁用")
return
}
data, err := h.userTokens(user)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, data)
}
// Logout POST /api/auth/logout 作废当前刷新令牌
func (h *Handler) Logout(c *gin.Context) {
var req refreshReq
_ = c.ShouldBindJSON(&req)
h.svc.RevokeRefreshToken(req.RefreshToken, string(appjwt.KindUser))
response.OK(c, gin.H{"ok": true})
}
// Profile 当前用户资料
func (h *Handler) Profile(c *gin.Context) {
user, err := h.svc.GetUser(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, user)
}
type updateProfileReq struct {
Nickname *string `json:"nickname"`
Avatar *string `json:"avatar"`
AvatarFileID *string `json:"avatar_file_id"`
Phone *string `json:"phone"`
}
// UpdateProfile 更新资料
// decorationReq 主页装扮。全部用指针:区分「没传」和「传了空串」——
// 用户清空个性签名和不改个性签名是两件事
type decorationReq struct {
Bio *string `json:"bio"`
BgFileID *string `json:"bg_file_id"`
Theme *string `json:"theme"`
ShowPets *bool `json:"show_pets"`
}
// UpdateDecoration PUT /api/user/decoration 装扮自己的主页
func (h *Handler) UpdateDecoration(c *gin.Context) {
var req decorationReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
user, err := h.svc.UpdateDecoration(middleware.UserID(c), req.Bio, req.BgFileID, req.Theme, req.ShowPets)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, user)
}
func (h *Handler) UpdateProfile(c *gin.Context) {
var req updateProfileReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Nickname != nil {
fields["nickname"] = *req.Nickname
fields["nickname_auto"] = false // 用户自己设过就别再被微信授权覆盖
}
if req.Avatar != nil {
fields["avatar"] = *req.Avatar
}
if req.AvatarFileID != nil {
fields["avatar_file_id"] = *req.AvatarFileID
}
if req.Phone != nil {
fields["phone"] = *req.Phone
}
user, err := h.svc.UpdateUser(middleware.UserID(c), fields)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, user)
}
// BindPhone POST /api/user/phone 用小程序 getPhoneNumber 的 code 绑定手机号
func (h *Handler) BindPhone(c *gin.Context) {
var req struct {
Code string `json:"code"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Code == "" {
response.FailParams(c, "缺少 code")
return
}
phone, err := h.svc.BindPhoneByCode(middleware.UserID(c), req.Code)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"phone": phone})
}
-221
View File
@@ -1,221 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type postListReq struct {
response.PageQuery
Tab string `form:"tab"`
}
// ListPosts GET /api/posts?tab=&page=&page_size=
func (h *Handler) ListPosts(c *gin.Context) {
var req postListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListPosts(middleware.UserID(c), req.Tab, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// GetPost GET /api/posts/:id
func (h *Handler) GetPost(c *gin.Context) {
post, err := h.svc.GetPost(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, post)
}
type postReq struct {
PetID *string `json:"pet_id"`
Identity string `json:"identity"`
Content string `json:"content"`
Tags []string `json:"tags"`
Images []string `json:"images"`
ImageFileIDs []string `json:"image_file_ids"`
}
// CreatePost POST /api/posts
func (h *Handler) CreatePost(c *gin.Context) {
var req postReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.PostInput{PetID: req.PetID, Identity: req.Identity, Content: req.Content}
if b, err := jsonMarshal(req.Tags); err == nil {
in.Tags = datatypes.JSON(b)
}
if b, err := jsonMarshal(req.Images); err == nil {
in.Images = datatypes.JSON(b)
}
if len(req.ImageFileIDs) > 0 {
if b, err := jsonMarshal(req.ImageFileIDs); err == nil {
in.ImageFileIDs = datatypes.JSON(b)
}
}
post, err := h.svc.CreatePost(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, post)
}
// LikePost POST /api/posts/:id/like
func (h *Handler) LikePost(c *gin.Context) {
count, err := h.svc.LikePost(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"like_count": count})
}
// UnlikePost DELETE /api/posts/:id/like
func (h *Handler) UnlikePost(c *gin.Context) {
count, err := h.svc.UnlikePost(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"like_count": count})
}
type commentListReq struct {
response.PageQuery
}
// ListComments GET /api/posts/:id/comments
func (h *Handler) ListComments(c *gin.Context) {
var req commentListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
comments, total, err := h.svc.ListComments(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(comments, total, req.PageQuery))
}
type commentReq struct {
Content string `json:"content"`
Images []string `json:"images"`
ParentID string `json:"parent_id"` // 回复某条评论时传它的 id
}
// CreateComment POST /api/posts/:id/comments
func (h *Handler) CreateComment(c *gin.Context) {
var req commentReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Content == "" {
response.FailParams(c, "评论内容不能为空")
return
}
comment, err := h.svc.CreateComment(middleware.UserID(c), idParam(c, "id"), req.Content, req.Images, req.ParentID)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, comment)
}
// FollowUser POST /api/users/:id/follow
func (h *Handler) FollowUser(c *gin.Context) {
if err := h.svc.FollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"followed": true})
}
// UnfollowUser DELETE /api/users/:id/follow
func (h *Handler) UnfollowUser(c *gin.Context) {
if err := h.svc.UnfollowUser(middleware.UserID(c), idParam(c, "id")); err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"followed": false})
}
// DeleteOwnPost DELETE /api/posts/:id (仅限本人)
func (h *Handler) DeleteOwnPost(c *gin.Context) {
if err := h.svc.DeleteOwnPost(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// DeleteOwnComment DELETE /api/comments/:id (仅限本人)
func (h *Handler) DeleteOwnComment(c *gin.Context) {
if err := h.svc.DeleteOwnComment(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// ListReplies GET /api/comments/:id/replies 展开某条评论的全部回复
func (h *Handler) ListReplies(c *gin.Context) {
list, err := h.svc.ListReplies(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// UserCard GET /api/users/:id/profile 某人的公开资料
func (h *Handler) UserCard(c *gin.Context) {
card, err := h.svc.GetUserCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
// UserPosts GET /api/users/:id/posts 某人发布的帖子
func (h *Handler) UserPosts(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
posts, total, err := h.svc.ListUserPosts(middleware.UserID(c), idParam(c, "id"), req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(posts, total, req.PageQuery))
}
// Relations GET /api/users/:id/relations?kind=following|followers
func (h *Handler) Relations(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
kind := c.Query("kind")
list, total, err := h.svc.ListRelations(middleware.UserID(c), idParam(c, "id"), kind, req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
-18
View File
@@ -1,18 +0,0 @@
package handler
import (
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/service"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
)
// Handler 所有 HTTP 处理器聚合,方法按领域分散在各文件
type Handler struct {
svc *service.Service
jwt *appjwt.Manager
cfg *config.Config
}
func New(svc *service.Service, jm *appjwt.Manager, cfg *config.Config) *Handler {
return &Handler{svc: svc, jwt: jm, cfg: cfg}
}
-39
View File
@@ -1,39 +0,0 @@
package handler
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/errcode"
"github.com/sundynix/pets-be/pkg/response"
)
// idParam 取路径参数(字符串雪花 ID)
func idParam(c *gin.Context, key string) string {
return c.Param(key)
}
// jsonMarshal 便捷序列化
func jsonMarshal(v any) ([]byte, error) {
return json.Marshal(v)
}
// respondErr 统一错误响应:区分 not found 与内部错误
func respondErr(c *gin.Context, err error) {
if errors.Is(err, service.ErrNotFound) {
response.Fail(c, errcode.ErrNotFound, "")
return
}
if errors.Is(err, service.ErrForbidden) {
response.Fail(c, errcode.ErrForbidden, err.Error())
return
}
if errors.Is(err, service.ErrInvalidParam) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err)
}
-199
View File
@@ -1,199 +0,0 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
// ListArticles GET /api/articles
func (h *Handler) ListArticles(c *gin.Context) {
articles, err := h.svc.ListArticles()
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, articles)
}
// GetArticle GET /api/articles/:id
func (h *Handler) GetArticle(c *gin.Context) {
article, err := h.svc.GetArticle(idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, article)
}
// GetPro GET /api/pro
func (h *Handler) GetPro(c *gin.Context) {
info, err := h.svc.GetPro(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, info)
}
// ActivatePro POST /api/pro/activate
func (h *Handler) ActivatePro(c *gin.Context) {
info, err := h.svc.ActivatePro(middleware.UserID(c))
if err != nil {
// 支付未接入不是服务器错误,按参数错返回,前端直接展示提示文案
if errors.Is(err, service.ErrPayNotReady) {
response.FailParams(c, err.Error())
return
}
response.FailErr(c, err)
return
}
response.OK(c, info)
}
type aiChatReq struct {
PetID *string `json:"pet_id"`
Session string `json:"session"`
Text string `json:"text"`
}
// AIChat POST /api/ai/chat
func (h *Handler) AIChat(c *gin.Context) {
var req aiChatReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.Text == "" {
response.FailParams(c, "问题不能为空")
return
}
reply, err := h.svc.AIChat(middleware.UserID(c), req.PetID, req.Session, req.Text)
if err != nil {
if errors.Is(err, service.ErrAIQuotaExceeded) {
response.Fail(c, 42900, err.Error())
return
}
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"reply": reply})
}
// HomeSummary GET /api/pets/:id/home-summary
func (h *Handler) HomeSummary(c *gin.Context) {
res, err := h.svc.GetHomeSummary(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
// UserSummary GET /api/user/summary
func (h *Handler) UserSummary(c *gin.Context) {
res, err := h.svc.GetUserSummary(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, res)
}
type assessSymptomReq struct {
Symptoms []string `json:"symptoms"`
Duration string `json:"duration"`
Spirit string `json:"spirit"`
}
// AssessSymptom POST /api/pets/:id/ai/assess-symptom
func (h *Handler) AssessSymptom(c *gin.Context) {
var req assessSymptomReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
res, err := h.svc.AssessSymptom(middleware.UserID(c), idParam(c, "id"), service.SymptomInput{
Symptoms: req.Symptoms, Duration: req.Duration, Spirit: req.Spirit,
})
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
type feedbackReq struct {
Content string `json:"content"`
Contact string `json:"contact"`
}
// CreateFeedback POST /api/feedback
func (h *Handler) CreateFeedback(c *gin.Context) {
var req feedbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
f, err := h.svc.CreateFeedback(middleware.UserID(c), req.Content, req.Contact)
if err != nil {
response.FailParams(c, err.Error())
return
}
response.OK(c, f)
}
// AdminListFeedback GET /api/admin/feedback
func (h *Handler) AdminListFeedback(c *gin.Context) {
var req adminListReq
_ = c.ShouldBindQuery(&req)
req.Normalize()
list, total, err := h.svc.ListFeedbackAdmin(req.Offset(), req.Limit())
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, response.NewPage(list, total, req.PageQuery))
}
type handledReq struct {
Handled bool `json:"handled"`
}
// AdminSetFeedbackHandled PUT /api/admin/feedback/:id/handled
func (h *Handler) AdminSetFeedbackHandled(c *gin.Context) {
var req handledReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.SetFeedbackHandled(idParam(c, "id"), req.Handled); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// ListAIMessages GET /api/ai/messages?session=&limit=
func (h *Handler) ListAIMessages(c *gin.Context) {
limit := 0
if v := c.Query("limit"); v != "" {
limit, _ = strconv.Atoi(v)
}
msgs, err := h.svc.ListAIMessages(middleware.UserID(c), c.Query("session"), limit)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, msgs)
}
// AIQuota GET /api/ai/quota 今日剩余次数
func (h *Handler) AIQuota(c *gin.Context) {
response.OK(c, h.svc.AIQuotaLeft(middleware.UserID(c)))
}
-234
View File
@@ -1,234 +0,0 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type petReq struct {
Name string `json:"name"`
Emoji string `json:"emoji"`
Type string `json:"type"`
Gender string `json:"gender"`
Birthday string `json:"birthday"` // YYYY-MM-DD
ArrivedAt string `json:"arrived_at"` // YYYY-MM-DD,到家日期
Weight string `json:"weight"`
Stage string `json:"stage"`
Age string `json:"age"`
Color string `json:"color"`
Breed string `json:"breed"`
AvatarFileID string `json:"avatar_file_id"`
Goals []string `json:"goals"`
Personality string `json:"personality"`
Allergy string `json:"allergy"`
Notes string `json:"notes"`
}
func (r petReq) toInput() service.PetInput {
in := service.PetInput{
Name: r.Name, Emoji: r.Emoji, Type: r.Type, Gender: r.Gender,
Weight: r.Weight, Stage: r.Stage, Age: r.Age, Color: r.Color, Breed: r.Breed,
AvatarFileID: r.AvatarFileID,
Personality: r.Personality, Allergy: r.Allergy, Notes: r.Notes,
}
if r.Birthday != "" {
if t, err := time.ParseInLocation("2006-01-02", r.Birthday, time.Local); err == nil {
// 生日不接受未来日期:前端限制过一道,服务端不能只信前端
if !t.After(time.Now()) {
in.Birthday = &t
}
}
}
if r.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", r.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
in.ArrivedAt = &t
}
}
// 阶段归一化不在这里做:狗要按品种查体型才能定阈值,需要访问 DB。
// 交给 handler 里 h.svc.NormalizeStage 处理。
if r.Goals != nil {
if b, err := jsonMarshal(r.Goals); err == nil {
in.Goals = datatypes.JSON(b)
}
}
return in
}
// ListPets GET /api/pets
func (h *Handler) ListPets(c *gin.Context) {
pets, err := h.svc.ListPets(middleware.UserID(c))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pets)
}
// CreatePet POST /api/pets
func (h *Handler) CreatePet(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.CreatePet(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
// GetPet GET /api/pets/:id
func (h *Handler) GetPet(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, pet)
}
// UpdatePet PUT /api/pets/:id
func (h *Handler) UpdatePet(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Name != "" {
fields["name"] = req.Name
}
if req.Emoji != "" {
fields["emoji"] = req.Emoji
}
if req.Weight != "" {
fields["weight"] = req.Weight
}
if req.Stage != "" {
fields["stage"] = req.Stage
}
if req.Gender != "" {
fields["gender"] = req.Gender
}
if req.Age != "" {
fields["age"] = req.Age
}
if req.Color != "" {
fields["color"] = req.Color
}
// 性格/过敏/备注:空串也允许写(用户可能想清空),用指针语义太重,
// 这里约定前端总是回传当前值,非空才更新;清空场景暂用「无」占位
if req.Personality != "" {
fields["personality"] = req.Personality
}
if req.Allergy != "" {
fields["allergy"] = req.Allergy
}
if req.Notes != "" {
fields["notes"] = req.Notes
}
if req.Breed != "" {
fields["breed"] = req.Breed
}
// 生日和到家日期是日期列,得先解析成 time。之前漏了这两个,
// 导致编辑档案时改到家日期不落库(建档走 toInput 才正常)
if req.Birthday != "" {
if t, err := time.ParseInLocation("2006-01-02", req.Birthday, time.Local); err == nil {
fields["birthday"] = t
}
}
if req.ArrivedAt != "" {
if t, err := time.ParseInLocation("2006-01-02", req.ArrivedAt, time.Local); err == nil && !t.After(time.Now()) {
fields["arrived_at"] = t
}
}
if req.AvatarFileID != "" {
fields["avatar_file_id"] = req.AvatarFileID
}
pet, err := h.svc.UpdatePet(middleware.UserID(c), idParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, pet)
}
// DeletePet DELETE /api/pets/:id
func (h *Handler) DeletePet(c *gin.Context) {
if err := h.svc.DeletePet(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// Onboarding POST /api/onboarding
func (h *Handler) Onboarding(c *gin.Context) {
var req petReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := req.toInput()
in.Stage = h.svc.NormalizeStage(service.SpeciesOf(req.Type), req.Breed, in.Stage, in.Birthday)
pet, err := h.svc.Onboarding(middleware.UserID(c), in)
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, pet)
}
// Breeds GET /api/breeds?species=cat 品种,按首字母分组。
// 建档第二步的选择器直接照这个渲染,前端不做分组也不做排序
func (h *Handler) Breeds(c *gin.Context) {
response.OK(c, h.svc.ListBreeds(c.Query("species")))
}
// LifeStages GET /api/life-stages?species=dog&size=large
// 生命阶段的划分标准。建档时选阶段那里、和「该换方案了」的提示里都要展示依据,
// 所以直接把驱动判定的那张表吐出来 —— 不另写一份说明文案,免得两边漂
func (h *Handler) LifeStages(c *gin.Context) {
species := c.Query("species")
if species == "" {
species = model.SpeciesCat
}
response.OK(c, gin.H{
"species": species,
"size": c.Query("size"),
"stages": service.StageRules(species, c.Query("size")),
"sizes": service.SizeLabels,
})
}
// PetStageDrift GET /api/pets/:id/stage-drift
// 存的阶段和按生日算的对不上时,告诉前端可以提示用户换一套方案
func (h *Handler) PetStageDrift(c *gin.Context) {
pet, err := h.svc.GetPet(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, h.svc.CheckStageDrift(pet))
}
// PetIDCard GET /api/pets/:id/id-card 宠物身份卡内容(含小程序码,未发布时无码)
func (h *Handler) PetIDCard(c *gin.Context) {
card, err := h.svc.GetIDCard(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, card)
}
-220
View File
@@ -1,220 +0,0 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
// ListTasks GET /api/pets/:id/tasks?date=YYYY-MM-DD
func (h *Handler) ListTasks(c *gin.Context) {
var date *time.Time
if q := c.Query("date"); q != "" {
if t, err := time.ParseInLocation("2006-01-02", q, time.Local); err == nil {
date = &t
}
}
tasks, err := h.svc.ListTasks(middleware.UserID(c), idParam(c, "id"), date)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tasks)
}
// 任务的增删改统一走 /plan-tasks 那套 —— 「今日任务」和「计划节点」合并之后,
// /tasks 这边只保留「读某天的」和「勾选」,重复的 CRUD 删掉了。
// 留着两套会出现「首页管理弹层加的任务」和「计划页加的节点」进不同的表。
// ToggleTask POST /api/tasks/:id/toggle
func (h *Handler) ToggleTask(c *gin.Context) {
task, err := h.svc.ToggleTask(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, task)
}
// CompleteAllTasks POST /api/pets/:id/tasks/complete-all
func (h *Handler) CompleteAllTasks(c *gin.Context) {
tasks, err := h.svc.CompleteAllTasks(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, tasks)
}
// GetPlan GET /api/pets/:id/plan
func (h *Handler) GetPlan(c *gin.Context) {
plan, err := h.svc.GetPlan(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// DayPlan GET /api/pets/:id/day-plan?date=YYYY-MM-DD
func (h *Handler) DayPlan(c *gin.Context) {
res, err := h.svc.DayPlan(middleware.UserID(c), idParam(c, "id"), c.Query("date"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
// PlanCalendar GET /api/pets/:id/plan/calendar?month=YYYY-MM
func (h *Handler) PlanCalendar(c *gin.Context) {
now := time.Now()
year, month := now.Year(), int(now.Month())
if q := c.Query("month"); q != "" {
if t, err := time.Parse("2006-01", q); err == nil {
year, month = t.Year(), int(t.Month())
}
}
res, err := h.svc.Calendar(middleware.UserID(c), idParam(c, "id"), year, month)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
type aiPlanReq struct {
Input string `json:"input"`
}
// CreateAIPlan POST /api/pets/:id/ai-plan
func (h *Handler) CreateAIPlan(c *gin.Context) {
var req aiPlanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
plan, err := h.svc.CreateAIPlan(middleware.UserID(c), idParam(c, "id"), req.Input)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// ApplyAIPlan POST /api/ai-plan/:id/apply
func (h *Handler) ApplyAIPlan(c *gin.Context) {
plan, err := h.svc.ApplyAIPlan(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, plan)
}
// TogglePlanTask POST /api/plan-tasks/:id/toggle
func (h *Handler) TogglePlanTask(c *gin.Context) {
if err := h.svc.TogglePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"ok": true})
}
// planTaskReq 用户自己加/改的计划节点
type planTaskReq struct {
Date string `json:"date"`
Title string `json:"title"`
Description string `json:"description"`
SheetType string `json:"sheet_type"`
Daily bool `json:"daily"`
}
func (r planTaskReq) input() service.PlanTaskInput {
return service.PlanTaskInput{Date: r.Date, Title: r.Title,
Description: r.Description, SheetType: r.SheetType, Daily: r.Daily}
}
// AddPlanTask POST /api/pets/:id/plan-tasks 往 30 天计划里加一条
func (h *Handler) AddPlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
list, err := h.svc.AddPlanTask(middleware.UserID(c), idParam(c, "id"), req.input())
if err != nil {
respondErr(c, err)
return
}
// daily 会一次建多条,统一返回数组,前端不用分两种情况处理
response.OK(c, list)
}
// UpdatePlanTask PUT /api/plan-tasks/:id
func (h *Handler) UpdatePlanTask(c *gin.Context) {
var req planTaskReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if err := h.svc.UpdatePlanTask(middleware.UserID(c), idParam(c, "id"), req.input()); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// DeletePlanTask DELETE /api/plan-tasks/:id
func (h *Handler) DeletePlanTask(c *gin.Context) {
if err := h.svc.DeletePlanTask(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, nil)
}
// PlanMonthStatus GET /api/pets/:id/plan/month-status
// 首页「管理计划」那个提醒角标要的:本月剩几件、下月排了没有
func (h *Handler) PlanMonthStatus(c *gin.Context) {
st, err := h.svc.PlanMonthStatus(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, st)
}
// CareTemplateOptions GET /api/care-templates?species=cat&stage=幼年期
// 建档最后一步和计划页的「套用方案」都读这个
func (h *Handler) CareTemplateOptions(c *gin.Context) {
response.OK(c, h.svc.ListTemplateOptions(c.Query("species"), c.Query("stage")))
}
type applyTemplateReq struct {
TemplateID string `json:"template_id"`
MonthOffset int `json:"month_offset"` // 0 本月,1 下月
}
// ApplyTemplate POST /api/pets/:id/apply-template
func (h *Handler) ApplyTemplate(c *gin.Context) {
var req applyTemplateReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
if req.TemplateID == "" {
response.FailParams(c, "缺少方案 id")
return
}
res, err := h.svc.ApplyTemplate(middleware.UserID(c), idParam(c, "id"), req.TemplateID, req.MonthOffset)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, res)
}
-98
View File
@@ -1,98 +0,0 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type recordReq struct {
Type string `json:"type"`
Icon string `json:"icon"`
Title string `json:"title"`
Description string `json:"description"`
NumValue float64 `json:"num_value"`
Category string `json:"category"`
ImageURL string `json:"image_url"`
ImageFileID string `json:"image_file_id"`
Extra datatypes.JSON `json:"extra"`
OccurredAt string `json:"occurred_at"`
}
// ListRecords GET /api/pets/:id/records?type=
func (h *Handler) ListRecords(c *gin.Context) {
var pq response.PageQuery
_ = c.ShouldBindQuery(&pq)
pq.Normalize()
records, total, err := h.svc.ListRecords(middleware.UserID(c), idParam(c, "id"), c.Query("type"), pq.Offset(), pq.Limit())
if err != nil {
respondErr(c, err)
return
}
response.OK(c, response.NewPage(records, total, pq))
}
// CreateRecord POST /api/pets/:id/records
func (h *Handler) CreateRecord(c *gin.Context) {
var req recordReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.RecordInput{
Type: req.Type, Icon: req.Icon, Title: req.Title, Description: req.Description,
NumValue: req.NumValue, Category: req.Category, ImageURL: req.ImageURL, ImageFileID: req.ImageFileID, Extra: req.Extra,
}
if req.OccurredAt != "" {
if t, err := time.Parse(time.RFC3339, req.OccurredAt); err == nil {
in.OccurredAt = &t
}
}
rec, err := h.svc.CreateRecord(middleware.UserID(c), idParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, rec)
}
// DeleteRecord DELETE /api/records/:id
func (h *Handler) DeleteRecord(c *gin.Context) {
if err := h.svc.DeleteRecord(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
// WeightTrend GET /api/pets/:id/records/weight-trend
func (h *Handler) WeightTrend(c *gin.Context) {
points, err := h.svc.WeightTrend(middleware.UserID(c), idParam(c, "id"), 7)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, points)
}
// PetInsights GET /api/pets/:id/insights
// 把散落的记录变成结论:体重趋势、换粮与软便的关联、异常扎堆、逾期提醒、同龄对比。
func (h *Handler) PetInsights(c *gin.Context) {
list, err := h.svc.PetInsights(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, list)
}
// RecordTypes GET /api/record-types 记录页可记事项,按分组归好。
// 类型从表里来而不是前端写死,后台加一种不用发版
func (h *Handler) RecordTypes(c *gin.Context) {
response.OK(c, h.svc.ListRecordTypes())
}
-88
View File
@@ -1,88 +0,0 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/internal/service"
"github.com/sundynix/pets-be/pkg/response"
)
type reminderReq struct {
Type string `json:"type"`
Title string `json:"title"`
NextDueDate string `json:"next_due_date"` // YYYY-MM-DD
Frequency string `json:"frequency"`
}
// ListReminders GET /api/pets/:id/reminders
func (h *Handler) ListReminders(c *gin.Context) {
reminders, err := h.svc.ListReminders(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, reminders)
}
// CreateReminder POST /api/pets/:id/reminders
func (h *Handler) CreateReminder(c *gin.Context) {
var req reminderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
in := service.ReminderInput{Type: req.Type, Title: req.Title, Frequency: req.Frequency}
if req.NextDueDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
in.NextDueDate = &t
}
}
r, err := h.svc.CreateReminder(middleware.UserID(c), idParam(c, "id"), in)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, r)
}
// UpdateReminder PUT /api/reminders/:id
func (h *Handler) UpdateReminder(c *gin.Context) {
var req reminderReq
if err := c.ShouldBindJSON(&req); err != nil {
response.FailParams(c, err.Error())
return
}
fields := map[string]any{}
if req.Type != "" {
fields["type"] = req.Type
}
if req.Title != "" {
fields["title"] = req.Title
}
if req.Frequency != "" {
fields["frequency"] = req.Frequency
}
if req.NextDueDate != "" {
if t, err := time.ParseInLocation("2006-01-02", req.NextDueDate, time.Local); err == nil {
fields["next_due_date"] = t
}
}
r, err := h.svc.UpdateReminder(middleware.UserID(c), idParam(c, "id"), fields)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, r)
}
// DeleteReminder DELETE /api/reminders/:id
func (h *Handler) DeleteReminder(c *gin.Context) {
if err := h.svc.DeleteReminder(middleware.UserID(c), idParam(c, "id")); err != nil {
respondErr(c, err)
return
}
response.OK(c, gin.H{"deleted": true})
}
-49
View File
@@ -1,49 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/middleware"
"github.com/sundynix/pets-be/pkg/response"
)
// WeeklyReport GET /api/pets/:id/report/weekly
func (h *Handler) WeeklyReport(c *gin.Context) {
rep, err := h.svc.GetWeeklyReport(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, rep)
}
// Bill GET /api/pets/:id/bill?period=month
func (h *Handler) Bill(c *gin.Context) {
period := c.DefaultQuery("period", "month")
bill, err := h.svc.GetBill(middleware.UserID(c), idParam(c, "id"), period)
if err != nil {
respondErr(c, err)
return
}
response.OK(c, bill)
}
// HealthSummary GET /api/pets/:id/health-summary
func (h *Handler) HealthSummary(c *gin.Context) {
sum, err := h.svc.GetHealthSummary(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, sum)
}
// Poster GET /api/pets/:id/poster
func (h *Handler) Poster(c *gin.Context) {
poster, err := h.svc.GetPoster(middleware.UserID(c), idParam(c, "id"))
if err != nil {
respondErr(c, err)
return
}
response.OK(c, poster)
}
-30
View File
@@ -1,30 +0,0 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/pkg/response"
)
// Upload POST /api/upload multipart/form-data,字段名 file。
// 按 MD5 去重:相同内容直接返回已有文件,不重复上传。返回 {id,url,md5}。
func (h *Handler) Upload(c *gin.Context) {
fileHeader, err := c.FormFile("file")
if err != nil {
response.FailParams(c, "缺少上传文件 file")
return
}
src, err := fileHeader.Open()
if err != nil {
response.FailErr(c, err)
return
}
defer src.Close()
f, err := h.svc.UploadFile(src, fileHeader.Filename, fileHeader.Header.Get("Content-Type"))
if err != nil {
response.FailErr(c, err)
return
}
response.OK(c, gin.H{"id": f.ID, "url": f.URL, "md5": f.MD5})
}
-76
View File
@@ -1,76 +0,0 @@
package handler
import (
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"io"
"log"
"net/http"
"sort"
"strings"
"github.com/gin-gonic/gin"
)
// secCallbackMsg 兼容消息推送的两种数据格式:JSON 和 XML(字段名一致,标签两套都标)
type secCallbackMsg struct {
Event string `json:"Event" xml:"Event"`
MsgType string `json:"MsgType" xml:"MsgType"`
TraceID string `json:"trace_id" xml:"trace_id"`
Result struct {
Suggest string `json:"suggest" xml:"suggest"`
} `json:"result" xml:"result"`
}
// 图片异步安全检测的结果回调(微信「消息推送」服务器配置指向这里)。
// GET 校验服务器:把 echostr 原样返回
// POST 接收 wxa_media_check 结果:用 trace_id 找回帖子/评论并放出或打回
//
// 明文模式(消息加解密方式选「明文」)下 body 就是 JSON,直接解析;
// 若配了加密模式还需要 AES 解密,这里暂不处理(上线时消息推送选明文即可)。
// verifyWxSignature 校验 signature = sha1(sort(token, timestamp, nonce))
func (h *Handler) verifyWxSignature(c *gin.Context) bool {
token := h.cfg.WeChat.MsgToken
if token == "" {
return true // 没配 token(本地调试):不校验
}
sig := c.Query("signature")
parts := []string{token, c.Query("timestamp"), c.Query("nonce")}
sort.Strings(parts)
sum := sha1.Sum([]byte(strings.Join(parts, "")))
return hex.EncodeToString(sum[:]) == sig
}
// WxSecCallback GET+POST /api/wx/sec-callback
func (h *Handler) WxSecCallback(c *gin.Context) {
if !h.verifyWxSignature(c) {
c.String(http.StatusForbidden, "invalid signature")
return
}
if c.Request.Method == http.MethodGet {
c.String(http.StatusOK, c.Query("echostr")) // 服务器配置校验
return
}
raw, _ := io.ReadAll(c.Request.Body)
// 数据格式 JSON / XML 都兼容:以 '<' 开头当 XML,否则当 JSON
var msg secCallbackMsg
var err error
if trimmed := strings.TrimSpace(string(raw)); strings.HasPrefix(trimmed, "<") {
err = xml.Unmarshal(raw, &msg)
} else {
err = json.Unmarshal(raw, &msg)
}
if err != nil {
log.Printf("[warn] sec-callback 解析失败: %v, body=%s", err, string(raw))
c.String(http.StatusOK, "success") // 回 success 让微信别重推
return
}
if msg.Event == "wxa_media_check" && msg.TraceID != "" {
h.svc.ResolveMediaCheck(msg.TraceID, msg.Result.Suggest)
}
c.String(http.StatusOK, "success")
}
+469
View File
@@ -0,0 +1,469 @@
package httpapi
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
type adminPage struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
Items any `json:"items"`
}
func pageParams(c *gin.Context) (adminPage, bool) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
size, sizeErr := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if err != nil || sizeErr != nil || page < 1 || page > 1000000 || size < 1 || size > 100 || len([]rune(c.Query("q"))) > 128 {
fail(c, http.StatusBadRequest, "分页或搜索参数无效")
return adminPage{}, false
}
return adminPage{Page: page, PageSize: size}, true
}
func adminRows[T any](c *gin.Context, query *gorm.DB, order string) ([]T, adminPage, bool) {
page, valid := pageParams(c)
if !valid {
return nil, page, false
}
if err := query.Count(&page.Total).Error; err != nil {
fail(c, 500, "读取记录数量失败")
return nil, page, false
}
rows := make([]T, 0)
if err := query.Order(order).Offset((page.Page - 1) * page.PageSize).Limit(page.PageSize).Find(&rows).Error; err != nil {
fail(c, 500, "读取记录失败")
return nil, page, false
}
return rows, page, true
}
func search(query *gorm.DB, c *gin.Context, columns ...string) *gorm.DB {
q := strings.TrimSpace(c.Query("q"))
if q == "" {
return query
}
// Only allow literal substring search. Columns are constants supplied by handlers.
value := "%" + strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(q) + "%"
clauses := make([]string, 0, len(columns))
args := make([]any, 0, len(columns))
for _, column := range columns {
clauses = append(clauses, column+" ILIKE ?")
args = append(args, value)
}
return query.Where("("+strings.Join(clauses, " OR ")+")", args...)
}
func filterStatus(c *gin.Context, query *gorm.DB, column string, allowed ...string) (*gorm.DB, bool) {
status := c.Query("status")
if status == "" {
return query, true
}
for _, value := range allowed {
if status == value {
return query.Where(column+" = ?", status), true
}
}
fail(c, 400, "筛选状态无效")
return query, false
}
func (a *API) userNames(ids []string) (map[string]string, error) {
names := map[string]string{}
if len(ids) == 0 {
return names, nil
}
var users []model.User
err := a.db.Select("id", "nickname").Where("id IN ?", ids).Find(&users).Error
for _, user := range users {
names[user.ID] = user.Nickname
}
return names, err
}
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{}}, {"tasks", &model.CareTask{}}, {"files", &model.File{}}} {
var n int64
if err := a.db.Model(pair.value).Count(&n).Error; err != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
now := time.Now()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
for _, pair := range []struct {
name string
query *gorm.DB
}{
{"today_walks", a.db.Model(&model.Walk{}).Where("started_at >= ?", today)},
{"active_walks", a.db.Model(&model.Walk{}).Where("status IN ?", []string{"active", "paused"})},
{"upcoming_meetups", a.db.Model(&model.Meetup{}).Where("status = ? AND starts_at > ?", "open", now)},
{"today_completions", a.db.Model(&model.TaskCompletion{}).Where("date = ?", today)},
} {
var n int64
if pair.query.Count(&n).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts[pair.name] = n
}
var metrics struct {
Distance float64
Duration int64
Storage int64
}
if a.db.Model(&model.Walk{}).Select("COALESCE(SUM(distance_meters),0) AS distance, COALESCE(SUM(duration_seconds),0) AS duration").Scan(&metrics).Error != nil || a.db.Model(&model.File{}).Select("COALESCE(SUM(size),0) AS storage").Scan(&metrics).Error != nil {
fail(c, 500, "读取统计失败")
return
}
counts["distance_meters"] = metrics.Distance
counts["duration_seconds"] = metrics.Duration
counts["storage_bytes"] = metrics.Storage
type dayCount struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
var grouped []dayCount
since := today.AddDate(0, 0, -6)
if a.db.Model(&model.Walk{}).Select("to_char(started_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM-DD') AS date, COUNT(*) AS count").Where("started_at >= ?", since).Group("date").Scan(&grouped).Error != nil {
fail(c, 500, "读取趋势失败")
return
}
daily := make([]dayCount, 0, 7)
for day := since; !day.After(today); day = day.AddDate(0, 0, 1) {
entry := dayCount{Date: day.Format("2006-01-02")}
for _, row := range grouped {
if row.Date == entry.Date {
entry.Count = row.Count
}
}
daily = append(daily, entry)
}
counts["daily_walks"] = daily
ok(c, counts)
}
func (a *API) adminUsers(c *gin.Context) {
query := search(a.db.Model(&model.User{}), c, "nickname", "id::text")
switch c.Query("status") {
case "":
case "enabled":
query = query.Where("disabled = false")
case "disabled":
query = query.Where("disabled = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.User](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, row := range rows {
ids = append(ids, row.ID)
}
var totals []struct {
UserID string
Count int64
}
if len(ids) > 0 && a.db.Model(&model.Pet{}).Select("user_id, COUNT(*) AS count").Where("user_id IN ?", ids).Group("user_id").Scan(&totals).Error != nil {
fail(c, 500, "读取宠物数量失败")
return
}
counts := map[string]int64{}
for _, row := range totals {
counts[row.UserID] = row.Count
}
items := make([]gin.H, 0, len(rows))
for _, u := range rows {
view := a.userView(u)
view["disabled"] = u.Disabled
view["pet_count"] = counts[u.ID]
items = append(items, view)
}
page.Items = items
ok(c, page)
}
func (a *API) adminUserPets(c *gin.Context) {
id := c.Param("id")
if !validID(id) {
fail(c, 400, "用户 ID 无效")
return
}
var user model.User
if err := a.db.First(&user, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
} else {
fail(c, 500, "读取用户失败")
}
return
}
var rows []model.Pet
if a.db.Where("user_id = ?", id).Order("archived ASC, created_at DESC, id DESC").Find(&rows).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, pet := range rows {
view := a.petView(pet)
view["archived"] = pet.Archived
view["user_id"] = id
view["user_name"] = user.Nickname
items = append(items, view)
}
ok(c, items)
}
func (a *API) adminPets(c *gin.Context) {
query := search(a.db.Model(&model.Pet{}), c, "name", "breed", "id::text", "user_id::text")
switch c.Query("status") {
case "":
case "active":
query = query.Where("archived = false")
case "archived":
query = query.Where("archived = true")
default:
fail(c, 400, "筛选状态无效")
return
}
rows, page, valid := adminRows[model.Pet](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, p := range rows {
ids = append(ids, p.UserID)
}
names, err := a.userNames(ids)
if err != 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["user_name"] = names[p.UserID]
v["archived"] = p.Archived
items = append(items, v)
}
page.Items = items
ok(c, page)
}
func (a *API) adminWalks(c *gin.Context) {
query := search(a.db.Model(&model.Walk{}), c, "id::text", "user_id::text")
query, valid := filterStatus(c, query, "status", "active", "paused", "finished")
if !valid {
return
}
rows, page, valid := adminRows[model.Walk](c, query, "started_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
walkIDs := []string{}
for _, w := range rows {
ids = append(ids, w.UserID)
walkIDs = append(walkIDs, w.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var links []struct {
WalkID string
PetName string
}
if len(walkIDs) > 0 && a.db.Model(&model.WalkPet{}).Select("sundynix_walk_pet.walk_id, p.name AS pet_name").Joins("JOIN sundynix_pet p ON p.id = sundynix_walk_pet.pet_id").Where("walk_id IN ?", walkIDs).Order("p.created_at ASC").Scan(&links).Error != nil {
fail(c, 500, "读取同行宠物失败")
return
}
pets := map[string][]string{}
for _, link := range links {
pets[link.WalkID] = append(pets[link.WalkID], link.PetName)
}
items := make([]gin.H, 0, len(rows))
for _, w := range rows {
items = append(items, gin.H{"id": w.ID, "user_id": w.UserID, "user_name": names[w.UserID], "pet_names": pets[w.ID], "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled})
}
page.Items = items
ok(c, page)
}
func (a *API) adminWalkDetail(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "记录 ID 无效")
return
}
var walk model.Walk
if err := a.db.First(&walk, "id = ?", c.Param("id")).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "遛狗记录不存在")
} else {
fail(c, 500, "读取记录失败")
}
return
}
var points []model.WalkPoint
if a.db.Where("walk_id = ?", walk.ID).Order("recorded_at ASC, id ASC").Find(&points).Error != nil {
fail(c, 500, "读取足迹失败")
return
}
ok(c, gin.H{"id": walk.ID, "points": points})
}
func (a *API) adminMeetups(c *gin.Context) {
query := search(a.db.Model(&model.Meetup{}), c, "title", "place_name", "id::text")
query, valid := filterStatus(c, query, "status", "open", "cancelled")
if !valid {
return
}
rows, page, valid := adminRows[model.Meetup](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids := []string{}
for _, m := range rows {
ids = append(ids, m.HostUserID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取发起人失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
v := a.meetupView(m)
v["user_name"] = names[m.HostUserID]
items = append(items, v)
}
page.Items = items
ok(c, page)
}
func (a *API) adminTasks(c *gin.Context) {
query := search(a.db.Model(&model.CareTask{}), c, "title", "id::text", "user_id::text", "pet_id::text")
query, valid := filterStatus(c, query, "category", "food", "water", "walk", "medicine", "grooming", "other")
if !valid {
return
}
rows, page, valid := adminRows[model.CareTask](c, query, "created_at DESC, id DESC")
if !valid {
return
}
ids, petIDs, taskIDs := []string{}, []string{}, []string{}
for _, t := range rows {
ids = append(ids, t.UserID)
if t.PetID != nil {
petIDs = append(petIDs, *t.PetID)
}
taskIDs = append(taskIDs, t.ID)
}
names, err := a.userNames(ids)
if err != nil {
fail(c, 500, "读取用户失败")
return
}
var pets []model.Pet
if len(petIDs) > 0 && a.db.Select("id", "name").Where("id IN ?", petIDs).Find(&pets).Error != nil {
fail(c, 500, "读取宠物失败")
return
}
petNames := map[string]string{}
for _, p := range pets {
petNames[p.ID] = p.Name
}
var completions []model.TaskCompletion
if len(taskIDs) > 0 && a.db.Where("task_id IN ? AND date = ?", taskIDs, time.Now().Format("2006-01-02")).Find(&completions).Error != nil {
fail(c, 500, "读取任务完成情况失败")
return
}
done := map[string]bool{}
for _, completion := range completions {
done[completion.TaskID] = true
}
items := make([]gin.H, 0, len(rows))
for _, t := range rows {
petName := "全部宠物"
if t.PetID != nil {
petName = petNames[*t.PetID]
}
items = append(items, gin.H{"id": t.ID, "user_id": t.UserID, "user_name": names[t.UserID], "pet_id": t.PetID, "all_pets": t.PetID == nil, "pet_name": petName, "title": t.Title, "category": t.Category, "time_of_day": t.TimeOfDay, "repeat_rule": t.RepeatRule, "reminder_enabled": t.ReminderEnabled, "completed": done[t.ID], "created_at": t.CreatedAt})
}
page.Items = items
ok(c, page)
}
func (a *API) adminFiles(c *gin.Context) {
query := search(a.db.Model(&model.File{}), c, "name", "md5", "id::text")
rows, page, valid := adminRows[model.File](c, query, "created_at DESC, id DESC")
if !valid {
return
}
items := make([]gin.H, 0, len(rows))
for _, file := range rows {
v := a.fileView(file)
v["created_at"] = file.CreatedAt
v["bucket"] = file.Bucket
v["object_key"] = file.ObjectKey
items = append(items, v)
}
page.Items = items
ok(c, page)
}
func (a *API) adminSetUserDisabled(c *gin.Context) {
var in struct {
Disabled *bool `json:"disabled"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil || in.Disabled == nil {
fail(c, 400, "参数无效")
return
}
err := a.db.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.User{}).Where("id = ?", c.Param("id")).Update("disabled", *in.Disabled)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
// Disabling an account also revokes any publicly shared locations.
if *in.Disabled {
return tx.Model(&model.Walk{}).Where("user_id = ?", c.Param("id")).Updates(map[string]any{"share_enabled": false, "share_expires_at": nil}).Error
}
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "用户不存在")
return
}
if err != nil {
fail(c, 500, "更新用户失败")
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 != "cancelled" {
fail(c, 400, "状态无效")
return
}
result := a.db.Model(&model.Meetup{}).Where("id = ? AND status = ?", c.Param("id"), "open").Update("status", in.Status)
if result.Error != nil {
fail(c, 500, "更新约遛失败")
return
}
if result.RowsAffected == 0 {
fail(c, 409, "约遛不存在或已取消")
return
}
ok(c, gin.H{"status": in.Status})
}
+231
View File
@@ -0,0 +1,231 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestAdminPageValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, query := range []string{"", "?page=2&page_size=10", "?page=0", "?page_size=101", "?page=abc", "?page=-1", "?page_size=0"} {
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(http.MethodGet, "/api/admin/users"+query, nil)
page, valid := pageParams(context)
expected := query == "" || query == "?page=2&page_size=10"
if valid != expected {
t.Errorf("%s: valid=%v", query, valid)
}
if query == "" && (page.Page != 1 || page.PageSize != 10) {
t.Fatalf("default pagination: %+v", page)
}
if !valid && recorder.Code != 400 {
t.Errorf("%s: status=%d", query, recorder.Code)
}
}
}
func TestAdminListsRequireAdminRole(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := &config.Config{}
cfg.JWT.Secret = "test-secret"
api := New(nil, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "user")
if err != nil {
t.Fatal(err)
}
for _, route := range []string{"users", "users/" + uuid.NewString() + "/pets", "pets", "walks", "walks/" + uuid.NewString(), "meetups", "tasks", "files", "stats"} {
for _, auth := range []bool{false, true} {
request := httptest.NewRequest(http.MethodGet, "/api/admin/"+route, nil)
if auth {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
expected := http.StatusUnauthorized
if auth {
expected = http.StatusForbidden
}
if recorder.Code != expected {
t.Errorf("%s auth=%v: status=%d", route, auth, recorder.Code)
}
}
}
}
// Opt-in read-only verification against the configured PostgreSQL; never seeds or changes data.
func TestAdminPostgresReadOnly(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("set PETS_ADMIN_INTEGRATION=1 for PostgreSQL verification")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
gin.SetMode(gin.TestMode)
api := New(db, nil, cfg)
router := gin.New()
api.Register(router)
token, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("GET %s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
return body.Data
}
read("/api/admin/stats")
for _, section := range []string{"users", "pets", "walks", "meetups", "tasks", "files"} {
var page struct {
Items []struct {
ID string `json:"id"`
UserID string `json:"user_id"`
PetNames []string `json:"pet_names"`
} `json:"items"`
Total int64 `json:"total"`
PageSize int `json:"page_size"`
Page int `json:"page"`
}
if err := json.Unmarshal(read("/api/admin/"+section+"?page=1&page_size=10"), &page); err != nil {
t.Fatal(err)
}
if page.Page != 1 || page.PageSize != 10 || len(page.Items) > 10 || page.Total < int64(len(page.Items)) {
t.Fatalf("invalid %s pagination: %+v", section, page)
}
read("/api/admin/" + section + "?page=2&page_size=10")
read("/api/admin/" + section + "?q=%25%27%5F")
if len(page.Items) > 0 && section == "users" {
read("/api/admin/users/" + page.Items[0].ID + "/pets")
}
if len(page.Items) > 0 && section == "walks" {
read("/api/admin/walks/" + page.Items[0].ID)
}
}
}
// Fixtures live inside a transaction and are always rolled back.
func TestAdminPaginationAndWalkRelations(t *testing.T) {
gin.SetMode(gin.TestMode)
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
prefix := "admin-test-" + uuid.NewString()
users := make([]model.User, 11)
for i := range users {
users[i] = model.User{Nickname: prefix, OpenID: uuid.NewString()}
}
if tx.Create(&users).Error != nil {
t.Fatal("create temporary users")
}
pets := []model.Pet{{UserID: users[0].ID, Name: "奶盖", Species: "dog"}, {UserID: users[0].ID, Name: "布丁", Species: "dog"}}
if tx.Create(&pets).Error != nil {
t.Fatal("create temporary pets")
}
now := time.Now()
walk := model.Walk{UserID: users[0].ID, Status: "finished", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := tx.Create(&walk).Error; err != nil {
t.Fatal(err)
}
for _, pet := range pets {
if err := tx.Create(&model.WalkPet{WalkID: walk.ID, PetID: pet.ID}).Error; err != nil {
t.Fatal(err)
}
}
api := New(tx, nil, cfg)
router := gin.New()
api.Register(router)
token, _ := api.token(uuid.NewString(), "admin")
read := func(path string) json.RawMessage {
t.Helper()
request := httptest.NewRequest(http.MethodGet, path, nil)
request.Header.Set("Authorization", "Bearer "+token)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != 200 {
t.Fatalf("%s: %d %s", path, recorder.Code, recorder.Body.String())
}
var body struct {
Data json.RawMessage `json:"data"`
}
json.Unmarshal(recorder.Body.Bytes(), &body)
return body.Data
}
for _, sample := range []struct {
page string
size int
}{{"1", 10}, {"2", 1}} {
var result struct {
Items []json.RawMessage `json:"items"`
Total int64 `json:"total"`
}
if err := json.Unmarshal(read("/api/admin/users?q="+prefix+"&page="+sample.page), &result); err != nil {
t.Fatal(err)
}
if result.Total != 11 || len(result.Items) != sample.size {
t.Fatalf("pagination got %d rows / %d total", len(result.Items), result.Total)
}
}
var children []struct {
Name string `json:"name"`
}
json.Unmarshal(read("/api/admin/users/"+users[0].ID+"/pets"), &children)
if len(children) != 2 {
t.Fatalf("user tree lost pets: %+v", children)
}
var result struct {
Items []struct {
UserID string `json:"user_id"`
UserName string `json:"user_name"`
PetNames []string `json:"pet_names"`
} `json:"items"`
}
json.Unmarshal(read("/api/admin/walks?q="+walk.ID), &result)
if len(result.Items) != 1 || result.Items[0].UserID != users[0].ID || result.Items[0].UserName != prefix || len(result.Items[0].PetNames) != 2 {
t.Fatalf("walk relation incomplete: %+v", result)
}
}
+223
View File
@@ -0,0 +1,223 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"math/big"
"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
}
pair, err := a.newSession(admin.ID, "admin")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
pair["admin"] = gin.H{"id": admin.ID, "username": admin.Username}
ok(c, pair)
}
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 len([]rune(name)) > 64 {
fail(c, http.StatusBadRequest, "昵称不能超过 64 字")
return
}
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
}
// Serialize new-user naming across server instances without requiring custom nicknames to be unique.
err = a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", int64(7270139527)).Error; err != nil {
return err
}
// Another login for this open ID may have completed while waiting for the lock.
if err := tx.Where("open_id = ?", openID).First(&user).Error; err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
name := strings.TrimSpace(nickname)
if name == "" {
generated, err := defaultNickname(tx)
if err != nil {
return err
}
name = generated
}
user = model.User{OpenID: openID, Nickname: name}
return tx.Create(&user).Error
})
if err != nil {
return user, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func defaultNickname(tx *gorm.DB) (string, error) {
// Prefer four digits. Expand when occupied names make retries frequent.
for digits := 4; digits <= 6; digits++ {
limit := int64(10000)
if digits == 5 {
limit = 100000
} else if digits == 6 {
limit = 1000000
}
for attempt := 0; attempt < 20; attempt++ {
number, err := rand.Int(rand.Reader, big.NewInt(limit))
if err != nil {
return "", fmt.Errorf("generate nickname: %w", err)
}
name := fmt.Sprintf("毛孩家长%0*d", digits, number.Int64())
var count int64
if err := tx.Model(&model.User{}).Where("nickname = ?", name).Count(&count).Error; err != nil {
return "", err
}
if count == 0 {
return name, nil
}
}
}
return "", errors.New("default nickname allocation failed")
}
func (a *API) loginUser(c *gin.Context, user model.User) {
if user.Disabled {
fail(c, http.StatusForbidden, "账号已停用")
return
}
pair, err := a.newSession(user.ID, "user")
if err != nil {
fail(c, http.StatusInternalServerError, "签发令牌失败")
return
}
pair["user"] = a.userView(user)
ok(c, pair)
}
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))
}
+219
View File
@@ -0,0 +1,219 @@
package httpapi
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) breedQuery(c *gin.Context) *gorm.DB {
return search(a.db.Model(&model.Breed{}), c, "name", "english_name", "aliases", "pinyin", "pinyin_initials")
}
func (a *API) listBreeds(c *gin.Context) {
if len([]rune(c.Query("q"))) > 128 {
fail(c, 400, "搜索内容过长")
return
}
query := a.breedQuery(c).Where("enabled = true")
var rows []model.Breed
if err := query.Order("initial ASC, pinyin ASC, name ASC").Find(&rows).Error; err != nil {
fail(c, 500, "读取品种失败")
return
}
// Users only need the selection fields; no internal source metadata.
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, gin.H{"id": row.ID, "name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "pinyin": row.Pinyin, "pinyin_initials": row.PinyinInitials, "initial": row.Initial, "has_details": row.Summary != "" || row.Origin != "", "origin": row.Origin})
}
ok(c, items)
}
func (a *API) adminBreeds(c *gin.Context) {
query := a.breedQuery(c).Preload("References")
if status := c.Query("status"); status != "" {
if status != "enabled" && status != "disabled" {
fail(c, 400, "状态无效")
return
}
query = query.Where("enabled = ?", status == "enabled")
}
if initial := c.Query("initial"); initial != "" {
if len(initial) != 1 || (initial != "#" && (initial < "A" || initial > "Z")) {
fail(c, 400, "字母无效")
return
}
query = query.Where("initial = ?", initial)
}
rows, page, valid := adminRows[model.Breed](c, query, "initial ASC, pinyin ASC, name ASC")
if !valid {
return
}
page.Items = rows
ok(c, page)
}
type breedInput struct {
LifeSpan string `json:"life_span"`
Exercise string `json:"exercise"`
Grooming string `json:"grooming"`
Traits map[string]int `json:"traits"`
FCINumber string `json:"fci_number"`
Origin string `json:"origin"`
BreedGroup string `json:"breed_group"`
Height string `json:"height"`
Weight string `json:"weight"`
Summary string `json:"summary"`
Temperament string `json:"temperament"`
CareNotes string `json:"care_notes"`
Name string `json:"name" binding:"required"`
EnglishName string `json:"english_name"`
Aliases string `json:"aliases"`
Enabled *bool `json:"enabled" binding:"required"`
}
func bindBreed(c *gin.Context) (model.Breed, bool) {
var in breedInput
if c.ShouldBindJSON(&in) != nil {
fail(c, 400, "请填写品种名称和状态")
return model.Breed{}, false
}
in.Name = strings.TrimSpace(in.Name)
in.EnglishName = strings.TrimSpace(in.EnglishName)
in.Aliases = strings.TrimSpace(in.Aliases)
if in.Name == "" || len([]rune(in.Name)) > 128 || len([]rune(in.EnglishName)) > 128 || len([]rune(in.Aliases)) > 512 {
fail(c, 400, "品种资料长度无效")
return model.Breed{}, false
}
if len([]rune(in.Origin)) > 128 || len([]rune(in.BreedGroup)) > 255 || len([]rune(in.Height)) > 128 || len([]rune(in.Weight)) > 128 || len([]rune(in.Summary)) > 2000 || len([]rune(in.Temperament)) > 255 || len([]rune(in.CareNotes)) > 2000 {
fail(c, 400, "百科资料长度无效")
return model.Breed{}, false
}
if len([]rune(in.LifeSpan)) > 128 || len([]rune(in.Exercise)) > 255 || len([]rune(in.Grooming)) > 255 || len(in.FCINumber) > 16 {
fail(c, 400, "照顾资料长度无效")
return model.Breed{}, false
}
validTraits := map[string]bool{"运动需求": true, "运动活力": true, "训练接受度": true, "掉毛程度": true, "吠叫倾向": true, "梳毛需求": true, "亲近家人": true}
for key, value := range in.Traits {
if !validTraits[key] || value < 1 || value > 5 {
fail(c, 400, "指数需要为 1–5")
return model.Breed{}, false
}
}
scores, _ := json.Marshal(in.Traits)
full, short, initial := breeds.SortFields(in.Name)
return model.Breed{LifeSpan: strings.TrimSpace(in.LifeSpan), Exercise: strings.TrimSpace(in.Exercise), Grooming: strings.TrimSpace(in.Grooming), FCINumber: strings.TrimSpace(in.FCINumber), Traits: string(scores), Origin: strings.TrimSpace(in.Origin), BreedGroup: strings.TrimSpace(in.BreedGroup), Height: strings.TrimSpace(in.Height), Weight: strings.TrimSpace(in.Weight), Summary: strings.TrimSpace(in.Summary), Temperament: strings.TrimSpace(in.Temperament), CareNotes: strings.TrimSpace(in.CareNotes), Name: in.Name, EnglishName: in.EnglishName, Aliases: in.Aliases, Pinyin: full, PinyinInitials: short, Initial: initial, Enabled: *in.Enabled}, true
}
func breedWriteError(c *gin.Context, err error) {
// pgx PostgreSQL unique_violation, without disclosing DB details.
type sqlState interface{ SQLState() string }
var state sqlState
if errors.As(err, &state) && state.SQLState() == "23505" {
fail(c, http.StatusConflict, "该品种名称已存在")
return
}
fail(c, 500, "保存品种失败")
}
func (a *API) createBreed(c *gin.Context) {
row, valid := bindBreed(c)
if !valid {
return
}
row.Source = "管理端录入"
row.EditorialLocked = true
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
// GORM defaults true for zero values on Create; explicitly select false after insertion.
err := a.db.Transaction(func(tx *gorm.DB) error {
enabled := row.Enabled
if err := tx.Create(&row).Error; err != nil {
return err
}
if !enabled {
row.Enabled = false
return tx.Model(&row).Update("enabled", false).Error
}
return nil
})
if err != nil {
breedWriteError(c, err)
return
}
created(c, row)
}
func (a *API) updateBreed(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 404, "品种不存在")
return
}
update, valid := bindBreed(c)
if !valid {
return
}
var row model.Breed
if a.db.First(&row, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "品种不存在")
return
}
if row.FieldSources == nil {
row.FieldSources = map[string]string{}
}
before := map[string]string{"name": row.Name, "english_name": row.EnglishName, "aliases": row.Aliases, "origin": row.Origin, "breed_group": row.BreedGroup, "height": row.Height, "weight": row.Weight, "summary": row.Summary, "temperament": row.Temperament, "care_notes": row.CareNotes, "life_span": row.LifeSpan, "exercise": row.Exercise, "grooming": row.Grooming, "fci_number": row.FCINumber, "traits": row.Traits}
after := map[string]string{"name": update.Name, "english_name": update.EnglishName, "aliases": update.Aliases, "origin": update.Origin, "breed_group": update.BreedGroup, "height": update.Height, "weight": update.Weight, "summary": update.Summary, "temperament": update.Temperament, "care_notes": update.CareNotes, "life_span": update.LifeSpan, "exercise": update.Exercise, "grooming": update.Grooming, "fci_number": update.FCINumber, "traits": update.Traits}
var previousTraits map[string]int
if json.Unmarshal([]byte(row.Traits), &previousTraits) == nil {
normalized, _ := json.Marshal(previousTraits)
before["traits"] = string(normalized)
}
for key, value := range after {
if value != before[key] && !(key == "traits" && value == "null") {
row.FieldSources[key] = "editorial"
}
}
row.Origin, row.BreedGroup, row.Height, row.Weight, row.Summary, row.Temperament, row.CareNotes = update.Origin, update.BreedGroup, update.Height, update.Weight, update.Summary, update.Temperament, update.CareNotes
row.LifeSpan, row.Exercise, row.Grooming, row.FCINumber = update.LifeSpan, update.Exercise, update.Grooming, update.FCINumber
if update.Traits != "null" {
row.Traits = update.Traits
}
row.EditorialLocked = true
row.EncyclopediaUpdatedAt = time.Now().Format("2006-01-02")
row.Name, row.EnglishName, row.Aliases = update.Name, update.EnglishName, update.Aliases
row.Pinyin, row.PinyinInitials, row.Initial, row.Enabled = update.Pinyin, update.PinyinInitials, update.Initial, update.Enabled
if err := a.db.Save(&row).Error; err != nil {
breedWriteError(c, err)
return
}
ok(c, row)
}
func (a *API) setBreedEnabled(c *gin.Context) {
var in struct {
Enabled *bool `json:"enabled" binding:"required"`
}
if !validID(c.Param("id")) || c.ShouldBindJSON(&in) != nil {
fail(c, 400, "状态参数无效")
return
}
result := a.db.Model(&model.Breed{}).Where("id = ?", c.Param("id")).Update("enabled", *in.Enabled)
if result.Error != nil {
fail(c, 500, "更新状态失败")
return
}
if result.RowsAffected == 0 {
fail(c, 404, "品种不存在")
return
}
ok(c, gin.H{"id": c.Param("id"), "enabled": *in.Enabled})
}
func (a *API) breedDetail(c *gin.Context) {
var row model.Breed
if !validID(c.Param("id")) || a.db.Preload("References").Where("id = ? AND enabled = true", c.Param("id")).First(&row).Error != nil {
fail(c, 404, "品种不存在或已停用")
return
}
ok(c, row)
}
@@ -0,0 +1,269 @@
package httpapi
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/breeds"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"net/http/httptest"
"os"
"testing"
"time"
)
func featureDB(t *testing.T) (*gorm.DB, *config.Config) {
t.Helper()
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
t.Cleanup(func() { sqlDB.Close() })
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
t.Cleanup(func() { tx.Rollback() })
return tx, cfg
}
func featureCall(handler gin.HandlerFunc, method, path string, body any, actorID, id string) *httptest.ResponseRecorder {
raw, _ := json.Marshal(body)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(method, path, bytes.NewReader(raw))
c.Request.Header.Set("Content-Type", "application/json")
c.Set("actor_id", actorID)
c.Params = gin.Params{{Key: "id", Value: id}}
handler(c)
return rec
}
func pairFrom(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
t.Helper()
if rec.Code != 200 && rec.Code != 201 {
t.Fatalf("status %d: %s", rec.Code, rec.Body)
}
var result struct {
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result.Data
}
func TestAccessTokenTwoHours(t *testing.T) {
api := New(nil, nil, &config.Config{JWT: config.JWTConfig{Secret: "test", ExpireHours: 168}})
raw, err := api.token(uuid.NewString(), "admin")
if err != nil {
t.Fatal(err)
}
c := &claims{}
_, err = jwt.ParseWithClaims(raw, c, func(*jwt.Token) (any, error) { return []byte("test"), nil })
if err != nil {
t.Fatal(err)
}
if c.ExpiresAt.Sub(c.IssuedAt.Time) != 2*time.Hour {
t.Fatal("access token lifetime is not two hours")
}
}
func TestRefreshRotationPostgres(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
user := model.User{OpenID: "refresh-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
}
pair, err := a.newSession(user.ID, "user")
if err != nil {
t.Fatal(err)
}
old := pair["refresh_token"].(string)
var session model.RefreshSession
if err := tx.Where("token_hash = ?", refreshHash(old)).First(&session).Error; err != nil {
t.Fatal(err)
}
if session.TokenHash == old {
t.Fatal("raw refresh stored")
}
call := func(raw, role string) *httptest.ResponseRecorder {
return featureCall(a.refreshToken(role), "POST", "/refresh", gin.H{"refresh_token": raw}, "", "")
}
if rec := call(old, "admin"); rec.Code != 401 {
t.Fatal("user credential accepted for admin")
}
next := pairFrom(t, call(old, "user"))
fresh := next["refresh_token"].(string)
if fresh == old || next["refresh_expires_at"].(float64) < float64(time.Now().Add(2*time.Hour-time.Second).Unix()) {
t.Fatal("rotation or two-hour expiry wrong")
}
if rec := call(old, "user"); rec.Code != 401 {
t.Fatal("old credential reusable")
}
revoked := featureCall(a.revokeRefresh("user"), "POST", "/logout", gin.H{"refresh_token": fresh}, "", "")
if revoked.Code != 200 {
t.Fatal(revoked.Body.String())
}
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("logout failed to revoke")
}
// Logout invalidates the access token tied to the same session too.
r := gin.New()
r.GET("/protected", a.requireRole("user"), func(c *gin.Context) { ok(c, true) })
req := httptest.NewRequest("GET", "/protected", nil)
req.Header.Set("Authorization", "Bearer "+next["token"].(string))
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatal("revoked session access accepted")
}
pair, err = a.newSession(user.ID, "user")
if err != nil {
t.Fatal(err)
}
fresh = pair["refresh_token"].(string)
if err := tx.Model(&user).Update("disabled", true).Error; err != nil {
t.Fatal(err)
}
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("disabled user refreshed")
}
if err := tx.Model(&user).Update("disabled", false).Error; err != nil {
t.Fatal(err)
}
tx.Model(&model.RefreshSession{}).Where("token_hash = ?", refreshHash(fresh)).Update("expires_at", time.Now().Add(-time.Minute))
if rec := call(fresh, "user"); rec.Code != 401 {
t.Fatal("expired credential refreshed")
}
admin := model.Admin{Username: "refresh-test:" + uuid.NewString(), PasswordHash: "fixture"}
if err := tx.Create(&admin).Error; err != nil {
t.Fatal(err)
}
pair, err = a.newSession(admin.ID, "admin")
if err != nil {
t.Fatal(err)
}
pairFrom(t, call(pair["refresh_token"].(string), "admin"))
}
func TestBreedCatalogPostgres(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
if added, err := breeds.Seed(tx); err != nil || added != 0 {
t.Fatalf("idempotent seed: %d %v", added, err)
}
var source model.Breed
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&source).Error; err != nil {
t.Fatal(err)
}
tx.Model(&source).Updates(map[string]any{"enabled": false, "name": "改名柯基测试"})
if added, err := breeds.Seed(tx); err != nil || added != 0 {
t.Fatalf("seed restored renamed record: %d %v", added, err)
}
tx.First(&source, "id = ?", source.ID)
if source.Enabled || source.Name != "改名柯基测试" {
t.Fatal("seed overwrote edits")
}
rec := featureCall(a.listBreeds, "GET", "/breeds?q=bianmu", nil, "", "")
if rec.Code != 200 {
t.Fatal(rec.Body.String())
}
// Short initials and Chinese aliases must both work.
for _, q := range []string{"边牧", "bjm", "bianjing"} {
rec = featureCall(a.listBreeds, "GET", "/breeds?q="+q, nil, "", "")
if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte("边境牧羊犬")) {
t.Fatalf("search %s: %s", q, rec.Body)
}
}
name := "测试品种" + uuid.NewString()
body := gin.H{"name": name, "aliases": "testbreed", "enabled": false}
created := pairFrom(t, featureCall(a.createBreed, "POST", "/breeds", body, "", ""))
id := created["id"].(string)
if created["enabled"].(bool) {
t.Fatal("disabled creation ignored")
}
pairFrom(t, featureCall(a.setBreedEnabled, "PUT", "/breeds", gin.H{"enabled": true}, "", id))
body["enabled"] = true
body["name"] = name + "更新"
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds", body, "", id))
// Duplicate request runs in a savepoint because PostgreSQL aborts a transaction on unique violations.
tx.SavePoint("duplicate_breed")
rec = featureCall(a.createBreed, "POST", "/breeds", body, "", "")
if rec.Code != 409 {
t.Fatalf("duplicate status %d: %s", rec.Code, rec.Body)
}
tx.RollbackTo("duplicate_breed")
user := model.User{OpenID: "breed-test:" + uuid.NewString(), Nickname: "测试家长"}
tx.Create(&user)
selected, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "forged", BreedID: &id})
if !valid || selected.Breed != body["name"] {
t.Fatal("selected name not resolved by ID")
}
custom, valid := a.buildPet(testPetContext(user.ID, ""), petInput{Name: "测试毛孩", Breed: "未收录地方犬"})
if !valid || custom.BreedID != nil || custom.Breed != "未收录地方犬" {
t.Fatal("manual breed unavailable")
}
selected.UserID = user.ID
tx.Create(&selected)
tx.Model(&model.Breed{}).Where("id = ?", id).Update("enabled", false)
_, valid = a.buildPet(testPetContext(user.ID, ""), petInput{Name: "新宠物", BreedID: &id})
if valid {
t.Fatal("disabled breed newly selected")
}
_, valid = a.buildPet(testPetContext(user.ID, selected.ID), petInput{Name: "旧宠物", BreedID: &id})
if !valid {
t.Fatal("existing disabled breed cannot be retained")
}
}
func testPetContext(actorID, id string) *gin.Context {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set("actor_id", actorID)
c.Params = gin.Params{{Key: "id", Value: id}}
return c
}
func TestBreedSourcesAndEditorialProtection(t *testing.T) {
tx, cfg := featureDB(t)
a := New(tx, nil, cfg)
var breed model.Breed
if err := tx.Where("name = ?", "彭布罗克威尔士柯基").First(&breed).Error; err != nil {
t.Fatal(err)
}
detail := pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
if len(detail["references"].([]any)) != 4 {
t.Fatal("Corgi references missing")
}
if detail["field_sources"].(map[string]any)["exercise"] != "rkc" {
t.Fatal("exercise attribution wrong")
}
body := gin.H{"name": breed.Name, "english_name": breed.EnglishName, "enabled": true, "exercise": "编辑安排的散步活动", "summary": "新的中文介绍", "traits": gin.H{"运动活力": 2}}
pairFrom(t, featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID))
if _, err := breeds.Seed(tx); err != nil {
t.Fatal(err)
}
detail = pairFrom(t, featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID))
if detail["exercise"] != "编辑安排的散步活动" || detail["summary"] != "新的中文介绍" || detail["height"] != "" {
t.Fatal("import overwrote manual edits or cleared fields")
}
if detail["field_sources"].(map[string]any)["exercise"] != "editorial" || len(detail["references"].([]any)) != 4 {
t.Fatal("edited provenance or reference uniqueness lost")
}
body["traits"] = gin.H{"运动活力": 6}
if rec := featureCall(a.updateBreed, "PUT", "/breeds/"+breed.ID, body, "", breed.ID); rec.Code != 400 {
t.Fatalf("invalid rating accepted: %d", rec.Code)
}
tx.Model(&model.Breed{}).Where("id = ?", breed.ID).Update("enabled", false)
if rec := featureCall(a.breedDetail, "GET", "/breeds/"+breed.ID, nil, "", breed.ID); rec.Code != 404 {
t.Fatal("disabled encyclopedia visible")
}
}
+31
View File
@@ -0,0 +1,31 @@
package httpapi
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/environment"
"net/http"
"strconv"
)
func (a *API) environment(c *gin.Context) {
lat, latErr := strconv.ParseFloat(c.Query("latitude"), 64)
lon, lonErr := strconv.ParseFloat(c.Query("longitude"), 64)
if latErr != nil || lonErr != nil {
fail(c, http.StatusBadRequest, "请提供有效的定位坐标")
return
}
result, err := a.env.Lookup(c.Request.Context(), lat, lon)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, environment.ErrCoordinates) {
status = http.StatusBadRequest
}
if errors.Is(err, environment.ErrNotConfigured) {
status = http.StatusServiceUnavailable
}
fail(c, status, err.Error())
return
}
ok(c, result)
}
+140
View File
@@ -0,0 +1,140 @@
package httpapi
import (
"context"
"crypto/md5"
"encoding/hex"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
)
func (a *API) fileExists(id string) bool {
if !validID(id) {
return false
}
var count int64
a.db.Model(&model.File{}).Where("id = ?", id).Count(&count)
return count == 1
}
const maxUploadSize = 10 << 20
func (a *API) uploadFile(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize+1024)
header, err := c.FormFile("file")
if err != nil || header.Size <= 0 || header.Size > maxUploadSize {
fail(c, http.StatusBadRequest, "请选择不超过 10 MB 的图片")
return
}
reader, err := header.Open()
if err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
defer reader.Close()
signature := make([]byte, 512)
n, _ := io.ReadFull(reader, signature)
contentType := http.DetectContentType(signature[:n])
extensions := map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp"}
ext, allowed := extensions[contentType]
if !allowed {
fail(c, http.StatusBadRequest, "仅支持 JPG、PNG、WebP 图片")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
hasher := md5.New()
if _, err := io.Copy(hasher, reader); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
if _, err := reader.Seek(0, io.SeekStart); err != nil {
fail(c, http.StatusBadRequest, "读取图片失败")
return
}
digest := hex.EncodeToString(hasher.Sum(nil))
var existing model.File
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil {
if existing.Size != header.Size {
fail(c, http.StatusConflict, "文件校验冲突")
return
}
ok(c, a.fileView(existing))
return
}
key := "files/" + digest[:2] + "/" + digest + ext
ctx, cancel := context.WithTimeout(c.Request.Context(), 45*time.Second)
defer cancel()
if err := a.storage.Put(ctx, key, reader, header.Size, contentType); err != nil {
fail(c, http.StatusBadGateway, "上传对象存储失败")
return
}
file := model.File{
Bucket: a.storage.Bucket(), ObjectKey: key, MD5: digest, URL: a.storage.PublicURL(key),
Name: strings.TrimSpace(header.Filename), ContentType: contentType,
Size: header.Size,
}
if err := a.db.Create(&file).Error; err != nil {
if a.db.Where("md5 = ?", digest).First(&existing).Error == nil && existing.Size == header.Size {
ok(c, a.fileView(existing))
return
}
fail(c, http.StatusInternalServerError, "保存文件信息失败")
return
}
created(c, a.fileView(file))
}
func (a *API) getFile(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, http.StatusBadRequest, "文件 ID 无效")
return
}
var file model.File
if err := a.db.Where("id = ?", c.Param("id")).First(&file).Error; err != nil {
fail(c, http.StatusNotFound, "文件不存在")
return
}
ok(c, a.fileView(file))
}
func (a *API) fileView(file model.File) gin.H {
return gin.H{"id": file.ID, "name": file.Name, "content_type": file.ContentType,
"size": file.Size, "md5": file.MD5, "url": file.URL}
}
func (a *API) fileURL(id *string) string {
if id == nil || !validID(*id) {
return ""
}
var file model.File
if a.db.Select("url").First(&file, "id = ?", *id).Error != nil {
return ""
}
return file.URL
}
func (a *API) userView(user model.User) gin.H {
return gin.H{"id": user.ID, "nickname": user.Nickname, "avatar_file_id": user.AvatarFileID,
"avatar_url": a.fileURL(user.AvatarFileID), "created_at": user.CreatedAt}
}
func (a *API) petView(pet model.Pet) gin.H {
var birthDate string
if pet.BirthDate != nil {
birthDate = pet.BirthDate.Format("2006-01-02")
}
return gin.H{"id": pet.ID, "name": pet.Name, "species": pet.Species, "breed": pet.Breed, "breed_id": pet.BreedID,
"gender": pet.Gender, "birth_date": birthDate, "weight_kg": pet.WeightKg,
"avatar_file_id": pet.AvatarFileID, "avatar_url": a.fileURL(pet.AvatarFileID),
"coat": pet.Coat, "allergies": pet.Allergies, "care_notes": pet.CareNotes,
"created_at": pet.CreatedAt}
}
@@ -0,0 +1,38 @@
package httpapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/config"
)
func TestLoginRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
New(nil, nil, &config.Config{}).Register(router)
for _, route := range router.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
t.Errorf("API route %q does not have /api/ prefix", route.Path)
}
}
for _, test := range []struct {
path string
want int
}{
{path: "/api/auth/login", want: http.StatusBadRequest},
{path: "/api/auth/wechat", want: http.StatusNotFound},
} {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(`{}`))
request.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, request)
if recorder.Code != test.want {
t.Errorf("POST %s: got %d, want %d", test.path, recorder.Code, test.want)
}
}
}
+197
View File
@@ -0,0 +1,197 @@
package httpapi
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) meetupView(m model.Meetup) gin.H {
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
return gin.H{"id": m.ID, "host_user_id": m.HostUserID, "title": m.Title, "description": m.Description, "place_name": m.PlaceName, "latitude": m.Latitude, "longitude": m.Longitude, "starts_at": m.StartsAt, "max_people": m.MaxPeople, "status": m.Status, "participants": count, "cover_file_id": m.CoverFileID, "cover_url": a.fileURL(m.CoverFileID)}
}
func (a *API) listMeetups(c *gin.Context) {
var rows []model.Meetup
if a.db.Where("starts_at > ? AND status = ?", time.Now().Add(-time.Hour), "open").Order("starts_at ASC").Limit(100).Find(&rows).Error != nil {
fail(c, 500, "读取约遛失败")
return
}
items := make([]gin.H, 0, len(rows))
for _, m := range rows {
items = append(items, a.meetupView(m))
}
ok(c, items)
}
func (a *API) getMeetup(c *gin.Context) {
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
view := a.meetupView(m)
var joins []model.MeetupParticipant
a.db.Where("meetup_id = ?", m.ID).Find(&joins)
people := make([]gin.H, 0, len(joins))
for _, j := range joins {
var pets []model.MeetupPet
a.db.Where("participant_id = ?", j.ID).Find(&pets)
ids := []string{}
for _, p := range pets {
ids = append(ids, p.PetID)
}
people = append(people, gin.H{"user_id": j.UserID, "pet_ids": ids})
}
view["people"] = people
ok(c, view)
}
type meetupInput struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
PlaceName string `json:"place_name" binding:"required"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
StartsAt time.Time `json:"starts_at" binding:"required"`
MaxPeople int `json:"max_people"`
PetIDs []string `json:"pet_ids" binding:"required"`
CoverFileID *string `json:"cover_file_id"`
}
func (a *API) createMeetup(c *gin.Context) {
var in meetupInput
if c.ShouldBindJSON(&in) != nil || strings.TrimSpace(in.Title) == "" || strings.TrimSpace(in.PlaceName) == "" || !in.StartsAt.After(time.Now()) || len(in.PetIDs) == 0 {
fail(c, 400, "约遛资料无效")
return
}
if in.MaxPeople == 0 {
in.MaxPeople = 8
}
if in.MaxPeople < 2 || in.MaxPeople > 100 {
fail(c, 400, "人数应为 2 到 100")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
if in.CoverFileID != nil && !a.fileExists(*in.CoverFileID) {
fail(c, 400, "封面文件无效")
return
}
m := model.Meetup{HostUserID: actor(c), Title: strings.TrimSpace(in.Title), Description: strings.TrimSpace(in.Description), PlaceName: strings.TrimSpace(in.PlaceName), Latitude: in.Latitude, Longitude: in.Longitude, StartsAt: in.StartsAt, MaxPeople: in.MaxPeople, Status: "open", CoverFileID: in.CoverFileID}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&m).Error; e != nil {
return e
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "发布失败")
return
}
created(c, a.meetupView(m))
}
func (a *API) joinMeetup(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请选择宠物")
return
}
for _, id := range in.PetIDs {
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "宠物无效")
return
}
}
var m model.Meetup
if !validID(c.Param("id")) || a.db.First(&m, "id = ?", c.Param("id")).Error != nil {
fail(c, 404, "约遛不存在")
return
}
if m.Status != "open" || !m.StartsAt.After(time.Now()) {
fail(c, 409, "约遛已关闭")
return
}
var count int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ?", m.ID).Count(&count)
if count >= int64(m.MaxPeople) {
fail(c, 409, "人数已满")
return
}
var existing int64
a.db.Model(&model.MeetupParticipant{}).Where("meetup_id = ? AND user_id = ?", m.ID, actor(c)).Count(&existing)
if existing > 0 {
fail(c, 409, "已经参加")
return
}
j := model.MeetupParticipant{MeetupID: m.ID, UserID: actor(c)}
err := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&j).Error; e != nil {
return e
}
for _, id := range in.PetIDs {
if e := tx.Create(&model.MeetupPet{ParticipantID: j.ID, PetID: id}).Error; e != nil {
return e
}
}
return nil
})
if err != nil {
fail(c, 500, "报名失败")
return
}
ok(c, a.meetupView(m))
}
func (a *API) leaveMeetup(c *gin.Context) {
var j model.MeetupParticipant
if !validID(c.Param("id")) || a.db.Where("meetup_id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&j).Error != nil {
fail(c, 404, "未参加")
return
}
var m model.Meetup
a.db.First(&m, "id = ?", j.MeetupID)
if m.HostUserID == actor(c) {
fail(c, http.StatusConflict, "发起人请取消约遛")
return
}
if a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Where("participant_id = ?", j.ID).Delete(&model.MeetupPet{}).Error; e != nil {
return e
}
return tx.Delete(&j).Error
}) != nil {
fail(c, 500, "退出失败")
return
}
ok(c, gin.H{"left": true})
}
func (a *API) cancelMeetup(c *gin.Context) {
if !validID(c.Param("id")) {
fail(c, 400, "ID 无效")
return
}
result := a.db.Model(&model.Meetup{}).Where("id = ? AND host_user_id = ? AND status = ?", c.Param("id"), actor(c), "open").Update("status", "cancelled")
if result.Error != nil || result.RowsAffected == 0 {
fail(c, 404, "约遛不存在或已关闭")
return
}
ok(c, gin.H{"status": "cancelled"})
}
+69
View File
@@ -0,0 +1,69 @@
package httpapi
import (
"os"
"regexp"
"testing"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
)
func TestDefaultNicknamePostgres(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
api := New(tx, nil, cfg)
seen := map[string]bool{}
for range 12 {
openID := "nickname-test:" + uuid.NewString()
user, err := api.findOrCreateUser(openID, "")
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^毛孩家长\d{4,6}$`).MatchString(user.Nickname) {
t.Fatalf("unexpected name %q", user.Nickname)
}
if seen[user.Nickname] {
t.Fatalf("duplicate name %q", user.Nickname)
}
seen[user.Nickname] = true
again, err := api.findOrCreateUser(openID, "不应覆盖的名字")
if err != nil {
t.Fatal(err)
}
if again.ID != user.ID || again.Nickname != user.Nickname {
t.Fatal("relogin changed existing user")
}
}
custom, err := api.findOrCreateUser("nickname-test:"+uuid.NewString(), "小豆的家长")
if err != nil {
t.Fatal(err)
}
if custom.Nickname != "小豆的家长" {
t.Fatal("custom nickname lost")
}
again, err := api.findOrCreateUser(custom.OpenID, "")
if err != nil {
t.Fatal(err)
}
if again.Nickname != custom.Nickname {
t.Fatal("relogin replaced custom nickname")
}
}
+172
View File
@@ -0,0 +1,172 @@
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"`
BreedID *string `json:"breed_id"`
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.BreedID = updated.BreedID
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
}
}
in.Breed = strings.TrimSpace(in.Breed)
if len([]rune(in.Breed)) > 128 {
fail(c, http.StatusBadRequest, "品种名称过长")
return pet, false
}
if in.BreedID != nil {
var breed model.Breed
if !validID(*in.BreedID) || a.db.First(&breed, "id = ?", *in.BreedID).Error != nil {
fail(c, http.StatusBadRequest, "品种不存在,请重新选择或手动填写")
return pet, false
}
// A disabled breed can be retained on an existing pet, but not newly selected.
if !breed.Enabled {
var current model.Pet
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ? AND breed_id = ?", c.Param("id"), actor(c), *in.BreedID).First(&current).Error != nil {
fail(c, http.StatusBadRequest, "该品种已停用,请手动填写或重新选择")
return pet, false
}
}
in.Breed = breed.Name
}
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),
BreedID: in.BreedID, 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})
}
+139
View File
@@ -0,0 +1,139 @@
package httpapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var errRefreshInvalid = errors.New("refresh credential invalid")
func refreshCredential() (string, error) {
data := make([]byte, 32)
if _, err := rand.Read(data); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func refreshHash(value string) string {
hash := sha256.Sum256([]byte(value))
return hex.EncodeToString(hash[:])
}
func (a *API) sessionPair(id, role, sessionID, refresh string, expires time.Time) (gin.H, error) {
token, err := a.sessionToken(id, role, sessionID)
if err != nil {
return nil, err
}
return gin.H{"token": token, "refresh_token": refresh, "expires_in": 7200, "expires_at": time.Now().Add(2 * time.Hour).Unix(), "refresh_expires_at": expires.Unix()}, nil
}
func (a *API) newSession(id, role string) (gin.H, error) {
raw, err := refreshCredential()
if err != nil {
return nil, err
}
expires := time.Now().Add(2 * time.Hour)
sessionID := uuid.NewString()
pair, err := a.sessionPair(id, role, sessionID, raw, expires)
if err != nil {
return nil, err
}
// Opportunistic cleanup bounded by indexed expiry, without a background scheduler.
if err := a.db.Where("expires_at <= ?", time.Now()).Delete(&model.RefreshSession{}).Error; err != nil {
return nil, err
}
err = a.db.Create(&model.RefreshSession{Base: model.Base{ID: sessionID}, ActorID: id, Role: role, TokenHash: refreshHash(raw), ExpiresAt: expires}).Error
return pair, err
}
func readRefresh(c *gin.Context) (string, bool) {
var in struct {
Token string `json:"refresh_token" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.Token) != 43 {
fail(c, http.StatusUnauthorized, "登录已过期,请重新登录")
return "", false
}
return in.Token, true
}
func (a *API) refreshToken(role string) gin.HandlerFunc {
return func(c *gin.Context) {
raw, valid := readRefresh(c)
if !valid {
return
}
var pair gin.H
err := a.db.Transaction(func(tx *gorm.DB) error {
var session model.RefreshSession
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).First(&session).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return errRefreshInvalid
}
if err != nil {
return err
}
if session.Revoked || !session.ExpiresAt.After(time.Now()) {
return errRefreshInvalid
}
if role == "user" {
var user model.User
err = tx.Select("id", "disabled").First(&user, "id = ?", session.ActorID).Error
if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && user.Disabled) {
return errRefreshInvalid
}
if err != nil {
return err
}
} else {
var count int64
if err := tx.Model(&model.Admin{}).Where("id = ?", session.ActorID).Count(&count).Error; err != nil {
return err
}
if count != 1 {
return errRefreshInvalid
}
}
next, err := refreshCredential()
if err != nil {
return err
}
nextExpiry := time.Now().Add(2 * time.Hour)
pair, err = a.sessionPair(session.ActorID, role, session.ID, next, nextExpiry)
if err != nil {
return err
}
// Rotate inside the lock. The old credential can only succeed once.
return tx.Model(&session).Updates(map[string]any{"token_hash": refreshHash(next), "expires_at": nextExpiry}).Error
})
if errors.Is(err, errRefreshInvalid) {
fail(c, 401, "登录已过期,请重新登录")
return
}
if err != nil {
fail(c, 500, "暂时无法刷新登录,请重试")
return
}
ok(c, pair)
}
}
func (a *API) revokeRefresh(role string) gin.HandlerFunc {
return func(c *gin.Context) {
raw, valid := readRefresh(c)
if !valid {
return
}
if err := a.db.Model(&model.RefreshSession{}).Where("token_hash = ? AND role = ?", refreshHash(raw), role).Update("revoked", true).Error; err != nil {
fail(c, 500, "退出登录失败")
return
}
ok(c, gin.H{"logged_out": true})
}
}
+190
View File
@@ -0,0 +1,190 @@
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/environment"
"github.com/sundynix/pets-be/internal/model"
"github.com/sundynix/pets-be/internal/storage"
)
type API struct {
env *environment.Client
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, env: environment.New(cfg.AMap.Key)}
}
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)
api.POST("/auth/refresh", a.refreshToken("user"))
api.POST("/admin/refresh", a.refreshToken("admin"))
api.POST("/auth/logout", a.revokeRefresh("user"))
api.POST("/admin/logout", a.revokeRefresh("admin"))
user := api.Group("")
user.Use(a.requireRole("user"))
user.GET("/environment", a.environment)
user.GET("/me", a.me)
user.GET("/breeds", a.listBreeds)
user.GET("/breeds/:id", a.breedDetail)
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.PUT("/tasks/completions", a.completeTasks)
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("/breeds", a.adminBreeds)
admin.POST("/breeds", a.createBreed)
admin.PUT("/breeds/:id", a.updateBreed)
admin.PUT("/breeds/:id/enabled", a.setBreedEnabled)
admin.GET("/stats", a.adminStats)
admin.GET("/users", a.adminUsers)
admin.GET("/users/:id/pets", a.adminUserPets)
admin.GET("/pets", a.adminPets)
admin.GET("/walks", a.adminWalks)
admin.GET("/walks/:id", a.adminWalkDetail)
admin.GET("/meetups", a.adminMeetups)
admin.GET("/tasks", a.adminTasks)
admin.GET("/files", a.adminFiles)
admin.PUT("/users/:id/disabled", a.adminSetUserDisabled)
admin.PUT("/meetups/:id/status", a.adminSetMeetupStatus)
}
type claims struct {
Role string `json:"role"`
SessionID string `json:"sid,omitempty"`
jwt.RegisteredClaims
}
func (a *API) token(id, role string) (string, error) { return a.sessionToken(id, role, "") }
func (a *API) sessionToken(id, role, sessionID string) (string, error) {
now := time.Now()
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims{Role: role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(), Subject: id, IssuedAt: jwt.NewNumericDate(now), ExpiresAt: jwt.NewNumericDate(now.Add(2 * 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
}
// Reject credentials issued with the old seven-day lifetime.
if cl.IssuedAt == nil || cl.ExpiresAt == nil || cl.ExpiresAt.Sub(cl.IssuedAt.Time) > 2*time.Hour {
fail(c, http.StatusUnauthorized, "登录已过期")
c.Abort()
return
}
if cl.SessionID != "" {
var count int64
if !validID(cl.SessionID) || a.db.Model(&model.RefreshSession{}).Where("id = ? AND actor_id = ? AND role = ? AND revoked = false AND expires_at > ?", cl.SessionID, cl.Subject, role, time.Now()).Count(&count).Error != nil || count != 1 {
fail(c, http.StatusUnauthorized, "登录已过期")
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
}
+287
View File
@@ -0,0 +1,287 @@
package httpapi
import (
"net/http"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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"`
AllPets bool `json:"all_pets"`
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 = ? OR pet_id IS NULL)", 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, "all_pets": task.PetID == nil, "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{}
var petID *string
if in.AllPets {
if in.PetID != "" {
fail(c, http.StatusBadRequest, "全部宠物与单只宠物不能同时选择")
return task, false
}
var count int64
if err := a.db.Model(&model.Pet{}).Where("user_id = ? AND archived = false", actor(c)).Count(&count).Error; err != nil {
fail(c, http.StatusInternalServerError, "读取宠物失败")
return task, false
}
if count == 0 {
fail(c, http.StatusBadRequest, "请先添加宠物")
return task, false
}
} else if _, err := a.ownedPet(actor(c), in.PetID); err != nil {
fail(c, http.StatusNotFound, "宠物不存在")
return task, false
} else {
petID = &in.PetID
}
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: 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 in struct {
Date string `json:"date" binding:"required"`
Completed bool `json:"completed"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "完成状态参数无效")
return
}
if !in.Completed {
fail(c, http.StatusBadRequest, "已完成的任务不能取消完成")
return
}
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
}
date, err := parseDay(in.Date)
if err != nil || !due(task, date) {
fail(c, http.StatusBadRequest, "该日期没有这项任务")
return
}
item := model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()}
if err := a.db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&item).Error; err != nil {
fail(c, http.StatusInternalServerError, "保存完成状态失败")
return
}
ok(c, gin.H{"id": task.ID, "date": in.Date, "completed": true})
}
// Complete the displayed tasks atomically, allowing safe retries and concurrent taps.
func (a *API) completeTasks(c *gin.Context) {
var in struct {
Date string `json:"date" binding:"required"`
TaskIDs []string `json:"task_ids" binding:"required,min=1,max=200"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, http.StatusBadRequest, "请选择 1 至 200 项任务")
return
}
date, err := parseDay(in.Date)
if err != nil {
fail(c, http.StatusBadRequest, "日期格式应为 YYYY-MM-DD")
return
}
ids := make([]string, 0, len(in.TaskIDs))
seen := map[string]bool{}
for _, id := range in.TaskIDs {
if !validID(id) {
fail(c, http.StatusBadRequest, "任务 ID 无效")
return
}
if !seen[id] {
ids = append(ids, id)
seen[id] = true
}
}
invalid := false
err = a.db.Transaction(func(tx *gorm.DB) error {
var tasks []model.CareTask
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ? AND user_id = ?", ids, actor(c)).Order("id").Find(&tasks).Error; err != nil {
return err
}
if len(tasks) != len(ids) {
invalid = true
return gorm.ErrRecordNotFound
}
items := make([]model.TaskCompletion, 0, len(tasks))
for _, task := range tasks {
if !due(task, date) {
invalid = true
return gorm.ErrRecordNotFound
}
items = append(items, model.TaskCompletion{TaskID: task.ID, UserID: actor(c), Date: date, CompletedAt: time.Now()})
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "task_id"}, {Name: "date"}}, DoNothing: true}).Create(&items).Error
})
if err != nil {
if invalid {
fail(c, http.StatusBadRequest, "包含不存在或该日期无需完成的任务")
} else {
fail(c, http.StatusInternalServerError, "完成任务失败,请重试")
}
return
}
ok(c, gin.H{"task_ids": ids, "date": in.Date, "completed": true})
}
+145
View File
@@ -0,0 +1,145 @@
package httpapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/config"
"github.com/sundynix/pets-be/internal/database"
"github.com/sundynix/pets-be/internal/model"
)
func TestTaskCompletionCannotBeUndone(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPut, "/completion", bytes.NewBufferString(`{"date":"2026-10-05","completed":false}`))
api := New(nil, nil, &config.Config{})
api.setTaskCompletion(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("undo status %d", rec.Code)
}
}
// All fixtures and writes are rolled back, including inner batch transactions.
func TestTaskCompletionPostgres(t *testing.T) {
if os.Getenv("PETS_ADMIN_INTEGRATION") != "1" {
t.Skip("PostgreSQL integration disabled")
}
cfg, err := config.Load()
if err != nil {
t.Fatal(err)
}
db, err := database.Open(cfg.Postgres, true)
if err != nil {
t.Fatal(err)
}
sqlDB, _ := db.DB()
defer sqlDB.Close()
tx := db.Begin()
if tx.Error != nil {
t.Fatal(tx.Error)
}
defer tx.Rollback()
// Verify the nullable pet association migration without changing live schema.
if err := tx.AutoMigrate(&model.CareTask{}); err != nil {
t.Fatal(err)
}
user := model.User{OpenID: "completion-test:" + uuid.NewString(), Nickname: "测试家长"}
if err := tx.Create(&user).Error; err != nil {
t.Fatal(err)
}
pet := model.Pet{UserID: user.ID, Name: "测试毛孩", Species: "dog", Gender: "male"}
if err := tx.Create(&pet).Error; err != nil {
t.Fatal(err)
}
tasks := []model.CareTask{{UserID: user.ID, PetID: &pet.ID, Title: "喝水", Category: "water", TimeOfDay: "08:00", RepeatRule: "daily"}, {UserID: user.ID, PetID: &pet.ID, Title: "喂食", Category: "food", TimeOfDay: "09:00", RepeatRule: "daily"}}
if err := tx.Create(&tasks).Error; err != nil {
t.Fatal(err)
}
api := New(tx, nil, cfg)
request := func(method, route, id string, body gin.H, handler gin.HandlerFunc) *httptest.ResponseRecorder {
t.Helper()
encoded, _ := json.Marshal(body)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Params = gin.Params{{Key: "id", Value: id}}
c.Request = httptest.NewRequest(method, route, bytes.NewReader(encoded))
handler(c)
return rec
}
allBody := gin.H{"all_pets": true, "title": "全部换水", "category": "water", "time_of_day": "10:00", "repeat_rule": "daily"}
rec := request("POST", "/tasks", "", allBody, api.createTask)
if rec.Code != 201 {
t.Fatalf("all-pet creation: %d %s", rec.Code, rec.Body.String())
}
var allTask model.CareTask
if err := tx.Where("user_id = ? AND title = ?", user.ID, "全部换水").First(&allTask).Error; err != nil || allTask.PetID != nil {
t.Fatalf("all-pet association should be null: %v", err)
}
// Single-pet filters include shared tasks; other users' tasks remain isolated.
rec = request("GET", "/tasks?date=2026-10-05&pet_id="+pet.ID, "", nil, api.listTasks)
var listing struct {
Data struct {
Items []struct {
ID string `json:"id"`
AllPets bool `json:"all_pets"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &listing); err != nil || rec.Code != 200 || len(listing.Data.Items) != 3 {
t.Fatalf("pet filter omitted shared task: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, gin.H{"pet_id": pet.ID, "title": "单只换水"}, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to single pet: %s", rec.Body.String())
}
if rec = request("PUT", "/tasks/"+allTask.ID, allTask.ID, allBody, api.updateTask); rec.Code != 200 {
t.Fatalf("switch to all pets: %s", rec.Body.String())
}
conflicting := gin.H{"all_pets": true, "pet_id": pet.ID, "title": "冲突选择"}
if rec = request("POST", "/tasks", "", conflicting, api.createTask); rec.Code != 400 {
t.Fatalf("ambiguous selection accepted: %s", rec.Body.String())
}
call := func(ids []string) int {
t.Helper()
body, _ := json.Marshal(gin.H{"date": "2026-10-05", "task_ids": ids})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Set("actor_id", user.ID)
c.Request = httptest.NewRequest(http.MethodPut, "/tasks/completions", bytes.NewReader(body))
api.completeTasks(c)
return rec.Code
}
if status := call([]string{tasks[0].ID, uuid.NewString()}); status != 400 {
t.Fatalf("invalid batch status %d", status)
}
var count int64
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 0 {
t.Fatal("invalid batch partially completed")
}
ids := []string{tasks[0].ID, tasks[1].ID}
for range 2 {
if status := call(ids); status != 200 {
t.Fatalf("batch status %d", status)
}
}
tx.Model(&model.TaskCompletion{}).Where("user_id = ?", user.ID).Count(&count)
if count != 2 {
t.Fatalf("retry duplicated completions: %d", count)
}
if status := call([]string{allTask.ID}); status != 200 {
t.Fatalf("all-pet completion status %d", status)
}
tx.Model(&model.TaskCompletion{}).Where("task_id = ?", allTask.ID).Count(&count)
if count != 1 {
t.Fatalf("shared task should have one completion: %d", count)
}
}
+254
View File
@@ -0,0 +1,254 @@
package httpapi
import (
"math"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/sundynix/pets-be/internal/model"
"gorm.io/gorm"
)
func (a *API) walk(c *gin.Context) (model.Walk, bool) {
var w model.Walk
if !validID(c.Param("id")) || a.db.Where("id = ? AND user_id = ?", c.Param("id"), actor(c)).First(&w).Error != nil {
fail(c, 404, "遛狗记录不存在")
return w, false
}
return w, true
}
func (a *API) walkView(w model.Walk) gin.H {
var links []model.WalkPet
var points []model.WalkPoint
a.db.Where("walk_id = ?", w.ID).Find(&links)
a.db.Where("walk_id = ?", w.ID).Order("recorded_at ASC").Find(&points)
ids := make([]string, 0, len(links))
for _, p := range links {
ids = append(ids, p.PetID)
}
return gin.H{"id": w.ID, "pet_ids": ids, "status": w.Status, "started_at": w.StartedAt, "ended_at": w.EndedAt, "paused_at": w.PausedAt, "paused_seconds": w.PausedSeconds, "duration_seconds": w.DurationSeconds, "distance_meters": w.DistanceMeters, "share_enabled": w.ShareEnabled, "share_token": func() string {
if w.ShareEnabled {
return w.ShareToken
}
return ""
}(), "share_expires_at": w.ShareExpiresAt, "points": points}
}
func (a *API) listWalks(c *gin.Context) {
var walks []model.Walk
if a.db.Where("user_id = ?", actor(c)).Order("started_at DESC").Limit(50).Find(&walks).Error != nil {
fail(c, 500, "读取记录失败")
return
}
items := make([]gin.H, 0, len(walks))
for _, w := range walks {
items = append(items, a.walkView(w))
}
ok(c, items)
}
func (a *API) getWalk(c *gin.Context) {
if w, yes := a.walk(c); yes {
ok(c, a.walkView(w))
}
}
func (a *API) startWalk(c *gin.Context) {
var in struct {
PetIDs []string `json:"pet_ids" binding:"required"`
}
if c.ShouldBindJSON(&in) != nil || len(in.PetIDs) == 0 {
fail(c, 400, "请至少选择一只宠物")
return
}
seen := map[string]bool{}
for _, id := range in.PetIDs {
if seen[id] {
fail(c, 400, "宠物重复")
return
}
seen[id] = true
if _, err := a.ownedPet(actor(c), id); err != nil {
fail(c, 400, "选择的宠物不存在")
return
}
}
var active int64
a.db.Model(&model.Walk{}).Where("user_id = ? AND status IN ?", actor(c), []string{"active", "paused"}).Count(&active)
if active > 0 {
fail(c, 409, "已有正在进行的遛狗")
return
}
now := time.Now()
w := model.Walk{UserID: actor(c), Status: "active", StartedAt: now, SegmentStartedAt: now, ShareToken: uuid.NewString()}
if err := a.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&w).Error; err != nil {
return err
}
for _, id := range in.PetIDs {
if err := tx.Create(&model.WalkPet{WalkID: w.ID, PetID: id}).Error; err != nil {
return err
}
}
return nil
}); err != nil {
fail(c, 500, "开始遛狗失败")
return
}
created(c, a.walkView(w))
}
func (a *API) addWalkPoint(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "遛狗未进行中")
return
}
var in struct {
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
}
if c.ShouldBindJSON(&in) != nil || math.Abs(in.Latitude) > 90 || math.Abs(in.Longitude) > 180 || (in.Latitude == 0 && in.Longitude == 0) {
fail(c, 400, "位置无效")
return
}
point := model.WalkPoint{WalkID: w.ID, Latitude: in.Latitude, Longitude: in.Longitude, RecordedAt: time.Now()}
var last model.WalkPoint
err := a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&last).Error
if err == nil && !last.RecordedAt.Before(w.SegmentStartedAt) {
d := haversine(last.Latitude, last.Longitude, in.Latitude, in.Longitude)
if d > 1000 {
fail(c, 400, "位置跳变过大")
return
}
if d < 5 {
ok(c, a.walkView(w))
return
}
w.DistanceMeters += d
}
if txErr := a.db.Transaction(func(tx *gorm.DB) error {
if e := tx.Create(&point).Error; e != nil {
return e
}
return tx.Model(&w).Update("distance_meters", w.DistanceMeters).Error
}); txErr != nil {
fail(c, 500, "保存位置失败")
return
}
ok(c, a.walkView(w))
}
func haversine(a, b, c, d float64) float64 {
const r = 6371000.
x1, x2 := a*math.Pi/180, c*math.Pi/180
dx, dy := (c-a)*math.Pi/180, (d-b)*math.Pi/180
h := math.Pow(math.Sin(dx/2), 2) + math.Cos(x1)*math.Cos(x2)*math.Pow(math.Sin(dy/2), 2)
return 2 * r * math.Asin(math.Sqrt(h))
}
func (a *API) pauseWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "active" {
fail(c, 409, "当前不能暂停")
return
}
now := time.Now()
w.Status = "paused"
w.PausedAt = &now
if a.db.Save(&w).Error != nil {
fail(c, 500, "暂停失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) resumeWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status != "paused" || w.PausedAt == nil {
fail(c, 409, "当前不能继续")
return
}
now := time.Now()
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
w.SegmentStartedAt = now
w.Status = "active"
if a.db.Save(&w).Error != nil {
fail(c, 500, "继续失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) finishWalk(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束")
return
}
now := time.Now()
if w.PausedAt != nil {
w.PausedSeconds += int(now.Sub(*w.PausedAt).Seconds())
w.PausedAt = nil
}
w.Status = "finished"
w.EndedAt = &now
w.DurationSeconds = int(now.Sub(w.StartedAt).Seconds()) - w.PausedSeconds
w.ShareEnabled = false
w.ShareExpiresAt = nil
if a.db.Save(&w).Error != nil {
fail(c, 500, "结束遛狗失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) setWalkShare(c *gin.Context) {
w, yes := a.walk(c)
if !yes {
return
}
if w.Status == "finished" {
fail(c, 409, "已结束的遛狗不能分享")
return
}
var in struct {
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&in) != nil {
fail(c, 400, "参数无效")
return
}
w.ShareEnabled = in.Enabled
if in.Enabled {
expire := time.Now().Add(4 * time.Hour)
w.ShareExpiresAt = &expire
w.ShareToken = uuid.NewString()
} else {
w.ShareExpiresAt = nil
}
if a.db.Save(&w).Error != nil {
fail(c, 500, "分享设置失败")
return
}
ok(c, a.walkView(w))
}
func (a *API) publicWalkLocation(c *gin.Context) {
var w model.Walk
if !validID(c.Param("token")) || a.db.Where("share_token = ? AND share_enabled = true AND status IN ? AND share_expires_at > ?", c.Param("token"), []string{"active", "paused"}, time.Now()).First(&w).Error != nil {
fail(c, http.StatusNotFound, "分享已失效")
return
}
var p model.WalkPoint
if a.db.Where("walk_id = ?", w.ID).Order("recorded_at DESC").First(&p).Error != nil {
fail(c, 404, "暂无位置")
return
}
ok(c, gin.H{"latitude": p.Latitude, "longitude": p.Longitude, "recorded_at": p.RecordedAt, "status": w.Status})
}
-77
View File
@@ -1,77 +0,0 @@
package middleware
import (
"strings"
"github.com/gin-gonic/gin"
"github.com/sundynix/pets-be/pkg/errcode"
appjwt "github.com/sundynix/pets-be/pkg/jwt"
"github.com/sundynix/pets-be/pkg/response"
)
// gin.Context 中存放身份的 key
const (
CtxUserID = "userID"
CtxAdminID = "adminID"
CtxName = "name"
)
func bearer(c *gin.Context) string {
h := c.GetHeader("Authorization")
if h == "" {
return ""
}
if strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return h
}
// AuthUser 小程序用户鉴权
func AuthUser(jm *appjwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := jm.Parse(bearer(c))
if err != nil || claims.Kind != appjwt.KindUser {
response.Abort(c, errcode.ErrUnauthized, "")
return
}
c.Set(CtxUserID, claims.ID)
c.Set(CtxName, claims.Name)
c.Next()
}
}
// AuthAdmin 后台管理员鉴权
func AuthAdmin(jm *appjwt.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
claims, err := jm.Parse(bearer(c))
if err != nil || claims.Kind != appjwt.KindAdmin {
response.Abort(c, errcode.ErrUnauthized, "")
return
}
c.Set(CtxAdminID, claims.ID)
c.Set(CtxName, claims.Name)
c.Next()
}
}
// UserID 从上下文取当前用户 ID
func UserID(c *gin.Context) string {
if v, ok := c.Get(CtxUserID); ok {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
// AdminID 从上下文取当前管理员 ID
func AdminID(c *gin.Context) string {
if v, ok := c.Get(CtxAdminID); ok {
if id, ok := v.(string); ok {
return id
}
}
return ""
}
-28
View File
@@ -1,28 +0,0 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
// CORS 开发期允许跨域(小程序无跨域限制,主要给后台 SPA dev server 用)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
c.Header("Access-Control-Allow-Origin", origin)
} else {
c.Header("Access-Control-Allow-Origin", "*")
}
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
c.Header("Access-Control-Allow-Credentials", "true")
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
-10
View File
@@ -1,10 +0,0 @@
package model
// Admin 后台管理员
type Admin struct {
Base
Username string `gorm:"size:64;uniqueIndex" json:"username"`
PasswordHash string `gorm:"size:128" json:"-"`
Role string `gorm:"size:32;default:admin" json:"role"`
Disabled bool `json:"disabled"`
}
-11
View File
@@ -1,11 +0,0 @@
package model
// AIMessage AI 聊天消息
type AIMessage struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" json:"pet_id"`
Session string `gorm:"size:64;index" json:"session"`
Role string `gorm:"size:8" json:"role"` // user / ai
Text string `gorm:"type:text" json:"text"`
}
-20
View File
@@ -1,20 +0,0 @@
package model
// AIQuotaConfig 每日 AI 次数上限(单例,固定 id=1)。
// AI 调用是真金白银,不设上限等于把钱包交给用户。
type AIQuotaConfig struct {
Base
Enabled bool `json:"enabled"` // 关掉即不限次
DailyChat int `json:"daily_chat"` // 问问 AI,每人每天
DailySymptom int `json:"daily_symptom"` // 异常观察评估
DailyPlan int `json:"daily_plan"` // AI 生成计划
}
// AIUsage 某人某天某类 AI 的用量。按天存,天然过期,不需要清理任务。
type AIUsage struct {
Base
UserID string `gorm:"size:24;uniqueIndex:idx_user_day_kind" json:"user_id"`
Day string `gorm:"size:10;uniqueIndex:idx_user_day_kind" json:"day"` // YYYY-MM-DD
Kind string `gorm:"size:16;uniqueIndex:idx_user_day_kind" json:"kind"`
Count int `json:"count"`
}
-13
View File
@@ -1,13 +0,0 @@
package model
// Article 新手知识文章
type Article struct {
Base
Icon string `gorm:"size:16" json:"icon"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
Content string `gorm:"type:text" json:"content"`
Category string `gorm:"size:32" json:"category"`
RelatedSheetType string `gorm:"size:32" json:"related_sheet_type"`
Published bool `gorm:"default:true" json:"published"`
}
-57
View File
@@ -1,57 +0,0 @@
package model
import (
"time"
"gorm.io/gorm"
"github.com/sundynix/pets-be/pkg/idgen"
)
// Base 所有模型的公共字段。主键为雪花算法字符串 ID(非自增)。
type Base struct {
ID string `gorm:"primaryKey;size:24" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// BeforeCreate 未显式赋值时自动生成雪花 ID(通过嵌入对所有模型生效)。
func (b *Base) BeforeCreate(tx *gorm.DB) error {
if b.ID == "" {
b.ID = idgen.Next()
}
return nil
}
// AllModels 需要 AutoMigrate 的模型清单(按依赖顺序)
func AllModels() []any {
return []any{
&User{},
&Pet{},
&HealthRecord{},
&RecordType{},
&Breed{},
&DailyTask{},
&Plan{},
&PlanTask{},
&Reminder{},
&Post{},
&Comment{},
&PostLike{},
&Article{},
&AIMessage{},
&ProMembership{},
&Admin{},
&DailyAdvice{},
&CareTemplate{},
&CareTemplateItem{},
&CommunityBotConfig{},
&File{},
&Feedback{},
&Follow{},
&RefreshToken{},
&AIQuotaConfig{},
&AIUsage{},
&MediaCheck{},
}
}
-26
View File
@@ -1,26 +0,0 @@
package model
// Breed 品种。原来建档时品种是个自由文本输入框,用户手打「柯基」「柯基犬」
// 「威尔士柯基」都算不同品种,后面想按品种做体重基准、常见病提示这类事就没法做。
type Breed struct {
Base
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Name string `gorm:"size:64" json:"name"`
// Initial 首字母,A-Z,用来做侧边索引。
//
// 存字段而不是运行时算:Go 标准库没有拼音,引一个拼音库只为了取首字母不值得,
// 而且多音字(「柴犬」的柴、「藏獒」的藏)自动转常出错——「藏獒」按 cang
// 还是 zang 分组,人来定比库来猜靠谱。后台新增品种时手填这一位。
Initial string `gorm:"size:1;index" json:"initial"`
// SizeClass 成年体型,只有犬类用:small(<10kg) / medium(10-25) / large(25-40) / giant(>40)。
//
// 生命阶段的判定要靠它。狗的发育完全取决于体型——大丹犬 8 个月还在长骨头,
// 吉娃娃 8 个月已经性成熟;大丹犬 7 岁进老年,吉娃娃 7 岁还是中年。
// 不能靠当前体重判断(幼犬体重说明不了成年后多大),只能靠品种。
SizeClass string `gorm:"size:16;index" json:"size_class"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
}
-77
View File
@@ -1,77 +0,0 @@
package model
// 养护方案:一套「这个物种、这个阶段该做哪些事」的配置。
// 后台可以给同一个 (species, stage) 配多套,建档时让用户挑一套套用,
// 也可以不套用、自己排。原来是一个组合只能有一套(唯一索引卡着),
// 「让用户选模版」这件事在数据模型上做不到。
// 物种取值
const (
SpeciesCat = "cat"
SpeciesDog = "dog"
)
// 明细的类别。原来还有个 task(今日任务),已经和 plan 合并——
// 「今天要做的事」就是「今天那一天的计划节点」,两套并存的结果是
// 用户在计划页加的「每天喂药」在首页看不到。
const (
CareKindPlan = "plan" // 计划节点(含每天/每周/每月重复)
CareKindReminder = "reminder" // 到期提醒(疫苗、驱虫这类有固定周期的)
)
// 重复规则。月度计划要的是「多久做一次」,不是「第几天做」——
// 原来只有 Day 偏移,月底建档的话 day=14/21/30 全落到下个月,
// 用户建完档打开计划页发现本月是空的。
const (
RepeatOnce = "once" // 一次性,按 Day 偏移(到家第 N 天,疫苗序列要用)
RepeatDaily = "daily" // 每天
RepeatWeekly = "weekly" // 每周某几天,看 Weekdays
RepeatMonthly = "monthly" // 每月某天,看 MonthDay
)
// CareTemplate 一套方案
type CareTemplate struct {
Base
Species string `gorm:"size:16;index" json:"species"` // cat | dog
Stage string `gorm:"size:48;index" json:"stage"` // 与 onboarding 阶段一致
// Name 方案名。建档时用户看到的就是这个,要能一眼分辨
// (「幼猫标准照护」/「幼猫省心版」)
Name string `gorm:"size:64" json:"name"`
// Summary 一句话摘要,选择列表里展示
Summary string `gorm:"size:255" json:"summary"`
Sort int `json:"sort"`
Enabled bool `gorm:"default:true" json:"enabled"`
Items []CareTemplateItem `gorm:"foreignKey:TemplateID" json:"items"`
}
// CareTemplateItem 明细,kind 区分两类;不同类只用到部分字段
type CareTemplateItem struct {
Base
TemplateID string `gorm:"size:24;index" json:"template_id"`
Kind string `gorm:"size:16;index" json:"kind"` // plan | reminder
Sort int `json:"sort"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
// plan 专用
Priority string `gorm:"size:16" json:"priority"` // 重要 / 普通(空)
SheetType string `gorm:"size:32" json:"sheet_type"` // 关联记录类型 code:weight/poop/bath...
// Repeat 列名用 repeat_rule:repeat 是 MySQL 保留字,GORM 虽然会加反引号,
// 但手写 SQL 排查时很容易踩(和 group_key 同样的理由)
Repeat string `gorm:"column:repeat_rule;size:16" json:"repeat"` // once | daily | weekly | monthly
Day int `json:"day"` // once:从套用日起第 N 天(0=当天)
// DayLabel 留着:后台的 AI 生成模板那条链路在产出它。月度计划里日期是绝对的
// 不需要「第 N 天」标签,但删它要连着改 AI 解析,不在这次范围内
DayLabel string `gorm:"size:32" json:"day_label"`
// Weekdays weekly 用:逗号分隔的 1-7(1=周一)。
// 存字符串不另开表——一条模板明细最多 7 个数字,开表换来的只是查询灵活性,
// 而这份数据从来只整条读出来展开
Weekdays string `gorm:"size:16" json:"weekdays"`
MonthDay int `json:"month_day"` // monthly 用:几号(1-31),超出当月天数按月末算
// reminder 专用
ReminderType string `gorm:"size:32" json:"reminder_type"` // vaccine/deworm/weight/monthlyReport
OffsetDays int `json:"offset_days"` // 到期 = 今天 + N 天;<=0 表示无固定到期,用频率
Frequency string `gorm:"size:64" json:"frequency"` // 频率描述,如「每周二、周五」
}
-23
View File
@@ -1,23 +0,0 @@
package model
import "gorm.io/datatypes"
// Comment 帖子评论
type Comment struct {
Base
PostID string `gorm:"size:24;index" json:"post_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
Content string `gorm:"size:512" json:"content"`
Images datatypes.JSON `json:"images"`
// 两级评论:ParentID 为空是一级评论,否则是某条一级评论下的回复。
// 不做无限层级——手机上层层缩进读到第四层就没法看了,
// 主流产品(微信、小红书、B站)都是两级 + @某人 表达指向。
ParentID string `gorm:"size:24;index" json:"parent_id"`
ReplyToName string `gorm:"size:64" json:"reply_to_name"`
Status string `gorm:"size:16;default:published" json:"status"`
IsSelf bool `gorm:"-" json:"is_self"` // 计算字段:是不是我自己发的
Replies []Comment `gorm:"-" json:"replies"` // 计算字段:一级评论下挂的回复
ReplyN int `gorm:"-" json:"reply_n"` // 回复总数(可能多于返回的条数)
}
-10
View File
@@ -1,10 +0,0 @@
package model
// CommunityBotConfig 社区 AI 运营配置(单例,固定 id=1)
type CommunityBotConfig struct {
Base
Enabled bool `json:"enabled"` // 是否开启每日自动生成
DailyCount int `json:"daily_count"` // 每天生成条数
StartHour int `json:"start_hour"` // 白天投放窗口起(含)
EndHour int `json:"end_hour"` // 白天投放窗口止(不含)
}
-9
View File
@@ -1,9 +0,0 @@
package model
// DailyAdvice 首页「今日建议」每宠每天缓存一条,避免重复调用模型
type DailyAdvice struct {
Base
PetID string `gorm:"size:24;uniqueIndex:idx_pet_day" json:"pet_id"`
Day string `gorm:"size:10;uniqueIndex:idx_pet_day" json:"day"` // YYYY-MM-DD
Text string `gorm:"type:text" json:"text"`
}
-17
View File
@@ -1,17 +0,0 @@
package model
import "time"
// DailyTask 今日任务
type DailyTask struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
TaskDate time.Time `gorm:"size:24;index" json:"task_date"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
Priority string `gorm:"size:16" json:"priority"` // "" / 重要
SheetType string `gorm:"size:32" json:"sheet_type"` // 点击打开的弹层类型
Done bool `json:"done"`
CompletedAt *time.Time `json:"completed_at"`
}
-11
View File
@@ -1,11 +0,0 @@
package model
// Feedback 用户意见反馈
type Feedback struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Nickname string `gorm:"size:64" json:"nickname"`
Content string `gorm:"type:text" json:"content"`
Contact string `gorm:"size:128" json:"contact"` // 可选,方便回访
Handled bool `gorm:"index" json:"handled"`
}
-12
View File
@@ -1,12 +0,0 @@
package model
// File 上传文件表:图片/头像等统一入库,按 MD5 去重,使用处以 file_id 关联。
type File struct {
Base
MD5 string `gorm:"size:32;uniqueIndex" json:"md5"` // 内容 MD5,唯一,用于去重
ObjectName string `gorm:"size:255" json:"object_name"` // MinIO 对象名
URL string `gorm:"size:512" json:"url"` // 可访问 URL
Size int64 `json:"size"`
ContentType string `gorm:"size:128" json:"content_type"`
Ext string `gorm:"size:16" json:"ext"`
}
-8
View File
@@ -1,8 +0,0 @@
package model
// Follow 关注关系:FollowerID 关注了 FolloweeID
type Follow struct {
Base
FollowerID string `gorm:"size:24;uniqueIndex:idx_follower_followee" json:"follower_id"`
FolloweeID string `gorm:"size:24;uniqueIndex:idx_follower_followee;index" json:"followee_id"`
}
-38
View File
@@ -1,38 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// 健康记录类型
const (
RecordWeight = "weight"
RecordPoop = "poop"
RecordFood = "food"
RecordSymptom = "symptom"
RecordMedicine = "medicine"
RecordVaccine = "vaccine"
RecordDeworm = "deworm"
RecordCost = "cost"
RecordPhoto = "photo"
)
// HealthRecord 统一健康/时间轴记录表,type 区分 9 类。
// NumValue 存体重/金额,Category 存消费类别/便便状态等,Extra 存各类型专有字段。
type HealthRecord struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
Type string `gorm:"size:16;index" json:"type"`
Icon string `gorm:"size:16" json:"icon"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
NumValue float64 `gorm:"type:decimal(10,2)" json:"num_value"`
Category string `gorm:"size:32" json:"category"`
ImageURL string `gorm:"size:512" json:"image_url"`
ImageFileID string `gorm:"size:24" json:"image_file_id"` // 关联 files 表:记录附图
Extra datatypes.JSON `json:"extra"`
OccurredAt time.Time `gorm:"size:24;index" json:"occurred_at"`
}
-17
View File
@@ -1,17 +0,0 @@
package model
// 图片异步安全检测的一次提交。mediaCheckAsync 返回 trace_id,
// 结果稍后由微信 push 回调,用 trace_id 找回它属于哪条帖子/评论。
const (
MediaPending = "pending"
MediaPass = "pass"
MediaRisky = "risky"
)
type MediaCheck struct {
Base
TraceID string `gorm:"size:64;index" json:"trace_id"`
TargetType string `gorm:"size:16;index" json:"target_type"` // post | comment
TargetID string `gorm:"size:24;index" json:"target_id"`
Status string `gorm:"size:16" json:"status"` // pending | pass | risky
}
+198
View File
@@ -0,0 +1,198 @@
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:"-"`
}
// RefreshSession stores only a hash of the rotating refresh credential.
type RefreshSession struct {
Base
ActorID string `gorm:"type:uuid;index;not null" json:"-"`
Role string `gorm:"size:16;index;not null" json:"-"`
TokenHash string `gorm:"size:64;uniqueIndex;not null" json:"-"`
ExpiresAt time.Time `gorm:"index;not null" json:"-"`
Revoked bool `gorm:"not null;default:false" json:"-"`
}
type Breed struct {
Base
LifeSpan string `gorm:"size:128" json:"life_span"`
Exercise string `gorm:"size:255" json:"exercise"`
Grooming string `gorm:"size:255" json:"grooming"`
EditorialLocked bool `gorm:"not null;default:false" json:"editorial_locked"`
FieldSources map[string]string `gorm:"serializer:json;type:jsonb" json:"field_sources"`
References []BreedReference `gorm:"foreignKey:BreedID" json:"references,omitempty"`
Origin string `gorm:"size:128" json:"origin"`
BreedGroup string `gorm:"size:255" json:"breed_group"`
FCINumber string `gorm:"size:16" json:"fci_number"`
Height string `gorm:"size:128" json:"height"`
Weight string `gorm:"size:128" json:"weight"`
Summary string `gorm:"type:text" json:"summary"`
Temperament string `gorm:"size:255" json:"temperament"`
Traits string `gorm:"type:text" json:"traits"`
CareNotes string `gorm:"type:text" json:"care_notes"`
EncyclopediaUpdatedAt string `gorm:"size:10" json:"encyclopedia_updated_at"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
EnglishName string `gorm:"size:128" json:"english_name"`
Aliases string `gorm:"size:512" json:"aliases"`
Pinyin string `gorm:"size:1024;index;not null" json:"pinyin"`
PinyinInitials string `gorm:"size:128" json:"pinyin_initials"`
Initial string `gorm:"size:1;index;not null" json:"initial"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
Source string `gorm:"size:128" json:"source"`
SourceKey *string `gorm:"size:128;uniqueIndex" json:"source_key"`
SourceURL string `gorm:"size:512" json:"source_url"`
}
// Facts contain short structured facts, never copied articles or remote images.
type BreedReference struct {
Base
BreedID string `gorm:"type:uuid;uniqueIndex:idx_breed_provider;not null" json:"breed_id"`
Provider string `gorm:"size:32;uniqueIndex:idx_breed_provider;not null" json:"provider"`
ExternalID string `gorm:"size:128;not null" json:"external_id"`
URL string `gorm:"size:512;not null" json:"url"`
RetrievedAt string `gorm:"size:10;not null" json:"retrieved_at"`
Facts map[string]string `gorm:"serializer:json;type:jsonb;not null" json:"facts"`
}
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:128" json:"breed"`
BreedID *string `gorm:"type:uuid;index" json:"breed_id"`
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" json:"pet_id"` // nil applies to all of the user's pets.
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{}, &RefreshSession{}, &Breed{}, &BreedReference{}, &Pet{}, &CareTask{}, &TaskCompletion{},
&Walk{}, &WalkPet{}, &WalkPoint{}, &Meetup{}, &MeetupParticipant{}, &MeetupPet{},
}
}
-35
View File
@@ -1,35 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// Pet 宠物,属于 User,支持多宠
type Pet struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
Name string `gorm:"size:64" json:"name"`
Emoji string `gorm:"size:16" json:"emoji"`
AvatarFileID string `gorm:"size:24" json:"avatar_file_id"` // 关联 files 表:宠物照片
AvatarURL string `gorm:"-" json:"avatar_url"` // 计算字段:由 AvatarFileID 解析
Type string `gorm:"size:16" json:"type"` // 猫猫 / 狗狗
Gender string `gorm:"size:16" json:"gender"` // 男孩 / 女孩 / 不确定
Birthday *time.Time `json:"birthday"`
ArrivedAt *time.Time `json:"arrived_at"` // 到家日期。30 天内会额外叠加一套安置期任务
Weight string `gorm:"size:16" json:"weight"` // 如 "2.8kg"
Stage string `gorm:"size:32" json:"stage"` // 年龄阶段:幼年期 / 成年期 / 老年期(由生日推断,可手改)
// Age 计算字段,不入库。原来是建档时算好存下来的字符串,
// 养半年后首页还显示「2个月」—— 存一个会过期的展示值本身就是 bug
Age string `gorm:"-" json:"age"`
Color string `gorm:"size:32" json:"color"` // 毛色,如 橘白 / 奶牛
Breed string `gorm:"size:64" json:"breed"` // 品种
HealthStatus string `gorm:"size:16;default:正常" json:"health_status"`
Goals datatypes.JSON `json:"goals"` // onboarding 目标多选
// —— 身份卡用的补充信息 ——
Personality string `gorm:"size:64" json:"personality"` // 性格标签,逗号分隔,如 "活泼,亲人,贪吃"
Allergy string `gorm:"size:128" json:"allergy"` // 过敏情况,没有就填「无」
Notes string `gorm:"size:255" json:"notes"` // 备注信息(照护提醒)
}
-44
View File
@@ -1,44 +0,0 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// 计划类型
const (
PlanThirtyDay = "thirty_day"
PlanAI = "ai"
)
// Plan 养宠计划(30 天路线图 / AI 计划)
type Plan struct {
Base
PetID string `gorm:"size:24;index" json:"pet_id"`
UserID string `gorm:"size:24;index" json:"user_id"`
Kind string `gorm:"size:16;index" json:"kind"` // thirty_day / ai
Stage string `gorm:"size:32" json:"stage"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
UserInput string `gorm:"size:512" json:"user_input"`
Extracted datatypes.JSON `json:"extracted"` // AI 提取信息
CompletionPct int `json:"completion_pct"`
Status string `gorm:"size:16;default:active" json:"status"` // active / completed / archived
Tasks []PlanTask `gorm:"foreignKey:PlanID" json:"tasks"`
}
// PlanTask 计划明细项
type PlanTask struct {
Base
PlanID string `gorm:"size:24;index" json:"plan_id"`
Day int `json:"day"`
DayLabel string `gorm:"size:32" json:"day_label"`
Title string `gorm:"size:128" json:"title"`
Description string `gorm:"size:512" json:"description"`
SheetType string `gorm:"size:32" json:"sheet_type"`
// Priority 从 DailyTask 合并过来的:首页那份「今天要做的事」要能标出哪条重要
Priority string `gorm:"size:16" json:"priority"`
Done bool `json:"done"`
Date string `gorm:"-" json:"date"` // 计算字段:计划开始日 + Day
}
-34
View File
@@ -1,34 +0,0 @@
package model
import "gorm.io/datatypes"
// 帖子状态
const (
PostPending = "pending" // 待审核:内容安全未通过/待人工,前端 feed 不展示
PostPublished = "published" // 已过审,正常展示
PostRejected = "rejected" // 内容安全判定风险,打回
PostHidden = "hidden"
PostDeleted = "deleted"
)
// Post 社区帖子
type Post struct {
Base
UserID string `gorm:"size:24;index" json:"user_id"`
PetID *string `gorm:"size:24" json:"pet_id"`
AuthorName string `gorm:"size:64" json:"author_name"`
AuthorEmoji string `gorm:"size:16" json:"author_emoji"`
Identity string `gorm:"size:16" json:"identity"` // petName / anonymous / official
Content string `gorm:"type:text" json:"content"`
Tags datatypes.JSON `json:"tags"`
Images datatypes.JSON `json:"images"` // 展示用:URL 数组(真实图)或 emoji 数组(机器人)
ImageFileIDs datatypes.JSON `json:"image_file_ids"` // 关联 files 表的 id 数组,读取时解析为 Images
LikeCount int `json:"like_count"`
CommentCount int `json:"comment_count"`
Status string `gorm:"size:16;default:published;index" json:"status"`
IsAI bool `gorm:"size:24;index" json:"is_ai"` // AI 运营生成的帖子,前端打「AI」标
// 以下为计算字段,按当前登录用户填充
Followed bool `gorm:"-" json:"followed"` // 我是否已关注该作者
IsSelf bool `gorm:"-" json:"is_self"` // 是不是我自己发的
}
-8
View File
@@ -1,8 +0,0 @@
package model
// PostLike 帖子点赞,(post_id, user_id) 唯一
type PostLike struct {
Base
PostID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"post_id"`
UserID string `gorm:"size:24;uniqueIndex:idx_post_user" json:"user_id"`
}
-21
View File
@@ -1,21 +0,0 @@
package model
import "time"
// Pro 会员状态
const (
ProActive = "active"
ProExpired = "expired"
ProNone = "none"
)
// ProMembership 会员,1:1 于 User
type ProMembership struct {
Base
UserID string `gorm:"size:24;uniqueIndex" json:"user_id"`
Status string `gorm:"size:16;default:none" json:"status"`
PlanType string `gorm:"size:16" json:"plan_type"` // yearly / monthly
Price float64 `gorm:"type:decimal(10,2)" json:"price"`
StartDate *time.Time `json:"start_date"`
EndDate *time.Time `json:"end_date"`
}

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