Files
observer/server/README.md
T
2026-08-26 18:37:24 +08:00

238 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 视野 后端服务
野生动物实时识别 App「视野」(Flutter 客户端见 `../flutter_app/`)的支付与授权后端。
## 功能模块
| 模块 | 说明 |
|---|---|
| 账号 | 手机号+密码注册/登录(`POST /api/v1/auth/register` / `login`),密码 bcrypt 哈希存储,登录签发自签名 token(`auth.secret`),授权绑定手机号账号、同账号多设备共享 |
| 订单 | 客户端 `GET /api/v1/plans` 拉取套餐价格 → `POST /api/v1/orders` 创建订单并返回支付参数(微信 prepay / 支付宝 orderStr);未支付订单 2 小时惰性关闭,同账号 2 小时内复用 created 订单防重复扣款 |
| 支付回调 | 微信支付 / 支付宝异步回调验签落授权(`/api/v1/payment` 分组,按渠道应答格式直返,不做统一包装) |
| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 |
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象,默认 qwen-image/DashScope 付费 API);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`DB 存元数据 + 标注 JSON |
| 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 |
| 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/models/<数据集>/latest.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK |
| 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/models/<数据集>/latest.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 |
| 标注 | **图片入库自动触发**:手动上传/AI 生成成功后,新增图自动调 `config.yml` `localAi` 节点配置的 AI 端点做 RF-DETR 全图扫描(`label_task` 记录进度,页顶进度条展示;**localAi 未配置 → 上传/生成接口直接报错;已有标注任务在跑(忙)→ 不报错**,当前任务成功完成后自动补标未标注图)→ 扫描结果(**重叠去重**:NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除——重叠比 = 交叠面积/两框较小面积,RF-DETR 同目标常输出一大一小两框,此判据能命中,同目标只留置信度最高者)**直接写 `dataset_image.labels_json`**(覆盖该图已有标注,即重标语义);点击弹窗放大后在 canvas 上画框/点框删除/清空/改类别 → 保存即整体覆写 `dataset_image.labels_json`YOLO 归一化 JSON 数组,AI 与人工框同存,人工可修改/清理);`POST /admin/label-tasks` 详情页「全量标注」按钮入口(另有自动触发),可发起全量/指定图重标;自动/手动/混合并存,训练前自动整理(prepare_yolo 逻辑在服务端) |
## 架构与数据流
```
Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发 token
├─► POST /orders (Bearer token) ──► 后端下单(校验 planId/channel
│ │
│◄───────┘ orderId + payParams
├─► 拉起微信/支付宝 SDK 支付
├─► 微信/支付宝服务端异步回调 ─► 验签 → 落授权(幂等,绑账号)
└─► POST /orders/{id}/confirm (Bearer token) ─► 幂等确认(回调已落授权则直接返回)
└─► GET /license (Bearer token) ─► 返回 {active, expiresAt}
```
- 授权以**服务端为准**:客户端本地缓存 `expiresAt` 仅用于主页展示,识别入口强制查询服务端
- 有效期语义:**自然日**(day 当天 24:00 失效;week/month 自生效日起自然日累计),服务端计算 `expiresAt`,客户端只读不判权
## 数据表
| 表 | 说明 | 关键字段 |
|---|---|---|
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num``plan_id``channel`(wechat/alipay)、`amount_cents``status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at``paid_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at``updated_at` |
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at``updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/` |
| `dataset` | 训练数据集 | `id`(PK)、`name`(UNIQUE)、`source`(manual/ai)、`image_count``labeled_count``status`(building/synced/labeled)、`cover`(封面文件名,UUID 命名 jpg,如 `9f2a...-xx.jpg`)、`description``created_at``updated_at`(图片文件在 `app.datasetDir`/`datasets/<name>/`,DB 只存元数据;AI 标注/训练机 SSH 配置走 `config.yml``localAi` / `training.ssh` 节点) |
| `dataset_image` | 数据集图片 | `id`(PK)、`dataset_id``filename``source`(manual/ai)、`prompt`(AI 生成图记录提示词)、`labels_json`(标注 JSON 数组:YOLO 归一化 xywh+类别+置信度,AI 自动标注与人工标注同存、人工可修改/清理,null/''/'[]'=未标注)、`created_at` |
| `model_training` | 训练任务 | `id`(PK)、`name``status`(running/success/failed)、`dataset`(训练机数据集名)、`imgsz`/`epochs`/`batch`/`device`(参数快照)、`current_epoch`/`total_epochs``metrics`(JSON)、`log_tail``pid``error``started_at`/`finished_at``created_at` |
| `model_version` | 模型版本(每数据集独立序列) | `id`(PK)、`dataset_id``version`(m1.0.0 递增, 同数据集 UNIQUE)、`training_id``artifact_file`(workspace 相对路径)、`metrics`(JSON)、`labels`(JSON 类别名数组)、`sha256``size_bytes``is_latest``notes``created_at`(模型文件不落表:发布即写 `models/<数据集名>/latest.tflite`,客户端固定下载,无存档回退) |
| `label_task` | 标注任务 | `id`(PK)、`dataset_id``filenames`(JSON 选中图片列表,NULL=全量)、`status`(running/done)、`total`/`done``created_at``finished_at` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
## API 清单
响应统一格式 `{"code": 0, "message": "ok", "data": {...}}``code != 0` 视为失败。接口只允许 GET / POST。
### POST /api/v1/auth/register
手机号注册(与登录无关的公开接口,无需 token)。请求:`{"phone": "13800000000", "password": "..."}`。已注册手机号返回错误。注册成功即创建账号(未充值,无额度)。
### POST /api/v1/auth/login
手机号登录。请求:`{"phone": "13800000000", "password": "..."}`。响应 `data: {"token": "..."}`HMAC 自签名,有效期 `auth.token_ttl`,默认 30 天)。后续登录态接口携带 `Authorization: Bearer <token>`
### GET /api/v1/plans
套餐列表(需 Bearer token)。响应 `data`
```json
{
"list": [
{"planId": "day", "days": 1, "priceCents": 1000},
{"planId": "week", "days": 7, "priceCents": 5600},
{"planId": "month", "days": 30, "priceCents": 18000}
]
}
```
套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置后重启服务生效);客户端不硬编码价格。展示名由客户端按 `days` 拼「N天」派生,无独立 label 字段。
### POST /api/v1/orders
创建订单,返回渠道支付参数(需 Bearer token,手机号由 token 解出)。
请求(JSON body):
```json
{"planId": "week", "channel": "wechat"}
```
- `channel``wechat` | `alipay`
- `planId``day` | `week` | `month`(需在 `config.yml` `plans` 节点存在,金额由服务端按配置锁定,客户端不可改价)
响应 `data`
```json
{
"orderId": "O20260822001",
"payParams": {
"partnerId": "1900xxxxx",
"prepayId": "wx...",
"nonceStr": "...",
"timeStamp": "1728000000",
"sign": "...",
"packageValue": "Sign=WXPay"
}
}
```
- `channel == wechat``payParams` 为微信 APP 支付统一下单返回参数(后端签名)
- `channel == alipay``payParams``{"orderStr": "alipay_sdk=..."}`(后端调 `alipay.trade.app.pay` 生成)
### POST /api/v1/orders/{orderId}/confirm
客户端支付成功后通知(幂等,需 Bearer token)。服务端以异步回调为准落授权,confirm 仅用于加速刷新;回调已到账时直接返回成功。
请求:`{}`(无参数)。响应:`data: {"status": "paid"|"created"}`
返回 `created`(回调未到账)时客户端按 3s → 10s → 30s 递增重试 confirm(最多 3 次),期间禁止重新下单;仍未成功回退 `GET /license` 查询(回调到账后自然变为 active)。回调为唯一授权来源,confirm 仅加速刷新。
### GET /api/v1/license
查询账号授权状态(需 Bearer token,手机号由 token 解出)。
响应 `data`
```json
{"active": true, "expiresAt": "2026-08-29T23:59:59+08:00"}
```
`active: false``expiresAt` 已过期(含未充值账号)→ 客户端展示付费墙/充值引导。
### 支付回调(微信/支付宝,勿直连)
| 渠道 | 路径 | 说明 |
|---|---|---|
| 微信支付(APP 支付 V3) | `POST /api/v1/payment/wechat/notify` | 解密+验签回调,`out_trade_no` → 落授权 |
| 支付宝(APP 支付) | `POST /api/v1/payment/alipay/notify` | RSA2 验签回调,`out_trade_no` → 落授权 |
### GET /api/v1/app/update
App 版本更新检查(公开接口,无需 token,未登录/旧版本均可访问)。返回服务器最新版本记录;无任何记录时 `data` 为空对象,客户端视为无需更新。
响应 `data`
```json
{
"version": "1.1.0",
"notes": "修复识别准确率问题"
}
```
- **仅 Android 客户端调用**iOS 不做版本下发,走 App Store 自行更新)
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
- 下载地址为固定静态路径:`/download/observer-latest.apk``app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问
- **模型热更新(与 APK 更新独立通道)**:服务器有已发布模型时响应额外返回 `models` 数组(与 `GET /api/v1/models` 同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl);客户端启动与 APK 更新**独立检查**——某数据集服务器版本高于本地已下载版本即下载 `/download/models/<数据集>/latest.tflite` 到应用私有目录,sha256 校验后原子替换,下次识别生效;**非强制**,失败回退旧模型下次启动重试。App 模型管理页列出服务器全部可用模型,用户自由下载/删除/启用;识别时加载全部已启用模型**并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底。无发布模型时不返回 models 字段(旧 App 忽略新字段、新 App 兼容旧服务器)
### GET /download-page
APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/download-page` 路径托管):微信内置浏览器会拦截 APK 下载,此页面按打开环境分流——**微信内打开**显示图形引导(点击右上角「···」→「在浏览器打开」+ 复制链接兜底);**手机浏览器打开**直接显示下载按钮,直链 `http://observer.redpowerfuture.com/download/observer-latest.apk`
### 管理端接口(`/api/v1/admin`,需请求头 `X-Admin-Token` = `config.yml admin.token`
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/admin/orders` | 订单列表,筛选参数 `phoneNum/status/startAt/endAt` + `page/size`,返回 `{total, list}` |
| GET | `/admin/licenses` | 账号/授权列表,筛选 `phoneNum` + `page/size`(含未充值账号) |
| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 |
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
| POST | `/admin/licenses/remark` | 写账号备注 `{"phoneNum":"13800000000","remark":"..."}`(空串清空,≤200 字) |
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`notes` + `file`APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
| POST | `/admin/datasets` | 创建数据集 `{"name":"pheasant_v2","source":"manual"\|"ai"}`(name ≤50 字唯一,目录自动建) |
| GET | `/admin/datasets` | 数据集列表:`page/size` 分页,返回 `{total, list}`(含 imageCount/labeledCount/status/cover/description/**training 聚合状态**:最新训练记录的 status/currentEpoch/totalEpochs |
| POST | `/admin/datasets/update` | 更新数据集配置 `{"id":1,"description":"...","cover":"a.jpg"}`:描述/封面,空值字段不覆盖原值 |
| POST | `/admin/datasets/cover` | 上传数据集封面(multipart`datasetId`+`file`jpg/jpeg/png ≤2MB):**自动转 jpg + UUID 命名**落盘并覆盖旧封面 |
| GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) |
| POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 |
| POST | `/admin/datasets/upload` | 上传图片(multipart/form-data`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets/<name>/`,逐张入库 |
| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行):调 `imageGen` provider(默认 qwen-image/DashScope)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 |
| GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) |
| GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) |
| POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) |
| GET | `/admin/datasets/export` | 导出数据集 zip`datasetId`,打包图片目录为 zip 下载(标注衔接用) |
| POST | `/admin/datasets/sync` | 同步数据集到训练机 `{"id":1}`:按 `training` 通道推送图片到训练机 `datasetDir/<name>/`subprocess 同机 cp、ssh 异机 scp/rsync);训练启动前自动执行 |
| POST | `/admin/label-tasks` | 发起预标注 `{"datasetId":1,"filenames":["a.jpg","b.jpg"]}`(详情页「全量标注」按钮入口,图片入库亦自动触发):**filenames 缺省=全量**,指定图则只扫描选中图(全量重标/单张修补);调 `config.yml` `localAi` 配置的 AI 端点 RF-DETR 逐张推理(common 池并行,未配置报错),扫描结果做**重叠去重**(NMS 风格按置信度降序保留,重叠比 > `localAi.overlapThreshold` 默认 0.3 的框剔除,同目标只留置信度最高者)后**直写 `dataset_image.labels_json`(重跑覆盖该图标注)**`label_task` 记录进度 |
| GET | `/admin/label-tasks` | 标注任务列表:`page/size` 分页(含 status/total/done |
| GET | `/admin/label-tasks/detail` | 标注任务详情:返回数据集全部图片 + 每张标注框(`boxes`YOLO 归一化 xywh + 置信度 + 类别) |
| POST | `/admin/label-tasks/save` | 保存单张标注 `{"datasetId":1,"filename":"a.jpg","boxes":[{"class":0,"cx":0.5,"cy":0.4,"w":0.1,"h":0.2}]}`:整体覆写该图 `labels_json`(空 boxes=清空标注),返回该数据集当前 `labeledCount` |
| POST | `/admin/trainings` | 发起训练 `{"dataset":"yolo","imgsz":704,"epochs":150,"batch":16,"device":"0","name":"..."}`:先同步数据集到训练机 → 校验目录存在 → runner 启动训练;**并发度 1**,已有 running 任务时返回错误 |
| GET | `/admin/trainings` | 训练任务列表:`page/size` 分页,按下发时间倒序,含 status/进度/指标 |
| GET | `/admin/trainings/detail` | 任务详情 `{"id":1}`:参数快照 + 进度 + 指标 + 日志尾部 |
| POST | `/admin/trainings/cancel` | 取消训练 `{"id":1}`(仅 running):杀训练进程,状态置 failed(记录 error |
| POST | `/admin/trainings/publish` | 发布为最新模型 `{"id":1,"notes":"..."}`(仅 success):tflite 拷为 `workspace/model-latest.tflite`(原子覆盖)+ sha256/大小 → 插入 `model_version`(版本号递增 m1.0.0 → m1.0.1+ 旧版 `is_latest=0` |
| GET | `/admin/label-workbench` | 标注工作台数据 `{"datasetId":1}`:数据集全部图片 + 每张标注框(`boxes`labels_json 全量)——无历史标注任务时详情页工作台的数据源 |
管理页面由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
### GET /api/v1/models
模型目录(需 Bearer token,客户端模型管理页拉取):返回**全部数据集当前生效模型**。响应 `data`
```json
{
"list": [
{"datasetId": 1, "datasetName": "pheasant", "version": "m1.2.0", "labels": ["pheasant", "suspect"],
"sizeBytes": 6400000, "sha256": "ab12...", "notes": "修复小目标漏检", "publishedAt": "2026-08-26T10:00:00+08:00",
"downloadUrl": "/download/models/pheasant/latest.tflite"}
]
}
```
- 只返回 `is_latest=1` 的模型(每数据集至多一条);无任何发布模型时 `list` 为空数组
- 下载地址由客户端拼 `apiBaseUrl` 访问;下载文件 sha256 校验,类别名数组 `labels` 用于多模型合并推理展示
## 使用说明
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`AI 生成图片 provider,默认 dashscope + apiKey + model qwen-image-3.0)、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化
2. `go build ./...` 编译验证
3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`
4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`Vite 代理 `/api``:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物)
5. Docker Compose 部署见 `Dockerfile` / `docker-compose.yml``./data` 运行时数据目录挂载持久化,容器重建不丢数据)
6. 客户端对接:`flutter_app/lib/config/app_config.dart``apiBaseUrl`、微信/支付宝 AppID 与 URL Scheme 替换为真实值
## 金额与安全约定
- 金额一律**整数分**`price_cents` / `amount_cents` 为 INT64),禁止浮点元;前端展示 ÷100
- 支付回调必须**验签**(微信 APIv3 验签/解密、支付宝 RSA2),回调处理**幂等**(`wx_trade_no`/`alipay_trade_no` 唯一约束,重复回调直接忽略)
- 授权落库以**较晚到期为准**:已有授权未过期时新套餐顺延叠加,禁止覆盖缩短