Files
observer/server/技术设计.md
T
2026-08-28 14:31:13 +08:00

45 KiB
Raw Blame History

视野后端 技术设计

支付与授权后端的实现细节与技术决策。文档驱动:涉及本文档的决策变更须先改文档再写代码。

技术栈与分层

Go + GoFrame(分层规范见 CLAUDE.md):controller → service → daoSQLite 单机存储,Docker Compose 单机部署(前后端一体单端口)。

  • controllerbiz/controllerDTO g.Meta 反射注册路由;支付回调组用 /api/v1/payment 前缀(不做统一响应包装,按渠道应答格式直接返回),客户端组用 /api/v1 前缀(统一响应包装)
  • servicebiz/service,订单创建、回调落授权、授权查询
  • daobiz/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,                        -- 未充值 NULLNULL/过期即未授权)
  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 = licenseremark 列(管理端备注;PRAGMA table_info 检测列缺失才 ALTER TABLE ... ADD COLUMN remark TEXT,新库直接建表跳过)
  • v5 = app_version 新表(版本管理;dao init CREATE TABLE IF NOT EXISTS 自动建,新库/存量库均无需 user_version 迁移,此处记录 DDL 变更)
  • v6 = app_versionurl 列(下载地址改为固定文件 app.apkDir/observer-latest.apk,表内不再记录;PRAGMA table_info 检测列存在才 ALTER TABLE ... DROP COLUMN url,新库建表已无此列直接跳过)
  • v7 = dataset / dataset_image / model_training / model_version / label_task 新表(模型训练体系;各 dao init CREATE TABLE IF NOT EXISTS 自动建,此处记录 DDL 变更)
  • v8 = dataset 加 9 列(cover/description/ai_endpoint/ai_model/train_host/train_user/train_password/train_key+ label_taskfilenames 列(多选批量标注;PRAGMA table_info 逐列检测缺失才 ALTER TABLE ... ADD COLUMN,新库建表自带跳过)——其中 6 列(ai/train 覆盖字段)为遗留列:AI 标注/训练机 SSH 配置统一走 config.ymllocalAi / training.ssh),新代码不读写,存量库保留不迁移
  • v9 = 标注存储从文件迁移入库:dataset_imagelabels_json/candidates_json 两列(common.EnsureColumn 迁移),启动时把历史 labels/<数据集>/*.txt 解析入 labels_json(幂等:仅未迁移行处理),boxes.json 废弃;历史 labels/ 目录与迁移代码已删除(2026-08-26:数据全部入表后无保留价值)
  • v10 = 标注流程简化(撤销候选确认两阶段):ALTER TABLE dataset_image DROP COLUMN candidates_jsonPRAGMA 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.ymllocalAi/training.ssh,零读写)+ model_versionartifact_filezip 产物布局移除后无人写)+ label_taskboxes_file(标注已入库,候选框文件机制废弃)——均 PRAGMA table_info 检测列存在才 ALTER TABLE ... DROP COLUMN,新库建表已无此列自动跳过

全局训练配置(config.yml 直读)

决策(2026-08-26AI 标注端点(localAi)与训练机 SSH 凭据(training.ssh)是全局配置、与数据集无关——不设独立存储(曾尝试 app_config KV 表 + 管理端「训练配置」入口,2026-08-26 撤销):标注与训练直接读 config.ymllocalAi / 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 缓存,密码变更需即时生效)
  • tokenHMAC-SHA256 自签名(payload = base64({phone, exp}) + 签名),secret 来自 config.yml auth.secret,有效期 auth.token_ttl(默认 30 天);无状态、服务端不存储,secret 轮换即全员下线
  • 鉴权中间件 common.AuthRequired:校验 Authorization: Bearer <token> → 解出 phone 注入请求上下文;失败返回统一 401(code 61)
  • 需登录态接口:GET /plansPOST /ordersPOST /orders/{orderId}/confirmGET /licensephone 一律从 token 解出,客户端不传)

有效期语义(自然日)

套餐 有效期
day 当天 24:00(服务端时区)失效
week 自生效日起第 7 天 24:00 失效
month 自生效日起第 30 天 24:00 失效
  • expiresAt 一律由服务端按本机时区计算并下发,客户端只做「是否过期」判断,不参与计算
  • 续费叠加:新授权 expiresAt = max(现有 expiresAt, now) 起算套餐天数,即未过期时顺延,已过期时从当前时间起算;禁止覆盖缩短
  • 单位与展示:服务端只下发 expiresAt(ISO8601,含时区),前端自行展示剩余时长

订单创建流程

  1. POST /api/v1/orders 校验 DTOplanId 必填、channel in wechat/alipay+ AuthRequired 解出的 phoneNum
  2. service:查 config.yml plans 锁定 price_centscommon.GetPlan,不存在报"套餐不存在或未配置")→ 生成 orderId → 插 payment_order(status=created, phone_num) → 调支付渠道统一下单
  3. 微信(APP 支付):V3 统一下单 appid 传客户端 AppID → 响应参数(prepay_id、partnerId、nonceStr、timeStamp、sign)组装返回
  4. 支付宝:alipay.trade.app.pay → 返回 orderStr
  5. 下单失败:事务回滚订单(status=closed 或删除),返回错误
  6. 未支付订单回收:创建新订单前,惰性关闭该 phone_num 下超时未支付的 created 订单(超时阈值默认 2 小时、可配置,对齐微信支付有效期,保证用户支付成功后回调必然可落授权);若该账号存在 2 小时内的 created 订单,直接复用返回,禁止重复创建(防重复扣款兜底)

支付回调与幂等

微信/支付宝回调是唯一授权来源confirm 只是加速刷新;回调未到账时 confirm 不落授权。

并发与事务:回调落授权是「查订单 → 算 expiresAt → 更新订单 → 写 license → 清缓存」的读改写链路,微信/支付宝回调与 confirm 可能并发到达同一订单。整条链路必须由 service 在事务 + 单写者下串行执行:SQLite 无 WAL,并发写会 database is locked;串行化同时保证 expiresAt 只按一次现授权状态计算(叠加语义不被并发重复累加)。事务内写方法以 XxxInTx 形式实现,由 service 持有 tx 编排(见 CLAUDE.md 事务规范)。

微信(APIv3POST /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 → 计算 expiresAtlicense
  • 幂等:wx_trade_no UNIQUE 约束兜底,重复回调忽略(返回 200 空响应);订单状态已是 paid 时直接成功返回
  • 应答:成功返回 {"code": "SUCCESS"},失败返回 {"code": "FAIL", "message": ...}(微信会重试)

支付宝POST /api/v1/payment/alipay/notify

  • 验签:RSA2 验签 + 校验 app_id/seller_id 与配置一致、total_amount 与订单 amount_cents(元)一致
  • 落授权:同上;alipay_trade_no UNIQUE 兜底
  • 应答:验签失败返回 failure(支付宝会重试),成功返回 success

客户端 confirmPOST /api/v1/orders/{orderId}/confirmBearer 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/licenseBearer token

  • AuthRequired 解出 phoneNum → 查 licenseexpires_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.dartapiBaseUrlwechatAppIdalipayAppId、URL Scheme / Universal Link
  • 微信开放平台需注册 App 包名 + 签名(Android)与 Universal LinkiOS);支付宝开放平台注册 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-Tokenconfig.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-datanotes + 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-versionsmultipartnotes + APK 文件,文件名 observer-x.y.z.apk 识别版本号)
      ├─► app_version 表新增记录(version 从文件名解析,UNIQUE 防重复下发)
      └─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不调用)
      └─► 返回最新一条记录(无记录返回空对象)
      └─► 客户端语义化比较 version > 本地版本?
            └─► 是 → 全屏阻塞弹窗(禁返回,仅「立即更新」)→ 打开 <apiBaseUrl>/download/observer-latest.apk
            └─► 否 → 正常进入

APK 存储与下载

  • 目录:config.yml app.apkDir(默认 ./workspace/,与 ./data 平级的运行时数据目录,docker-compose 已挂载持久化);服务启动时自动建目录
  • 文件:固定文件名 observer-latest.apkcommon.ApkFilename 常量),上传流程「先落库 → 再保存临时文件 → os.Rename 原子覆盖」,目录下永远只有最新一个文件;落库失败删临时文件、文件保存失败删记录(补偿),保证「记录存在 ⟺ 文件存在」
  • 下载:后端静态托管 /downloadapp.apkDirURL 固定 /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.pybest.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.tflitesha256
                  └─► 三期:客户端启动 GET /app/update → 模型热更新下载替换

数据集存储与流转(决策:图片不进 DB、不提交 git)

  • 图片目录:app.datasetDir(默认 ./workspace/)下 datasets/<数据集名>/(图片平铺,文件名唯一防重名);标注存 dataset_image.labels_jsonJSON 数组,每元素 {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,旧封面删除);DB cover 列只存文件名,前端用 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>/DB model_training.dataset 只存数据集名

AI 生成图片(provider 抽象)

  • imageGen 配置节点:provider: dashscope | localai,接口在 common 层抽象 ImageGenProviderGenerate(ctx, prompt, size) ([]byte, error)),config 切换
    • dashscope(通义万相付费 API):apiKey + model: qwen-image-3.0;内部自管 120s 轮询上限
    • localai(本机/训练机 local-aiPOST {baseUrl}/v1/images/generationsOpenAI 兼容):baseUrl + model(local-ai 上加载的模型名,如 qwen-image+ timeoutSeconds(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为 baseUrl 的 hoststep/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传
    • baseUrl/apiKey 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」
  • 尺寸参数由前端传(固定竖版 704x1248,比例与既有训练图 1152x2048 一致、32 倍数对齐);实测竖版图在 local-ai 上极慢(约 26 分钟/张,Qwen-Image 行列注意力对非方形输入效率差,与分辨率关系不大;方形 1024x1024 仅 5 分钟),超时与前端等待按此配置,一次建议 1 张
  • 生成同步执行(count 1..8,每张超时 imageGen.timeoutSeconds,默认 120slocalai 建议 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_jsonVL 与 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)subprocessssh 两个实现,按 config mode 选择;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.pycheck_tflite),训练收尾定位 best.tflite 后自动校验并写 result.jsontflite_check 字段:{"ok":bool,"reason":string,"inputs":[{"name","shape","type"}],"outputs":[...]};校验规则 = 输入恰 1 张且 4 维、元素总数 == imgsz²×3(兼容 NCHW/NHWC)、输出 ≥ 1 张且 batch 维 = 1ok=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.labelsJSON 数组),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_jsonJSON 数组,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 venvtraining.venvPython)、RF-DETR 服务从服务器可达(localAi.baseUrl)、图像生成服务可达(imageGendashscope 需外网 + apiKeylocalai 需训练机 local-ai 可达 + imageGen.baseUrl)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路
  • subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换 training.mode: ssh
  • 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),workspace/ 已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理