Files
observer/server/README.md
T
2026-08-25 18:05:48 +08:00

187 lines
12 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 文件,删历史版本仅删记录 |
## 架构与数据流
```
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/` |
建表与迁移见 `技术设计.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` 访问
### 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);删历史版本只删记录不动文件 |
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
## 使用说明
1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;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` 唯一约束,重复回调直接忽略)
- 授权落库以**较晚到期为准**:已有授权未过期时新套餐顺延叠加,禁止覆盖缩短