## 目录

## 一、相对 v0.1 的变更摘要

设计系统（`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 可切换、客户端协议不变。

## 三、HTTP API 设计

REST + JSON，鉴权 `Authorization: Bearer <JWT>`（标 🔓 的无需登录）。统一错误体 `{"code":"...","message":"..."}`。

### 3.1 认证

接口 | 说明
--- | ---
🔓 `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 黑名单

### 3.2 用户与余额

    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 四态直接消费此字段。

### 3.3 时长包与订单

    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  // 🔓 微信支付回调：验签 → 幂等入账（详见第五章）

### 3.4 应用更新

    GET /v1/app/latest?platform=mac|win|android|ios
    → { "version": "1.2.0", "notes": "…", "url": "https://dl.dudu.app/...", "force": false }

### 3.5 反馈问题（v1.1 新增）

    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": "今天反馈次数已达上限" }

- 图片由后端校验（magic bytes 验真实格式）后转存阿里云 OSS（key：`fb/{feedback_id}/{n}.{ext}`，私有读），表中只存 key；MVP 量小不做客户端直传 STS
- 限频：每用户 10 条/天（Redis 计数）
- 处理流转：status `new → triaged → resolved`；MVP 内部 SQL 查询处理，可选 webhook 通知到飞书/企微群

### 3.6 客户端打点（v1.1 新增）

    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（永不因业务原因报错，校验失败静默丢弃——打点绝不影响客户端）

- 单批 ≤100 条、单条 props ≤2KB，超出截断；按 device_id + IP 限频（60 批/分钟）
- 带 JWT 时自动关联 user_id；匿名事件仅 device_id 维度
- 写入 `metric_events`（异步批量 INSERT，失败丢弃不重试）；事件白名单（见前端文档 9.1 指标清单），未知事件名丢弃
- 每日聚合任务：按 event × platform × date 计算 count / P50 / P95 写入 `metric_daily`，监控延迟目标（first_partial P95 < 500ms 等）超标告警；原始事件保留 90 天定期清理，量大后可平迁 ClickHouse

## 四、流式识别协议 v1.1（WSS /v1/asr/stream）

长连接复用：客户端启动即连接 + ping/pong 保活，按键时零握手。一次按住-松开为一个识别会话（utterance），同连接串行多次会话。

### 4.1 上行

帧 | 内容
--- | ---
文本 · 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",...}`（上滑取消：仍计量但丢弃结果）

### 4.2 下行

帧 | 内容
--- | ---
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":"..."}`

### 4.3 错误码

code | 语义 | 前端表现
--- | --- | ---
QUOTA_EXCEEDED | 试用 + 余额均已用尽 | 桌面浮层警告并引导购买；键盘转 quota 态
UNAUTHORIZED | JWT 失效 / 被踢出 | 转未登录态，引导重新登录
ASR_UNAVAILABLE | 上游 ASR 故障（已重试/切备用后仍失败） | "识别服务暂不可用，稍后再试"
SESSION_LIMIT | 单会话超长（上限 3 分钟自动截断定稿） | 自动结束并上屏已识别内容，提示"单次最长 3 分钟，松开后可继续"
RATE_LIMITED | 设备 30 分钟滑动窗口超限（会话 > 30 次或累计识别时长 > 30 分钟） | 提示"操作过于频繁，稍后再试"

### 4.4 中继实现（gateway）

- 每连接两个 goroutine：上行泵（客户端→Provider）/ 下行泵（Provider→客户端），零拷贝转发
- start 时从连接池取 / 新建 Provider 会话（Gummy DashScope WS）；Provider 断连对客户端透明重建
- 计量：网关按实收音频帧时长累计（100ms/帧），与 Provider 返回的时间戳交叉校验取小，杜绝多扣

## 五、计费模型：时长账本（核心变更）

### 5.1 模型

- 余额 = 账本合计：`balance_ledger` 只追加不修改（审计友好）；`users.balance_seconds` 冗余列与账本在同一事务内更新，读路径走冗余列
- 每日试用：`trial_usage(user_id, date, used_seconds)`，上限 180s，按服务端时区（Asia/Shanghai）自然日重置——重置即"当日无记录"，无需定时任务
- 不过期：购买入账无有效期字段，永久有效

### 5.2 扣费规则

1. 会话结束（stop/cancel/超时断连）按实收音频时长向上取整到秒
2. 顺序：先扣当日试用，余下扣余额（与文案"每天另有 3 分钟免费试用"一致）
3. start 时预检：`trial_remaining + balance ≤ 0` → 拒绝并回 QUOTA_EXCEEDED
4. 识别中每 2s 增量预扣 Redis 计数；若中途扣穿（余额归零）→ 当句允许说完（final 下发），随后回 QUOTA_EXCEEDED，避免说一半被掐断的体验
5. cancel（上滑取消）仍计量——音频已实际消耗 ASR 资源

### 5.3 支付入账时序

    客户端                 后端                          微信支付
      │ 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，重复回调直接应答成功
- 对账兜底：pending 超 5 分钟的订单主动调微信查单接口核对（定时任务），防回调丢失
- 桌面端"支付完成后自动开通"由订单轮询实现；移动端由支付回跳 + /v1/me 刷新实现

### 5.4 并发与一致性

- 实时计数在 Redis（Lua 脚本原子"检查+扣减"，先试用桶后余额桶），会话结束异步落库 ledger；崩溃恢复时以 ledger 为准重建 Redis
- 同一用户多设备并发识别：MVP 允许并发（计量各自独立、Redis 原子扣减保证不超扣），避免误伤 iPad + 手机场景；但单设备同时仅 1 路识别会话（见第九章识别限制）

## 六、数据表设计（PostgreSQL）

    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)`，对账任务每日核对并告警。

## 七、Redis Key 设计

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）

## 八、Go 模块结构

    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 端对齐的唯一真相源

## 九、安全与风控

- JWT：HS256（密钥从 Bitwarden 经部署注入），7 天有效 + 静默续签；jti 支持黑名单踢出
- 支付回调：wechatpay-go 平台证书自动验签；transaction_id 唯一约束防重放；金额与订单核对一致才入账
- 扫码 state：一次性、120s TTL、confirmed 后即删，防钓鱼复用
- 音频：不落盘、不留存（识别完即弃），隐私政策中声明；TLS 全链路
- 识别限制（设备维度，值均服务端可配）：
  - 单会话 ≤ 3 分钟，到点自动截断定稿上屏（SESSION_LIMIT，内容不丢，松开再按可继续）
  - 单设备同时仅允许 1 路活跃识别会话（杜绝并发多流烧量）
  - 设备 30 分钟滑动窗口：会话次数 ≤ 30 次 且累计识别时长 ≤ 30 分钟，超限回 RATE_LIMITED
  注：30 次/30 分钟对高频聊天用户（每条消息按一次）可能偏紧，上线后观察 metric_daily 中 RATE_LIMITED 触发率再调参——这正是限额做成服务端配置的原因
- ASR 容灾：Gummy 连续失败 N 次熔断切火山豆包 Provider，恢复后切回
- 反馈风控（v1.1）：每用户 10 条/天；图片 magic bytes 校验 + 大小/数量/格式白名单；OSS bucket 私有读，仅内部处理端可访问
- 打点边界（v1.1）：事件白名单 + 单条 2KB 截断 + device_id/IP 限频；不采集识别文本与音频；匿名 device_id 与账号体系隔离，隐私政策中声明用途

## 十、验证方式

- `go test ./...`：quota Lua 脚本并发扣减、订单状态机幂等、扣费规则（先试用后余额、向上取整、扣穿当句不掐断）单测
- WS 集成测试：脚本推送 wav 切片模拟音频流，断言 partial → final → usage 序列与扣费结果
- 支付：微信沙箱 + 构造回调报文测试验签 / 幂等 / 金额不符拒绝
- 配额场景：余额 0 + 试用剩 10s → 说 15s，断言试用扣满 180s 上限内、余额不变、最终 QUOTA_EXCEEDED 时机正确
- 对账：随机注入 Redis/DB 偏差，验证对账任务发现并告警
- 识别限制：单会话推流到 3 分钟断言自动截断且 final 正常下发；同设备第 2 路并发 start 被拒；30 分钟窗口构造第 31 次会话 / 累计超 1800s 断言 RATE_LIMITED
- 反馈（v1.1）：multipart 上传 3 张图断言 OSS 落盘与表记录；第 4 张 / 6MB 图 / 伪造扩展名被拒；第 11 条触发 429
- 打点（v1.1）：gzip 批量上报断言异步入库；未知事件名/超长 props 被丢弃或截断且仍返回 204；聚合任务对构造数据算出正确 P50/P95