从前端交互反推的 API、流式协议与时长账本计费设计 · Go 单体
设计系统(design/)确立的产品决策反推出以下后端变更:
| 项 | v0.1 | v1.0(本文档) |
|---|---|---|
| 计费模型 | 月/年订阅 + 每日免费 10 分钟 | 时长包(100min/¥9、500min/¥39、2000min/¥129,不过期)+ 每日免费试用 3 分钟 |
| 数据表 | plans / subscriptions | 废弃,改为 duration_packs / balance_ledger / trial_usage |
| WS 协议 | partial / final / error | 新增下行 usage 帧(实时余额),error code 枚举化 |
| API | 未细化 | 本文档第三章给出完整契约(与前端 UI 数据点一一对应) |
| 新增接口 | — | GET /v1/app/latest(托盘"检查更新")、GET /v1/packs(时长包服务端可配) |
| 反馈问题(v1.1) | — | POST /v1/feedback:文字 + 图片(OSS 存储)+ 诊断信息,见 3.5 |
| 客户端打点(v1.1) | — | POST /v1/metrics/batch 批量上报 + metric_events / metric_daily 表 + 每日聚合,见 3.6 |
总体架构、ASR Provider 抽象、技术栈(Go + gin + gorilla/websocket + PostgreSQL + Redis + wechatpay-go、阿里云与百炼同区域部署)沿用 v0.2 总体方案。
桌面端 Tauri / 移动端键盘扩展+主 App
│ HTTPS(auth/me/packs/orders) + WSS(音频流)
▼
┌──────────────────────────────────────────────────┐
│ Go 后端(单体,Docker 部署) │
│ auth │ gateway(WS 中继) │ billing │ quota │ user │
└──────────┬───────────────────────┬───────────────┘
│ WSS 同区域 │
▼ ▼
阿里云百炼 Gummy PostgreSQL + Redis + OSS
(流式大模型 ASR, (账本/订单 + 实时计数
Provider 可切火山豆包) + 反馈图片存储)
音频走后端中继的三个理由不变:① ASR 密钥不下发;② 按秒计费必须服务端计量;③ Provider 可切换、客户端协议不变。
REST + JSON,鉴权 Authorization: Bearer <JWT>(标 🔓 的无需登录)。统一错误体 {"code":"...","message":"..."}。
| 接口 | 说明 |
|---|---|
🔓 POST /v1/auth/qr |
桌面端发起扫码登录。响应 {"state":"...","qr_url":"https://open.weixin.qq.com/...&state=..."};state TTL 2 分钟(Redis) |
🔓 GET /v1/auth/qr/{state} |
桌面端轮询(1s 间隔)。响应 {"status":"pending"} → {"status":"confirmed","token":"<JWT>","user":{...}} / {"status":"expired"} |
🔓 GET /v1/auth/wechat/callback |
微信网页授权回调(扫码确认后),code 换 openid/unionid → 建号或关联 → 标记 state confirmed |
🔓 POST /v1/auth/wechat |
移动端:body {"code":"<OpenSDK code>"} → {"token":"<JWT>","user":{...}} |
POST /v1/auth/logout | JWT 进 Redis 黑名单 |
GET /v1/me
→ {
"user_id": "u_8f3k",
"nickname_masked": "wang***", // 前端直接展示,服务端脱敏
"balance_seconds": 28320, // 时长余额(秒),UI 显示 472 分钟
"trial_daily_limit": 180, // 每日免费试用上限(秒)
"trial_used_today": 60, // 今日已用(秒),UI 显示 1 / 3 分钟
"account_state": "ok" // ok | trial | quota(guest 由无 token 推得)
}
account_state 服务端判定:余额 > 0 → ok;余额 = 0 且试用未用尽 → trial;两者皆尽 → quota。键盘工具条与 MicBar 四态直接消费此字段。
GET /v1/packs // 🔓 服务端可配,前端不写死价格
→ { "packs": [
{ "id": "pack_100", "minutes": 100, "price_cents": 900, "unit_desc": "约 ¥0.09 / 分钟", "tag": "" },
{ "id": "pack_500", "minutes": 500, "price_cents": 3900, "unit_desc": "约 ¥0.078 / 分钟", "tag": "省 13%" },
{ "id": "pack_2000", "minutes": 2000, "price_cents": 12900, "unit_desc": "约 ¥0.065 / 分钟", "tag": "省 28%" }
]}
POST /v1/orders // 下单
body: { "pack_id": "pack_500", "channel": "native" | "app" }
→ native(桌面扫码): { "order_id": "o_123", "code_url": "weixin://wxpay/..." }
→ app(移动收银台): { "order_id": "o_123", "pay_params": { appid, partnerid, prepayid, ... } }
GET /v1/orders/{id} // 桌面端轮询"支付完成后自动开通"
→ { "status": "pending" | "paid" | "closed", "balance_seconds": 58320 } // paid 时带最新余额
POST /v1/pay/notify // 🔓 微信支付回调:验签 → 幂等入账(详见第五章)
GET /v1/app/latest?platform=mac|win|android|ios
→ { "version": "1.2.0", "notes": "…", "url": "https://dl.dudu.app/...", "force": false }
POST /v1/feedback // multipart/form-data,需登录
fields:
content 文字内容,1–1000 字(必填)
images[] 图片 ≤3 张,jpg/png/webp,单张 ≤5MB(可选)
diagnostics 诊断信息 JSON 字符串(可选):app_version / os_version /
platform / device_id / 最近错误摘要(不含识别文本)
→ 200 { "feedback_id": "fb_7k2m" }
→ 429 { "code": "FEEDBACK_RATE_LIMITED", "message": "今天反馈次数已达上限" }
fb/{feedback_id}/{n}.{ext},私有读),表中只存 key;MVP 量小不做客户端直传 STSnew → triaged → resolved;MVP 内部 SQL 查询处理,可选 webhook 通知到飞书/企微群POST /v1/metrics/batch // 🔓 可匿名(Content-Encoding: gzip)
body: { "device_id": "d-uuid", "platform": "mac", "app_version": "1.2.0",
"os_version": "macOS 15.5",
"events": [
{ "event": "asr.first_partial_ms", "props": { "ms": 312 }, "client_ts": 1765430400123 },
{ "event": "asr.session", "props": { "audio_seconds": 6, "partial_count": 14,
"cancel": false, "error_code": null }, "client_ts": 1765430406456 }
] }
→ 204(永不因业务原因报错,校验失败静默丢弃——打点绝不影响客户端)
metric_events(异步批量 INSERT,失败丢弃不重试);事件白名单(见前端文档 9.1 指标清单),未知事件名丢弃metric_daily,监控延迟目标(first_partial P95 < 500ms 等)超标告警;原始事件保留 90 天定期清理,量大后可平迁 ClickHouse长连接复用:客户端启动即连接 + ping/pong 保活,按键时零握手。一次按住-松开为一个识别会话(utterance),同连接串行多次会话。
| 帧 | 内容 |
|---|---|
| 文本 · start | {"type":"start","session_id":"c-生成uuid","sample_rate":16000,"format":"pcm16"};服务端校验配额,不足直接回 error(QUOTA_EXCEEDED) |
| 二进制 · 音频 | 16kHz / 16bit / mono PCM,每 100ms 一帧(3200 字节) |
| 文本 · stop | {"type":"stop","session_id":"..."}(松开);{"type":"cancel",...}(上滑取消:仍计量但丢弃结果) |
| 帧 | 内容 |
|---|---|
| partial | {"type":"partial","session_id":"...","text":"今天天气"} 增量结果,UI 实时刷新 |
| final | {"type":"final","session_id":"...","text":"今天天气不错。"} 句子定稿 |
| usage(新增) | {"type":"usage","session_id":"...","session_seconds":4,"balance_seconds":28316,"trial_remaining":120};识别中每 2s 及 stop 后下发,驱动托盘余额 / 键盘计时旁状态实时刷新 |
| error | {"type":"error","code":"...","message":"..."} |
| code | 语义 | 前端表现 |
|---|---|---|
| QUOTA_EXCEEDED | 试用 + 余额均已用尽 | 桌面浮层警告并引导购买;键盘转 quota 态 |
| UNAUTHORIZED | JWT 失效 / 被踢出 | 转未登录态,引导重新登录 |
| ASR_UNAVAILABLE | 上游 ASR 故障(已重试/切备用后仍失败) | "识别服务暂不可用,稍后再试" |
| SESSION_LIMIT | 单会话超长(上限 3 分钟自动截断定稿) | 自动结束并上屏已识别内容,提示"单次最长 3 分钟,松开后可继续" |
| RATE_LIMITED | 设备 30 分钟滑动窗口超限(会话 > 30 次或累计识别时长 > 30 分钟) | 提示"操作过于频繁,稍后再试" |
balance_ledger 只追加不修改(审计友好);users.balance_seconds 冗余列与账本在同一事务内更新,读路径走冗余列trial_usage(user_id, date, used_seconds),上限 180s,按服务端时区(Asia/Shanghai)自然日重置——重置即"当日无记录",无需定时任务trial_remaining + balance ≤ 0 → 拒绝并回 QUOTA_EXCEEDED客户端 后端 微信支付
│ POST /v1/orders │ │
│────────────────────▶│ 创建订单(pending) + 统一下单 │
│ │─────────────────────────────▶│
│ ◀ code_url/pay_params│◀────────────── prepay ─────│
│ (展示二维码/拉起收银台) │
│ │◀──── POST /v1/pay/notify ────│ 用户支付完成
│ │ 验签 → 事务{ 订单 pending→paid │
│ │ + ledger 入账 + 余额冗余列 } │
│ 轮询 GET /orders/:id │ → 应答微信 SUCCESS │
│ ◀ paid + 新余额 │ │
transaction_id 唯一约束去重;订单状态机只允许 pending→paid,重复回调直接应答成功users id PK · nickname · avatar_url · balance_seconds int (冗余) · created_at
wechat_identities id PK · user_id FK · openid UNIQUE · unionid INDEX · app_type(web|mobile)
duration_packs id PK · minutes · price_cents · unit_desc · tag · sort · active -- 服务端可配
orders id PK · user_id FK · pack_id FK · price_cents · channel(native|app)
· status(pending|paid|closed) · transaction_id UNIQUE NULL · paid_at · created_at
balance_ledger id PK · user_id FK · delta_seconds (+购买/赠送 −用量) · reason(purchase|usage|gift)
· order_id FK NULL · session_id NULL · created_at -- 只追加
trial_usage user_id+date PK · used_seconds -- 每日试用,自然日即键
asr_sessions id PK · user_id FK · device_id · audio_seconds · trial_part · balance_part
· provider · canceled bool · created_at -- 用量明细/排障
devices id PK · user_id FK · platform(mac|win|ios|android) · last_seen · app_version
-- v1.1 新增
feedbacks id PK · user_id FK · content text · images jsonb(OSS keys) · diagnostics jsonb
· platform · app_version · status(new|triaged|resolved) · created_at
metric_events id bigserial · user_id FK NULL · device_id · platform · app_version · os_version
· event · props jsonb · client_ts · received_at (BRIN) -- 保留 90 天定期清理
metric_daily date+event+platform PK · count · p50_ms · p95_ms -- 每日聚合任务写入
废弃 v0.1 的 plans / subscriptions / usage_records(后者并入 asr_sessions)。
users.balance_seconds == SUM(balance_ledger.delta_seconds),对账任务每日核对并告警。| Key | 类型 / TTL | 用途 |
|---|---|---|
quota:{uid}:balance | string(无 TTL,懒加载自 DB) | 余额秒数实时计数,Lua 原子扣减 |
quota:{uid}:trial:{yyyymmdd} | string / TTL 48h | 当日试用已用秒数,自然过期即重置 |
authqr:{state} | string(JSON) / TTL 120s | 扫码登录状态机 pending→confirmed(+JWT) |
jwt:block:{jti} | string / TTL=token 剩余有效期 | 登出 / 踢出黑名单 |
rate:{did}:asr:cnt | ZSET 滑动窗口 / 30min | 设备 30 分钟内会话次数(≤30 次) |
rate:{did}:asr:secs | ZSET 滑动窗口 / 30min | 设备 30 分钟内累计识别秒数(≤1800s) |
server/
cmd/server/main.go
internal/
auth/ 微信 OAuth(扫码 state 机 + OpenSDK code 换取)· JWT 签发/校验/黑名单
asr/ Provider 接口 + gummy / volcano 实现(StartSession/SendAudio/Results/Close)
gateway/ WS /v1/asr/stream:会话状态机 · 双泵中继 · 计量 · usage 帧下发
billing/ packs 查询 · 统一下单(native/app) · 回调验签幂等入账 · 查单对账任务
quota/ Redis Lua 原子扣减(试用桶→余额桶)· 异步落库 ledger · 一致性对账
user/ /v1/me 聚合(含 account_state 判定)· devices · app/latest
feedback/ 反馈接收 · 图片校验与 OSS 转存 · 限频 · webhook 通知(v1.1)
telemetry/ 打点批量接收(白名单/截断/异步入库)· 每日聚合任务 · 超标告警(v1.1)
store/ gorm models + migrations
pkg/protocol/ WS 帧结构 / 错误码常量 —— 5 端对齐的唯一真相源
go test ./...:quota Lua 脚本并发扣减、订单状态机幂等、扣费规则(先试用后余额、向上取整、扣穿当句不掐断)单测