45 KiB
视野后端 技术设计
支付与授权后端的实现细节与技术决策。文档驱动:涉及本文档的决策变更须先改文档再写代码。
技术栈与分层
Go + GoFrame(分层规范见 CLAUDE.md):controller → service → dao,SQLite 单机存储,Docker Compose 单机部署(前后端一体单端口)。
- controller:
biz/controller,DTOg.Meta反射注册路由;支付回调组用/api/v1/payment前缀(不做统一响应包装,按渠道应答格式直接返回),客户端组用/api/v1前缀(统一响应包装) - service:
biz/service,订单创建、回调落授权、授权查询 - dao:
biz/dao,单表 CRUD,无业务逻辑 - 并发:回调处理为 IO 任务,可走
common池;SQLite 写一律回主 goroutine 串行(无 WAL)
套餐配置与数据表 DDL
套餐(静态定价)存 config.yml plans 节点,不建表(决策:套餐常年不变,改价 = 改配置重启,避免数据库表 + 管理端改价接口的维护成本;存量 plan 表由 v3 迁移 DROP)。展示名按 days 派生「N天」,无独立 label 字段。
plans:
- id: day
days: 1
price_cents: 1000
- id: week
days: 7
price_cents: 5600
- id: month
days: 30
price_cents: 18000
SQLite,金额整数分(INT64),时间存 ISO8601 文本(gtime.Time 序列化)。
CREATE TABLE IF NOT EXISTS payment_order (
order_id TEXT PRIMARY KEY, -- O + 时间戳 + 随机后缀,业务唯一键
phone_num TEXT NOT NULL, -- 下单账号(手机号),绑定授权归属
plan_id TEXT NOT NULL,
channel TEXT NOT NULL, -- wechat | alipay
amount_cents INTEGER NOT NULL, -- 下单时锁定套餐价格(快照,防套餐改价)
status TEXT NOT NULL DEFAULT 'created', -- created | paid | closed
wx_trade_no TEXT UNIQUE, -- 微信交易号,幂等唯一约束
alipay_trade_no TEXT UNIQUE, -- 支付宝交易号,幂等唯一约束
created_at TEXT NOT NULL,
paid_at TEXT
);
CREATE TABLE IF NOT EXISTS license (
phone_num TEXT PRIMARY KEY, -- 手机号账号,一账号一授权记录,续费更新
password TEXT NOT NULL, -- bcrypt 加盐哈希,不存明文
expires_at TEXT, -- 未充值 NULL(NULL/过期即未授权)
remark TEXT, -- 管理端备注(运营发卡/人工记录,客户端不可见)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE, -- 语义化版本号 x.y.z,客户端按数字段比较
notes TEXT, -- 更新说明(客户端弹窗展示)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
-- 无下载地址列:APK 为固定文件 app.apkDir/observer-latest.apk,上传即覆盖,目录永远只有一个文件
);
CREATE TABLE IF NOT EXISTS dataset (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE, -- 数据集名(≤50 字),同时是服务器目录名
source TEXT NOT NULL DEFAULT 'manual', -- manual | ai
image_count INTEGER NOT NULL DEFAULT 0, -- 图片数(冗余计数,随增删更新)
labeled_count INTEGER NOT NULL DEFAULT 0, -- 已标注数(二期标注任务完成累计)
status TEXT NOT NULL DEFAULT 'building',-- building | labeled | synced
cover TEXT, -- 封面文件名(上传自动转 jpg + UUID 命名,卡片展示)
description TEXT, -- 描述(卡片展示)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
-- 图片文件在 app.datasetDir/datasets/<name>/;DB 存元数据 + 标注(dataset_image.labels_json)
);
-- 存量库 v8 迁移:逐列检测补列(PRAGMA table_info),新库 CREATE 自带全列
CREATE TABLE IF NOT EXISTS dataset_image (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL, -- → dataset.id
filename TEXT NOT NULL, -- 唯一文件名(防重名加时间戳后缀)
source TEXT NOT NULL DEFAULT 'manual', -- manual | ai
prompt TEXT, -- AI 生成图记录提示词(追溯用)
labels_json TEXT, -- 标注 JSON 数组(YOLO 归一化 xywh+类别+置信度),AI 自动标注与人工标注同存(人工可修改/清理),null/''/'[]'=未标注
created_at TEXT NOT NULL,
UNIQUE (dataset_id, filename)
);
-- 存量库迁移:EnsureColumn 逐列检测补列(v9);candidates_json 列已随 v10 删除(DROP COLUMN);
-- model_version.model_file 列已随 v11 删除(模型无存档回退机制,只留 latest.tflite)
CREATE TABLE IF NOT EXISTS model_training (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 任务名(默认「数据集+时间」)
status TEXT NOT NULL DEFAULT 'running', -- running | success | failed
dataset TEXT NOT NULL, -- 训练机数据集名(datasetDir 下子目录名)
imgsz INTEGER NOT NULL DEFAULT 704,
epochs INTEGER NOT NULL DEFAULT 150,
batch INTEGER NOT NULL DEFAULT 16,
device TEXT NOT NULL DEFAULT '0',
current_epoch INTEGER NOT NULL DEFAULT 0,
total_epochs INTEGER NOT NULL DEFAULT 0,
metrics TEXT, -- JSON {p, r, map50}(成功后写)
log_tail TEXT, -- 日志尾部(截断 N KB,轮询更新)
pid INTEGER, -- 训练进程 pid(取消/存活探测用)
error TEXT, -- 失败原因
started_at TEXT NOT NULL,
finished_at TEXT,
created_at TEXT NOT NULL,
UNIQUE (dataset, imgsz, epochs, batch, started_at) -- 防止重复提交同参任务(宽松防呆)
);
CREATE TABLE IF NOT EXISTS model_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL, -- → dataset.id,**每个数据集独立模型版本序列**
version TEXT NOT NULL, -- m1.0.0 递增(每次发布 patch+1,同数据集内唯一)
training_id INTEGER, -- 来源训练任务 → model_training.id
metrics TEXT, -- JSON,与来源任务一致
labels TEXT NOT NULL, -- JSON 类别名数组(随模型下发,App 合并/展示用)
sha256 TEXT NOT NULL, -- tflite 文件校验
size_bytes INTEGER NOT NULL,
is_latest INTEGER NOT NULL DEFAULT 0, -- 1=该数据集当前生效(客户端拉取对象),每数据集至多一条
notes TEXT,
created_at TEXT NOT NULL,
UNIQUE (dataset_id, version)
);
CREATE TABLE IF NOT EXISTS label_task (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dataset_id INTEGER NOT NULL, -- → dataset.id
filenames TEXT, -- JSON 选中图片列表(多选批量标注);NULL = 全量扫描
status TEXT NOT NULL DEFAULT 'running', -- running | done
total INTEGER NOT NULL DEFAULT 0, -- 待标注图片数
done INTEGER NOT NULL DEFAULT 0, -- 已标注数
error TEXT, -- 失败原因(检测中途失败/服务重启中断)
created_at TEXT NOT NULL,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
CREATE INDEX IF NOT EXISTS idx_dataset_image_dataset ON dataset_image(dataset_id);
CREATE INDEX IF NOT EXISTS idx_model_training_status ON model_training(status);
存量库迁移以 PRAGMA user_version 版本化标记,禁止重复执行。迁移记录:
- v1 = 建表(各 dao init)+ 种子套餐(v1 已移除)
- v2 = 删除
license.plan_id列(该字段无业务语义,只留到期时间;PRAGMA table_info检测列存在才ALTER TABLE ... DROP COLUMN,新库与已迁移库跳过) - v3 =
DROP TABLE IF EXISTS plan(套餐改配置后清残留表,订单快照payment_order.plan_id不受影响) - v4 =
license加remark列(管理端备注;PRAGMA table_info检测列缺失才ALTER TABLE ... ADD COLUMN remark TEXT,新库直接建表跳过) - v5 =
app_version新表(版本管理;daoinitCREATE TABLE IF NOT EXISTS自动建,新库/存量库均无需user_version迁移,此处记录 DDL 变更) - v6 =
app_version删url列(下载地址改为固定文件app.apkDir/observer-latest.apk,表内不再记录;PRAGMA table_info检测列存在才ALTER TABLE ... DROP COLUMN url,新库建表已无此列直接跳过) - v7 =
dataset/dataset_image/model_training/model_version/label_task新表(模型训练体系;各daoinitCREATE TABLE IF NOT EXISTS自动建,此处记录 DDL 变更) - v8 =
dataset加 9 列(cover/description/ai_endpoint/ai_model/train_host/train_user/train_password/train_key)+label_task加filenames列(多选批量标注;PRAGMA table_info逐列检测缺失才ALTER TABLE ... ADD COLUMN,新库建表自带跳过)——其中 6 列(ai/train 覆盖字段)为遗留列:AI 标注/训练机 SSH 配置统一走config.yml(localAi/training.ssh),新代码不读写,存量库保留不迁移 - v9 = 标注存储从文件迁移入库:
dataset_image加labels_json/candidates_json两列(common.EnsureColumn迁移),启动时把历史labels/<数据集>/*.txt解析入labels_json(幂等:仅未迁移行处理),boxes.json废弃;历史 labels/ 目录与迁移代码已删除(2026-08-26:数据全部入表后无保留价值) - v10 = 标注流程简化(撤销候选确认两阶段):
ALTER TABLE dataset_image DROP COLUMN candidates_json(PRAGMA table_info检测列存在才 DROP,新库建表已无此列直接跳过;存量候选数据为空直接删)——AI 预标注结果直写labels_json,人工可修改/清理全部标注框 - v11 = 移除模型存档回退机制:
ALTER TABLE model_version DROP COLUMN model_file(模型文件不落表:发布即写trainings/<数据集名>.tflite,客户端固定下载) - v12 = 清理孤儿字段:
dataset删 6 列(ai_endpoint/ai_model/train_host/train_user/train_password/train_key,配置统一走config.yml的localAi/training.ssh,零读写)+model_version删artifact_file(zip 产物布局移除后无人写)+label_task删boxes_file(标注已入库,候选框文件机制废弃)——均PRAGMA table_info检测列存在才ALTER TABLE ... DROP COLUMN,新库建表已无此列自动跳过
全局训练配置(config.yml 直读)
决策(2026-08-26):AI 标注端点(localAi)与训练机 SSH 凭据(training.ssh)是全局配置、与数据集无关——不设独立存储(曾尝试 app_config KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 config.yml 的 localAi / training.ssh 节点,改配置需重启服务。数据集表原 6 个覆盖列(ai_endpoint 等)已随 v12 删除。
- AI 客户端:
common.LocalAiClient(ctx)直读localAi.baseUrl/model,未配置返回 nil(预标注接口报「标注服务未配置」) - SSH 凭据:
common/training_runner.go的 sshRunner 直读training.ssh.host/user/port/privateKeyPath/password,未配置 host 报「training.ssh 未配置 host」
账号体系(注册/登录)
授权与账号合一:license 表即账号表(决策:不建独立 user 表)——注册插一行(password=bcrypt 哈希,expires_at 为 NULL),充值/手动授权更新同一行。未充值账号存在但无额度,授权判断 expires_at > now(NULL 视为未授权)。下单购买的套餐记录在 payment_order.plan_id(订单快照),license 表不存套餐只存到期时间。
POST /api/v1/auth/register:{phone, password}→ 插入 license 行;phone 已存在报错;password 服务端 bcrypt 加盐哈希存储POST /api/v1/auth/login:{phone, password}→ bcrypt 校验 → 签发 token;登录查库必须绕过缓存(license 查询有 TTL 缓存,密码变更需即时生效)- token:HMAC-SHA256 自签名(payload = base64({phone, exp}) + 签名),secret 来自
config.ymlauth.secret,有效期auth.token_ttl(默认 30 天);无状态、服务端不存储,secret 轮换即全员下线 - 鉴权中间件
common.AuthRequired:校验Authorization: Bearer <token>→ 解出 phone 注入请求上下文;失败返回统一 401(code 61) - 需登录态接口:
GET /plans、POST /orders、POST /orders/{orderId}/confirm、GET /license(phone 一律从 token 解出,客户端不传)
有效期语义(自然日)
| 套餐 | 有效期 |
|---|---|
| day | 当天 24:00(服务端时区)失效 |
| week | 自生效日起第 7 天 24:00 失效 |
| month | 自生效日起第 30 天 24:00 失效 |
expiresAt一律由服务端按本机时区计算并下发,客户端只做「是否过期」判断,不参与计算- 续费叠加:新授权
expiresAt = max(现有 expiresAt, now) 起算套餐天数,即未过期时顺延,已过期时从当前时间起算;禁止覆盖缩短 - 单位与展示:服务端只下发
expiresAt(ISO8601,含时区),前端自行展示剩余时长
订单创建流程
POST /api/v1/orders校验 DTO(planId必填、channelin wechat/alipay)+ AuthRequired 解出的phoneNum- service:查
config.ymlplans 锁定price_cents(common.GetPlan,不存在报"套餐不存在或未配置")→ 生成orderId→ 插payment_order(status=created, phone_num)→ 调支付渠道统一下单 - 微信(APP 支付):V3 统一下单
appid传客户端 AppID → 响应参数(prepay_id、partnerId、nonceStr、timeStamp、sign)组装返回 - 支付宝:
alipay.trade.app.pay→ 返回orderStr - 下单失败:事务回滚订单(status=closed 或删除),返回错误
- 未支付订单回收:创建新订单前,惰性关闭该
phone_num下超时未支付的 created 订单(超时阈值默认 2 小时、可配置,对齐微信支付有效期,保证用户支付成功后回调必然可落授权);若该账号存在 2 小时内的 created 订单,直接复用返回,禁止重复创建(防重复扣款兜底)
支付回调与幂等
微信/支付宝回调是唯一授权来源,confirm 只是加速刷新;回调未到账时 confirm 不落授权。
并发与事务:回调落授权是「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」的读改写链路,微信/支付宝回调与 confirm 可能并发到达同一订单。整条链路必须由 service 在事务 + 单写者下串行执行:SQLite 无 WAL,并发写会 database is locked;串行化同时保证 expiresAt 只按一次现授权状态计算(叠加语义不被并发重复累加)。事务内写方法以 XxxInTx 形式实现,由 service 持有 tx 编排(见 CLAUDE.md 事务规范)。
微信(APIv3):POST /api/v1/payment/wechat/notify
- 验签:
Wechatpay-Timestamp/Nonce/Signature+ 平台证书 → 验签通过后 AES-GCM 解密 - 商户校验:解密后校验
appid/mchid与配置一致(防跨商户重放,对称支付宝的app_id/seller_id校验) - 落授权:
out_trade_no→ 查payment_order→ 更新status=paid+wx_trade_no→ 计算expiresAt写license - 幂等:
wx_trade_noUNIQUE 约束兜底,重复回调忽略(返回 200 空响应);订单状态已是 paid 时直接成功返回 - 应答:成功返回
{"code": "SUCCESS"},失败返回{"code": "FAIL", "message": ...}(微信会重试)
支付宝:POST /api/v1/payment/alipay/notify
- 验签:RSA2 验签 + 校验
app_id/seller_id与配置一致、total_amount与订单amount_cents(元)一致 - 落授权:同上;
alipay_trade_noUNIQUE 兜底 - 应答:验签失败返回
failure(支付宝会重试),成功返回success
客户端 confirm:POST /api/v1/orders/{orderId}/confirm(Bearer token)幂等 —— 订单已 paid 返回 {"status": "paid"};created 且无回调返回 {"status": "created"}。禁止在 confirm 里直接落授权。
confirm 轮询(客户端对接约定):回调通常 1~5 秒到账。confirm 返回 created 时,客户端按 3s → 10s → 30s 递增重试(最多 3 次,总计 ~43s),期间展示「支付确认中」并禁用重新下单;重试仍 created 则回到授权查询(GET /license),服务端回调到账后自然变为 active。防重复支付:客户端在订单闭环(confirm 返回 paid)前不得为同一套餐创建新订单;服务端兜底见「订单创建流程」第 6 条(2 小时内复用 created 订单)。
授权查询
GET /api/v1/license(Bearer token)
- AuthRequired 解出
phoneNum→ 查license:expires_at > now→{active: true, expiresAt};否则{active: false, expiresAt: null}(含未充值 NULL) - 查询走 DAO 缓存(
gdb.CacheOption,TTL 来自配置),落授权后必须清对应缓存(键license:{phoneNum}),否则「库里已改、查询还是旧值」
安全与风险
| 项 | 方案 |
|---|---|
| 回调伪造 | 微信 APIv3 证书验签+解密;支付宝 RSA2 验签,且校验金额/商户一致,禁止只验签不比对 |
| 账号体系 | 手机号+密码登录,密码 bcrypt 哈希存储;token 自签名(auth.secret 校验),手机号从 token 解出,客户端不可伪造;授权绑账号,同账号多设备共享 |
| 下单防刷 | 金额不由客户端传入(服务端按 planId 查 config.yml plans 锁定),客户端只能选套餐不能改价 |
| 订单号防猜测 | orderId 加随机后缀,长度 ≥ 20,不可枚举 |
| 回调重放 | 交易号唯一约束 + 订单状态机(created→paid 单向),paid 后忽略一切变更 |
| 网络异常 | 回调失败返回 FAIL/failure 触发渠道重试;客户端 confirm 失败可重试(幂等) |
客户端对接要点
- API 契约:
flutter_app/docs/PaymentApi.md(字段与本文档一致,以本文档为准) - 客户端配置替换项:
flutter_app/lib/config/app_config.dart(apiBaseUrl、wechatAppId、alipayAppId、URL Scheme / Universal Link) - 微信开放平台需注册 App 包名 + 签名(Android)与 Universal Link(iOS);支付宝开放平台注册 AppID + 密钥对
- 账号流程:首次进入 App → 注册/登录页(手机号+密码)→ token 存
flutter_secure_storage→ 主页展示到期时间;点击「识别」按钮强制调GET /license服务端校验(不走本地缓存),未到期才进摄像头,过期/未授权跳付费墙 - App 内授权缓存:客户端
flutter_secure_storage缓存expiresAt仅用于主页展示,识别入口一律服务端为准
后台管理端
代码在仓库根 server_admin/(Vue3 + Element Plus + Vite),构建产物输出到 server/admin_dist/ 由后端 /admin/ 路径托管(前后端一体单端口,SPA 未命中静态文件的 GET 回退 index.html)。开发期 Vite server.proxy 将 /api 代理到 :8080。
鉴权(静态 token):管理接口挂在 /api/v1/admin 组,common.AdminAuth 中间件校验请求头 X-Admin-Token 与 config.yml admin.token 一致,未配置或失配返回 401 统一错误格式({"code":401,"message":"管理端未授权","data":null})。前端登录页输入 token 后存浏览器 localStorage,请求拦截器自动携带;token 不内嵌构建产物,失配/失效时前端清 token 回登录页。token 属内网管理凭据,server_admin/.env* 已 gitignore(前端不再有 env token)。
接口清单(统一响应包装,/api/v1/admin 前缀):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /orders | 订单列表:phoneNum/status/startAt/endAt 筛选 + page/size 分页(size 上限 100),金额返回整数分 |
| GET | /licenses | 账号列表:phoneNum 筛选 + 分页(含未充值账号) |
| POST | /licenses/grant | 手动授权 {phoneNum, planId}:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存 |
| POST | /licenses/revoke | 撤销授权 {phoneNum}:清空 expires_at 保留账号行(不删密码),清缓存,客户端下次查询即 inactive |
| GET | /app-versions | 版本记录列表:分页(size 上限 100),按下发时间倒序 |
| POST | /app-versions | 下发新版本(multipart/form-data:notes + file APK):版本号从文件名识别,文件名须为 observer-x.y.z.apk(如 observer-1.0.1.apk,正则 ^observer-(\d+\.\d+\.\d+)\.apk$,格式不符拒绝)、notes ≤500、仅接受 .apk 文件;APK 覆盖保存 app.apkDir/observer-latest.apk(目录永远只有一个文件) |
| POST | /app-versions/delete | 删除版本 {id}(不存在报错):删最新版本时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404);删历史版本仅删记录、不动文件 |
结构决策:管理端是跨表业务面,controller 聚合在 biz/controller/admin.go(避免同一 controller 挂客户端组 + 管理组时 group.Bind 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → service/order.go,授权 grant/revoke/列表 → service/license.go),dto 聚合在 biz/model/dto/admin.go。套餐已配置化(common/plans.go 读取 config.yml,仅客户端登录组 GET /plans 使用),无独立分层文件,管理端不管理套餐。
手动授权语义:grant 等同免费发卡,与支付回调 persistPaid 共用自然日叠加规则——base = max(现有到期, now),expiresAt = base 所在日 0 点 + plan.days;已过期账号从当前时间起算。整条链路在 common.Serial() 单写者 + 事务内执行(SQLite 无 WAL),提交后清 license:{phoneNum} 缓存。revoke 清空授权字段、保留账号行(密码不删),同一事务内执行并清缓存。
分页约定:page ≥1(默认 1),size 1..100(默认 20),service 内钳制;返回 {total, list},total 为同条件总数(COUNT)。
强制更新
背景:客户端发版后旧版本用户无法感知新版本,bug 修复/安全更新需要强制覆盖。不走 config.yml(避免改配置重启才能下发),管理端页面上传 APK + 版本号维护 app_version 表,客户端启动时主动查询。仅 Android 参与(iOS 不做版本下发,用户从 App Store 自行更新)。
数据流:
管理端 POST /admin/app-versions(multipart:notes + APK 文件,文件名 observer-x.y.z.apk 识别版本号)
├─► app_version 表新增记录(version 从文件名解析,UNIQUE 防重复下发)
└─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不调用)
└─► 返回最新一条记录(无记录返回空对象)
└─► 客户端语义化比较 version > 本地版本?
└─► 是 → 全屏阻塞弹窗(禁返回,仅「立即更新」)→ 打开 <apiBaseUrl>/download/observer-latest.apk
└─► 否 → 正常进入
APK 存储与下载:
- 目录:
config.yml app.apkDir(默认./workspace/,与./data平级的运行时数据目录,docker-compose 已挂载持久化);服务启动时自动建目录 - 文件:固定文件名
observer-latest.apk(common.ApkFilename常量),上传流程「先落库 → 再保存临时文件 →os.Rename原子覆盖」,目录下永远只有最新一个文件;落库失败删临时文件、文件保存失败删记录(补偿),保证「记录存在 ⟺ 文件存在」 - 下载:后端静态托管
/download→app.apkDir,URL 固定/download/observer-latest.apk(绕过统一响应包装,纯二进制流) - 客户端打开方式:
url_launcher跳系统浏览器下载安装(避开应用内下载的 FileProvider / 安装权限复杂度)
设计决策:
- 检测到新版本即强制更新(无普通/强制之分):
version > 本地版本即全屏阻塞弹窗,用户必须跳转下载安装才能继续使用。简化管理端操作(不需要判断「这次要不要强制」),客户端语义单一(「有新版本 = 必须更新」) - 公开接口:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
- iOS 不检查:客户端
UpdateChecker.fetch()在非 Android 平台直接返回空(iOS 用户从 App Store 更新,应用内无法安装 APK,提示无意义) - 版本号语义化比较:
x.y.z三段按数字比较(1.10.0 > 1.9.9),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由文件名解析正则校验(observer-x.y.z.apk),DB 层UNIQUE兜底防重复 - 文件名识别版本号:管理端上传不再手工填写版本号,版本号从文件名解析(打包产物即
observer-<versionName>.apk命名)——消除「填的版本号与包内版本不一致」的人为错误,上传文件名即唯一事实来源 - 删除联动文件:APK 固定文件永远对应「最新一条记录」,故删除只对最新版本联动删文件(
记录存在 ⟺ 文件存在的删除方向:记录没了文件即失效,客户端不再提示、下载 404);删历史版本不动文件。执行顺序「先删记录、再删文件」——记录删除是核心操作,文件删除失败仅记日志不阻断(残留旧文件无害,客户端不会误提示更新)。不存在删除/修改接口(下发后版本号不可变),删除按id定位 - 写入串行:新增/删除记录均走
common.Serial()单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存 - 写入串行:新增记录走
common.Serial()单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
模型训练体系(数据集 → 训练 → 模型版本)
背景:识别模型(YOLOv8n → TFLite 704x704)此前在仓库根 training/ 目录人工命令行训练(本机 MPS / GPU 服务器跑 train_server.py,best.tflite 手动拷进 App assets 重打包 APK 下发)。本模块将「数据集管理 → 训练编排 → 模型版本发布 → 客户端热更新」闭环搬进后台管理端,模型迭代不再依赖开发者本机与 APK 发版。Python 训练脚本随项目整合进 server/training/(2026-08-26:能 Go 化的已 Go 化——prepare_yolo→prepareYoloSet、analyze_rfdetr→LocalAi.Detect,脚本删除;train_server.py/yolov8n.pt/调试工具迁移入 server/training/,yolov8n.pt 权重不进 git,由 .gitignore 排除;2026-08-26 再收口:inspect_tflite.py 并入 train_server.py 自检、dump_graph.py 保留)。
整体数据流:
管理端 数据集管理(上传 / AI 生成)→ 图片落服务器 workspace/datasets/<name>/
├─► 二期:标注工作台(RF-DETR 预标注 + 人工确认)→ labels/
└─► 发起训练 → 同步数据集到训练机 → runner 起训练 → 进度/日志/指标
└─► 产物(best.tflite + best.pt 归档)拉回服务器
└─► 发布 → model_version + workspace/model-latest.tflite(sha256)
└─► 三期:客户端启动 GET /app/update → 模型热更新下载替换
数据集存储与流转(决策:图片不进 DB、不提交 git)
- 图片目录:
app.datasetDir(默认./workspace/)下datasets/<数据集名>/(图片平铺,文件名唯一防重名);标注存dataset_image.labels_json(JSON 数组,每元素{class,cx,cy,w,h,confidence}归一化,AI 自动标注与人工标注同存、人工可修改/清理);历史 labels/ txt 目录与 yolo 暂存目录已删除(2026-08-26,标注全在 DB,训练集流式化不落本地盘) - 封面命名规范(2026-08-26):封面文件不得固定命名
cover,上传时解码为 jpg 格式 + UUIDv4 命名(common.UuidV4()生成,crypto/rand 无第三方依赖,落盘datasets/<name>/<uuid>.jpg,旧封面删除);DBcover列只存文件名,前端用 DB 值拼 URL 零改动;删除/校验按isCoverName正则(UUID v4 +.jpg后缀)识别,不认固定名——历史cover.jpg等存量封面启动时MigrateLegacyCovers幂等重命名迁移 - DB 只存文件名/来源/prompt 等元数据(
dataset/dataset_image),禁止图片进库 - 生成/上传图片是付费资产:删除接口必须带前端确认文案(提示 AI 生成图有成本);删除 = 删文件 + 删记录,目录清理
- 训练机与 Go 服务器可能异机:训练前按
training通道同步(subprocess 同机 cp、ssh 异机 scp);数据集在训练机上的权威路径training.workdir/training.datasetDir/<name>/,DBmodel_training.dataset只存数据集名
AI 生成图片(provider 抽象)
imageGen配置节点:provider: dashscope | localai,接口在common层抽象ImageGenProvider(Generate(ctx, prompt, size) ([]byte, error)),config 切换dashscope(通义万相付费 API):apiKey+model: qwen-image-3.0;内部自管 120s 轮询上限localai(本机/训练机 local-ai,POST {baseUrl}/v1/images/generations,OpenAI 兼容):baseUrl+model(local-ai 上加载的模型名,如 qwen-image)+timeoutSeconds(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为baseUrl的 host;step/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传baseUrl/apiKey缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
- 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟),超时与前端等待按此配置,一次建议 1 张
- 生成同步执行(count 1..8,每张超时
imageGen.timeoutSeconds,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则) - prompt 手填覆盖;留空走
imageGen.promptTemplate通用模板逐张组装(物种池 speciesByDataset 按数据集名、数量词=animalCount、场景/动作/光线池随机,每张独立组装保持多样性;物种只写名字不写羽毛细节,细节会拉近镜头);遵守项目提示词规范:不得包含目标位置描述(位置由模型自行推理,前端模板与校验文案落实此约束) - 生成图距离/数量校验(2026-08-28):表单填
distanceMin/Max(米) 与animalCount后逐张启用;RF-DETR 全图检测,目标数=检测框数,精确等于 animalCount(与标注同口径:suppressOverlap 去重后计数) - VLM 藏匿位补检(两阶段第二阶段):
/datasets/images/vlm-review单图接口;qwen3.8-9b(+mmproj) 以「图片+物种(生成 prompt 与物种池匹配)+场景光线线索+已确认框坐标(排除)」推理藏匿位,输出归一化 bbox JSON(范围校验+与已有框重叠去重),追加 ≤3 个 class=1 疑似框进同一 labels_json;VL 与 z-image 显存互斥(12G 装不下两者),批量补检在生成队列空闲后执行;qwen3.8-9b yaml 自带 mmproj 实为多模态模型(生成校验);自动标注框数按置信度降序裁剪 ≤ animal_count(存 dataset_image.animal_count 列),估算距离 = 物种体高 × focalPx ÷ 最大框高像素(focalPx=图高/2/tan(assumedVfovDeg/2),体高按物种查imageGen.speciesHeights,default 兜底;避免跨物种共用一个标定系数)(K=1.8:6%≈30m、4.5%≈40m,K 隐含镜头视场角假设,偏差大改配置);不合格丢弃重生成,单张上限 3 次(consts.GenValidateMaxAttempts),连续不合格整体报错(已合格张数保留)
训练通道(runner 抽象,决策:同机/异机不确定 → 可配置)
training:
mode: subprocess # subprocess | ssh
ssh: { host: "", user: "", port: 22, privateKeyPath: "", password: "" }
workdir: /opt/pheasant_data # 训练机工作目录
venvPython: /opt/pheasant_data/venv/bin/python
datasetDir: datasets # 训练机数据集根目录(相对 workdir,数据集为子目录)
concurrency: 1 # GPU 独占:同时仅一个 running,新任务排队
timeoutMinutes: 240 # 超时判死(started_at 起算;100 张实测约 3 分钟)
imgsz: 704 # 训练/导出分辨率(须与 App 端推理输入对齐)
epochs: 150 # 训练轮数(patience 30 早停,设大可自动停)
batch: 16 # 批大小(按训练机显存调整)
device: "0" # GPU 编号(cpu 用 cpu)
-
训练参数默认走配置(2026-08-26):imgsz/epochs/batch/device 不随管理端请求传(界面一键开始),由
training节点统一配置——device 取决于训练机硬件、imgsz 必须与端侧推理对齐、epochs 取决于算力预期,均为部署级参数;任务记录仍存各值(model_training.imgsz/epochs/batch/device)供列表展示 -
service 内
Runner接口:Start(ctx, *TrainingJob) (pid, error)/FetchLogTail(ctx, job)/IsAlive(ctx, job) bool/Cancel(ctx, job)/FetchArtifacts(ctx, job, destDir);subprocess与ssh两个实现,按 configmode选择;ssh 凭据直接读本节点training.ssh配置(见「全局训练配置」节) -
训练脚本(
server/training/train_server.py,随项目迁移):支持--task-json <file>(含 dataset/imgsz/epochs/batch/device/project 名),每 epoch 输出一行机器可读 JSON 到--log-file({"epoch":1,"total":150,"metrics":{...}}),结束写result.json(最终指标);Go 侧解析日志行更新进度、轮询日志尾部截断 N KB 存model_training.log_tail -
tflite 产物自检(2026-08-26):
inspect_tflite.py的 flatbuffer 解析逻辑内嵌进train_server.py(check_tflite),训练收尾定位best.tflite后自动校验并写result.json的tflite_check字段:{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]};校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1;ok=false(如 shape 漂移、导出异常)时 Go 侧在拉产物前直接置训练失败并带出 reason,杜绝坏产物进入发布链路;dump_graph.py保留作训练机人工深度调试 -
任务生命周期:
running → success/failed;取消 = 杀进程(ssh 模式远程 kill pid);超时无心跳判死;Go 服务重启后启动扫描 running 任务按 pid 存活探测(subprocess 本机、ssh 远程kill -0),进程已死则置 failed -
发起训练异步化(2026-08-27):发起请求仅做校验(数据集存在 /
prepareYoloSet有标注 / Serial 内并发检查)+ 落 running 记录即返回(毫秒级);训练机侧准备(写任务参数 → ssh tar 同步数据集 → 启动进程,耗时可达分钟级)在后台协程执行(context.Background(),与预标注runDetection同模式),任何一步失败经finishFailed置任务 failed 由列表/轮询呈现——此前同步执行超过管理端 axios 10s 超时,出现「任务已落库但前端报 timeout」的不一致 -
并发度 1:发起训练时若已有 running 任务返回错误「训练进行中」;训练任务不排队(简化,管理端人工再点一次)
-
产物拉取(2026-08-27 重构):成功后只拉
best.tflite直写服务器workspace/trainings/<数据集名>.tflite(原子覆盖,无 per-task 存档、不再打包 zip) -
写操作走
common.Serial()单写者(SQLite 无 WAL,与既有链路一致);任务状态更新(进度轮询)为高频写,单独小事务
模型版本(每数据集一个模型,多模型体系)
核心决策:每个数据集训练一个模型,模型按数据集独立版本化,App 多模型并行推理合并——用户按需下载若干数据集的模型,加载全部已下载模型共同推理标注(类别名不同则自然互补,同类名跨模型 NMS 去重)。
- 版本号规则:
m<major>.<minor>.<patch>,同一数据集内每次发布 patch+1(取该数据集最大版本号解析自增,无记录从 m1.0.0 起);UNIQUE(dataset_id, version)防重复 - 文件布局(2026-08-27 重构):
workspace/trainings/<数据集名>.tflite即当前生效模型唯一位——训练成功时从训练机直写(原子覆盖),客户端固定下载该文件;无<version>.tflite存档(2026-08-26 决策:不需要模型回退机制,模型只增不删不回滚);每数据集一个文件互不影响 - 类别名:发布时从训练任务/数据集记录类别(训练脚本 result.json 输出
names),存model_version.labels(JSON 数组),App 合并推理依赖它 - 发布(
POST /admin/trainings/publish):校验任务 success +trainings/<数据集名>.tflite存在 → 读文件算 sha256/size → 插model_version+ 该数据集旧版is_latest=0;文件已在训练成功时就位,发布仅落版本记录 - 管理端无模型管理界面(2026-08-26 决策):删
AdminListModels/AdminActivateModel/AdminDeleteModel三个管理接口,model_version表保留——仅支撑客户端下发目录;版本只增不删不回滚(发布即最新) - 模型目录(客户端拉取):
GET /api/v1/models(公开,登录态即可)返回所有数据集当前生效模型:{datasetId, datasetName, version, labels, sizeBytes, sha256, notes, publishedAt, downloadUrl};下载 URL/download/trainings/<数据集名>.tflite(复用/download静态托管,文件名含中文需 URL 编码)
标注工作台(依赖 local-ai 可达;2026-08-26 布局重构)
localAi配置节点:{ baseUrl: "http://127.0.0.1:18080", model: "rfdetr-xlarge", threshold: 0.08, confConfirmed: 0.2, overlapThreshold: 0.3 }——部署前提:RF-DETR 服务须从 Go 服务器可达(现跑在 Mac 上,部署时搬服务器/训练机;未配置时图片上传/生成接口直接报错——标注是强语义,不允许产生无标注图片;配置调不通则任务 failed,页顶错误条展示,不自动重试);端点为配置唯一来源,无运行时覆盖- 预标注自动触发(2026-08-26 决策,无手动按钮):手动上传/AI 生成图片入库成功后,自动对本次新增图发起标注;
localAi未配置 → 上传/生成接口直接报错(同步检查,生成场景在第一张生成前检查避免付费资产生成后标不了);已有 running 标注任务(忙)→ 不报错:上传/生成照常成功,新图由「任务成功完成后自动补标未标注图」机制兜底(每轮任务成功结束时检查该数据集未标注图,有则自动续一轮只标未标注图——失败任务不续,防配置坏时无限重试);service 逐张调 local-ai 推理(common 池并行,label_task记录 total/done 进度)→ 扫描结果(YOLO 归一化 xywh + 置信度 + 建议类别,conf≥confConfirmed 为 class 0)直写dataset_image.labels_json(重跑覆盖该图标注);重叠去重(2026-08-26 修订):NMS 风格、按置信度降序依次保留,与已保留框**重叠比(minIoU = 交叠面积/两框较小面积)>localAi.overlapThreshold(默认 0.3)**的框剔除(同目标被重复检出只留置信度最高者,跨 class 去重;人工画框不参与去重)——用 minIoU 而非 IoU:RF-DETR 对同一目标常输出一大一小两个框(标准 IoU 仅 0.3~0.5 会漏杀,实测红黄重叠即此形态),大框套小框时小框被覆盖比例高,minIoU 命中;相邻目标两框互有外露,minIoU 通常 < 0.3;全图扫描(项目既定规则:不套用生成规格的位置裁剪,候选宁多勿漏);不设候选确认两阶段(2026-08-26 撤销 candidates_json);POST /admin/label-tasks接口保留(详情页「全量标注」按钮,2026-08-26 恢复),可手动全量/指定图重标 - 详情页布局(无选项卡):分页(每页 20 条)逐行「原图 ‖ 标注图」对照;页顶标注任务进度条(自动触发任务进度,含错误展示);标注图 = 原图 + 标注框叠加(只读 canvas,与工作台同一绘制逻辑)
- 弹窗标注:点击原图/标注图打开放大弹窗(大 canvas,1152x2048 原尺寸),支持两点画框/点框删除/清空/类别切换(确认 0/疑似 1)/上下一张/保存——AI 自动标注与人工框同层可编辑(含清理 AI 框);保存即整体覆写
dataset_image.labels_json(JSON 数组,YOLO 归一化 xywh+类别+置信度,空=清空标注);未保存修改切换图片有确认 - 无历史标注任务时详情页工作台数据源为
GET /admin/label-workbench?dataset_id=(图片 + 全量标注框),避免依赖人工先跑一次任务 - 训练前自动整理(prepare_yolo 逻辑服务端,内存组装不落本地盘):数据集有 labels → 按 80/20 拆 train/val 生成 YOLO 训练集包(标注 txt 内存生成、原图只记数据集目录源路径)→ 随同步推训练机(subprocess 直写
workdir/datasetDir/yolo/<name>/;ssh 走 tar 流式管道本地不落盘);自动标注图直接进训练集(人工修改/清理后即为训练数据);本地无暂存目录(旧workspace/datasets/yolo/与labels/已删除)
模型热更新与多模型推理(客户端 Flutter)
GET /api/v1/app/update扩展:响应追加models数组(GET /api/v1/models同构:datasetId/datasetName/version/labels/sizeBytes/sha256/downloadUrl)——多模型目录,不再有单一 modelUrl;无发布模型时不返回该字段(旧 App 忽略、新 App 兼容旧服务器)- 模型管理:App 设置页「模型管理」——列出服务器全部可用模型 + 本地已下载状态(版本/大小/说明),用户自由下载/删除/启用;下载到应用私有目录
.tmp→ sha256 校验 → 原子替换;已下载模型跨启动持久 - 并行推理合并:
tflite_detector启动时加载全部已启用且已下载的模型(每模型独立 interpreter + 独立 isolate 线程并行推理);单帧结果按类别名合并 + 跨模型 NMS 去重(IoU 阈值 0.45,同类名才合并);类别名冲突以合并结果展示、标签文本随模型 labels - 内置模型兜底:assets
model.tflite+labels.txt作为无任何下载模型时的默认;有下载模型则并行加载下载模型(内置不重复加载,避免重复检测同类别) - 非强制:下载失败/校验不过删 tmp 用旧状态,下次启动重试;不弹阻塞窗
- 性能:N 模型并行推理墙钟 ≈ 单模型 1.2~1.5 倍(多核并行);用户按需下载控制 N;帧率不足时可降采样
- 版本比较规则同 APK(数字段比较,m 前缀剥离后按 x.y.z)
待办/风险
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走
config.yml占位 - iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案)
- 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:
config.yml admin.token失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git - 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计)
- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(
training.venvPython)、RF-DETR 服务从服务器可达(localAi.baseUrl)、图像生成服务可达(imageGen:dashscope 需外网 + apiKey,localai 需训练机 local-ai 可达 +imageGen.baseUrl)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路 - subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换
training.mode: ssh - 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),
workspace/已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理