Files
dudu/doc/backend-architecture.html
T
wangjia 40760aa884
ci / server (push) Failing after 14s
ci / design-tokens (push) Failing after 11s
dudu MVP:五端语音输入法初始提交
- server:Go 网关(WS 流式识别中继/计费配额/微信登录支付 mock/反馈/埋点),gummy provider 已真实联调
- desktop:Tauri 2(全局快捷键 push-to-talk/浮层/托盘/设置/登录购买/反馈/首启引导)
- android:Compose 主 App + IME(键盘内录音直传)
- ios:App + 键盘扩展(1A spike 实证键盘内不可录音,走 deep link 听写)
- design/design-pipeline:设计系统 + token 导出 iOS/Android 主题
- doc:前后端设计文档(HTML);web:官网宣传页;todo:任务看板

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 00:38:37 +08:00

413 lines
27 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>dudu 后端与数据架构 v1.1</title>
<style>
:root {
--accent: #4F6EF7;
--accent-text: #3D58DB;
--accent-soft: #EEF1FE;
--bg: #F6F7FA;
--card: #FFFFFF;
--text-1: #1B1E26;
--text-2: #5A6072;
--text-3: #A6ABB8;
--border-1: #E4E6EB;
--positive: #16A34A;
--warning: #E8890C;
--danger: #DC2626;
--code-bg: #1B1E26;
--code-text: #D6DCEA;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg); color: var(--text-1); line-height: 1.75;
}
.container { max-width: 980px; margin: 0 auto; padding: 0 24px 80px; }
header.hero { background: var(--accent); color: #fff; padding: 48px 24px 40px; text-align: center; }
header.hero h1 { margin: 0 0 6px; font-size: 30px; }
header.hero p { margin: 0; opacity: .85; font-size: 15px; }
.chips { margin-top: 16px; }
.chip { display: inline-block; padding: 3px 14px; margin: 3px; background: rgba(255,255,255,.16); border-radius: 999px; font-size: 13px; }
section.card {
background: var(--card); border: 1px solid var(--border-1); border-radius: 14px;
padding: 28px 32px; margin-top: 24px; box-shadow: 0 1px 3px rgba(17,19,26,.05);
}
h2 { font-size: 21px; margin: 0 0 14px; padding-bottom: 8px; border-bottom: 2px solid var(--accent); display: inline-block; }
h3 { font-size: 16px; margin: 22px 0 8px; color: var(--accent-text); }
table { width: 100%; border-collapse: collapse; margin: 10px 0; font-size: 13.5px; }
th, td { border: 1px solid var(--border-1); padding: 7px 11px; text-align: left; vertical-align: top; }
th { background: #F0F1F4; font-weight: 600; }
tr:nth-child(even) td { background: #FCFCFD; }
pre.code, pre.diagram {
background: var(--code-bg); color: var(--code-text); padding: 16px 18px; border-radius: 10px;
overflow-x: auto; font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12.5px; line-height: 1.55;
}
code.inline { background: var(--accent-soft); color: var(--accent-text); padding: 1px 6px; border-radius: 4px; font-family: "SF Mono", Menlo, monospace; font-size: 12.5px; }
.callout { border-left: 4px solid var(--accent); background: var(--accent-soft); padding: 11px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; font-size: 13.5px; }
.callout.warn { border-color: var(--warning); background: #FCEFD7; }
.tag { display: inline-block; font-size: 12px; padding: 1px 10px; border-radius: 999px; margin-right: 6px; font-weight: 600; }
.tag.green { background: #DCFCE7; color: var(--positive); }
.tag.orange { background: #FCEFD7; color: var(--warning); }
.tag.red { background: #FEE2E2; color: var(--danger); }
.tag.blue { background: var(--accent-soft); color: var(--accent-text); }
ul, ol { padding-left: 22px; }
li { margin: 3px 0; }
.toc { columns: 2; font-size: 14px; }
.toc a { color: var(--accent-text); text-decoration: none; }
.toc a:hover { text-decoration: underline; }
footer { text-align: center; color: var(--text-3); font-size: 13px; margin-top: 44px; }
</style>
</head>
<body>
<header class="hero">
<h1>dudu 后端与数据架构</h1>
<p>从前端交互反推的 API、流式协议与时长账本计费设计 · Go 单体</p>
<div class="chips">
<span class="chip">v1.1</span>
<span class="chip">2026-06-11</span>
<span class="chip">依据:frontend-design.html v1.1 + design/ 设计系统</span>
<span class="chip">v1.1 新增:反馈接口 + 打点上报</span>
</div>
</header>
<div class="container">
<section class="card">
<h2>目录</h2>
<div class="toc">
<ol>
<li><a href="#changes">相对 v0.1 的变更摘要</a></li>
<li><a href="#arch">总体架构(沿用)</a></li>
<li><a href="#api">HTTP API 设计</a></li>
<li><a href="#ws">流式识别协议 v1.1</a></li>
<li><a href="#billing">计费模型:时长账本</a></li>
<li><a href="#schema">数据表设计</a></li>
<li><a href="#redis">Redis Key 设计</a></li>
<li><a href="#modules">Go 模块结构</a></li>
<li><a href="#security">安全与风控</a></li>
<li><a href="#verify">验证方式</a></li>
</ol>
</div>
</section>
<!-- ============ 1 ============ -->
<section class="card" id="changes">
<h2>一、相对 v0.1 的变更摘要</h2>
<p>设计系统(<code class="inline">design/</code>)确立的产品决策反推出以下后端变更:</p>
<table>
<tr><th style="width:24%"></th><th>v0.1</th><th>v1.0(本文档)</th></tr>
<tr><td>计费模型</td><td>月/年订阅 + 每日免费 10 分钟</td><td><strong>时长包</strong>100min/¥9、500min/¥39、2000min/¥129<strong>不过期</strong>+ <strong>每日免费试用 3 分钟</strong></td></tr>
<tr><td>数据表</td><td>plans / subscriptions</td><td><strong>废弃</strong>,改为 duration_packs / balance_ledger / trial_usage</td></tr>
<tr><td>WS 协议</td><td>partial / final / error</td><td>新增下行 <code class="inline">usage</code> 帧(实时余额),error code 枚举化</td></tr>
<tr><td>API</td><td>未细化</td><td>本文档第三章给出完整契约(与前端 UI 数据点一一对应)</td></tr>
<tr><td>新增接口</td><td></td><td><code class="inline">GET /v1/app/latest</code>(托盘"检查更新")、<code class="inline">GET /v1/packs</code>(时长包服务端可配)</td></tr>
<tr><td>反馈问题(v1.1</td><td></td><td><code class="inline">POST /v1/feedback</code>:文字 + 图片(OSS 存储)+ 诊断信息,见 3.5</td></tr>
<tr><td>客户端打点(v1.1</td><td></td><td><code class="inline">POST /v1/metrics/batch</code> 批量上报 + metric_events / metric_daily 表 + 每日聚合,见 3.6</td></tr>
</table>
<p>总体架构、ASR Provider 抽象、技术栈(Go + gin + gorilla/websocket + PostgreSQL + Redis + wechatpay-go、阿里云与百炼同区域部署)沿用 <a href="plan/design.html">v0.2 总体方案</a></p>
</section>
<!-- ============ 2 ============ -->
<section class="card" id="arch">
<h2>二、总体架构(沿用)</h2>
<pre class="diagram">
桌面端 Tauri / 移动端键盘扩展+主 App
│ HTTPSauth/me/packs/orders + WSS(音频流)
┌──────────────────────────────────────────────────┐
│ Go 后端(单体,Docker 部署) │
│ auth │ gateway(WS 中继) │ billing │ quota │ user │
└──────────┬───────────────────────┬───────────────┘
│ WSS 同区域 │
▼ ▼
阿里云百炼 Gummy PostgreSQL + Redis + OSS
(流式大模型 ASR, (账本/订单 + 实时计数
Provider 可切火山豆包) + 反馈图片存储)
</pre>
<p>音频走后端中继的三个理由不变:① ASR 密钥不下发;② 按秒计费必须服务端计量;③ Provider 可切换、客户端协议不变。</p>
</section>
<!-- ============ 3 ============ -->
<section class="card" id="api">
<h2>三、HTTP API 设计</h2>
<p>REST + JSON,鉴权 <code class="inline">Authorization: Bearer &lt;JWT&gt;</code>(标 🔓 的无需登录)。统一错误体 <code class="inline">{"code":"...","message":"..."}</code></p>
<h3>3.1 认证</h3>
<table>
<tr><th style="width:34%">接口</th><th>说明</th></tr>
<tr>
<td>🔓 <code class="inline">POST /v1/auth/qr</code></td>
<td>桌面端发起扫码登录。响应 <code class="inline">{"state":"...","qr_url":"https://open.weixin.qq.com/...&state=..."}</code>state TTL 2 分钟(Redis</td>
</tr>
<tr>
<td>🔓 <code class="inline">GET /v1/auth/qr/{state}</code></td>
<td>桌面端轮询(1s 间隔)。响应 <code class="inline">{"status":"pending"}</code><code class="inline">{"status":"confirmed","token":"&lt;JWT&gt;","user":{...}}</code> / <code class="inline">{"status":"expired"}</code></td>
</tr>
<tr>
<td>🔓 <code class="inline">GET /v1/auth/wechat/callback</code></td>
<td>微信网页授权回调(扫码确认后),code 换 openid/unionid → 建号或关联 → 标记 state confirmed</td>
</tr>
<tr>
<td>🔓 <code class="inline">POST /v1/auth/wechat</code></td>
<td>移动端:body <code class="inline">{"code":"&lt;OpenSDK code&gt;"}</code><code class="inline">{"token":"&lt;JWT&gt;","user":{...}}</code></td>
</tr>
<tr><td><code class="inline">POST /v1/auth/logout</code></td><td>JWT 进 Redis 黑名单</td></tr>
</table>
<h3>3.2 用户与余额</h3>
<pre class="code">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 | quotaguest 由无 token 推得)
}</pre>
<p><code class="inline">account_state</code> 服务端判定:余额 &gt; 0 → ok;余额 = 0 且试用未用尽 → trial;两者皆尽 → quota。键盘工具条与 MicBar 四态直接消费此字段。</p>
<h3>3.3 时长包与订单</h3>
<pre class="code">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 // 🔓 微信支付回调:验签 → 幂等入账(详见第五章)</pre>
<h3>3.4 应用更新</h3>
<pre class="code">GET /v1/app/latest?platform=mac|win|android|ios
→ { "version": "1.2.0", "notes": "…", "url": "https://dl.dudu.app/...", "force": false }</pre>
<h3>3.5 反馈问题(v1.1 新增)</h3>
<pre class="code">POST /v1/feedback // multipart/form-data,需登录
fields:
content 文字内容,11000 字(必填)
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": "今天反馈次数已达上限" }</pre>
<ul>
<li>图片由后端校验(magic bytes 验真实格式)后转存阿里云 OSS(key:<code class="inline">fb/{feedback_id}/{n}.{ext}</code>,私有读),表中只存 key;MVP 量小不做客户端直传 STS</li>
<li>限频:每用户 10 条/天(Redis 计数)</li>
<li>处理流转:status <code class="inline">new → triaged → resolved</code>;MVP 内部 SQL 查询处理,可选 webhook 通知到飞书/企微群</li>
</ul>
<h3>3.6 客户端打点(v1.1 新增)</h3>
<pre class="code">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(永不因业务原因报错,校验失败静默丢弃——打点绝不影响客户端)</pre>
<ul>
<li>单批 ≤100 条、单条 props ≤2KB,超出截断;按 device_id + IP 限频(60 批/分钟)</li>
<li>带 JWT 时自动关联 user_id;匿名事件仅 device_id 维度</li>
<li>写入 <code class="inline">metric_events</code>(异步批量 INSERT,失败丢弃不重试);<strong>事件白名单</strong>(见前端文档 9.1 指标清单),未知事件名丢弃</li>
<li>每日聚合任务:按 event × platform × date 计算 count / P50 / P95 写入 <code class="inline">metric_daily</code>,监控延迟目标(first_partial P95 &lt; 500ms 等)超标告警;原始事件保留 90 天定期清理,量大后可平迁 ClickHouse</li>
</ul>
</section>
<!-- ============ 4 ============ -->
<section class="card" id="ws">
<h2>四、流式识别协议 v1.1WSS /v1/asr/stream</h2>
<p>长连接复用:客户端启动即连接 + ping/pong 保活,按键时零握手。一次按住-松开为一个<strong>识别会话</strong>(utterance),同连接串行多次会话。</p>
<h3>4.1 上行</h3>
<table>
<tr><th style="width:24%"></th><th>内容</th></tr>
<tr><td>文本 · start</td><td><code class="inline">{"type":"start","session_id":"c-生成uuid","sample_rate":16000,"format":"pcm16"}</code>;服务端校验配额,不足直接回 error(QUOTA_EXCEEDED)</td></tr>
<tr><td>二进制 · 音频</td><td>16kHz / 16bit / mono PCM,每 100ms 一帧(3200 字节)</td></tr>
<tr><td>文本 · stop</td><td><code class="inline">{"type":"stop","session_id":"..."}</code>(松开);<code class="inline">{"type":"cancel",...}</code>(上滑取消:仍计量但丢弃结果)</td></tr>
</table>
<h3>4.2 下行</h3>
<table>
<tr><th style="width:24%"></th><th>内容</th></tr>
<tr><td>partial</td><td><code class="inline">{"type":"partial","session_id":"...","text":"今天天气"}</code> 增量结果,UI 实时刷新</td></tr>
<tr><td>final</td><td><code class="inline">{"type":"final","session_id":"...","text":"今天天气不错。"}</code> 句子定稿</td></tr>
<tr><td><strong>usage(新增)</strong></td><td><code class="inline">{"type":"usage","session_id":"...","session_seconds":4,"balance_seconds":28316,"trial_remaining":120}</code>;识别中每 2s 及 stop 后下发,驱动托盘余额 / 键盘计时旁状态实时刷新</td></tr>
<tr><td>error</td><td><code class="inline">{"type":"error","code":"...","message":"..."}</code></td></tr>
</table>
<h3>4.3 错误码</h3>
<table>
<tr><th style="width:28%">code</th><th>语义</th><th>前端表现</th></tr>
<tr><td>QUOTA_EXCEEDED</td><td>试用 + 余额均已用尽</td><td>桌面浮层警告并引导购买;键盘转 quota 态</td></tr>
<tr><td>UNAUTHORIZED</td><td>JWT 失效 / 被踢出</td><td>转未登录态,引导重新登录</td></tr>
<tr><td>ASR_UNAVAILABLE</td><td>上游 ASR 故障(已重试/切备用后仍失败)</td><td>"识别服务暂不可用,稍后再试"</td></tr>
<tr><td>SESSION_LIMIT</td><td>单会话超长(上限 <strong>3 分钟</strong>自动截断定稿)</td><td>自动结束并上屏已识别内容,提示"单次最长 3 分钟,松开后可继续"</td></tr>
<tr><td>RATE_LIMITED</td><td>设备 30 分钟滑动窗口超限(会话 &gt; 30 次或累计识别时长 &gt; 30 分钟)</td><td>提示"操作过于频繁,稍后再试"</td></tr>
</table>
<h3>4.4 中继实现(gateway</h3>
<ul>
<li>每连接两个 goroutine:上行泵(客户端→Provider/ 下行泵(Provider→客户端),零拷贝转发</li>
<li>start 时从连接池取 / 新建 Provider 会话(Gummy DashScope WS);Provider 断连对客户端透明重建</li>
<li>计量:网关按<strong>实收音频帧时长</strong>累计(100ms/帧),与 Provider 返回的时间戳交叉校验取小,杜绝多扣</li>
</ul>
</section>
<!-- ============ 5 ============ -->
<section class="card" id="billing">
<h2>五、计费模型:时长账本(核心变更)</h2>
<h3>5.1 模型</h3>
<ul>
<li><strong>余额 = 账本合计</strong><code class="inline">balance_ledger</code> 只追加不修改(审计友好);<code class="inline">users.balance_seconds</code> 冗余列与账本在同一事务内更新,读路径走冗余列</li>
<li><strong>每日试用</strong><code class="inline">trial_usage(user_id, date, used_seconds)</code>,上限 180s,按服务端时区(Asia/Shanghai)自然日重置——重置即"当日无记录",无需定时任务</li>
<li><strong>不过期</strong>:购买入账无有效期字段,永久有效</li>
</ul>
<h3>5.2 扣费规则</h3>
<ol>
<li>会话结束(stop/cancel/超时断连)按实收音频时长<strong>向上取整到秒</strong></li>
<li>顺序:<strong>先扣当日试用,余下扣余额</strong>(与文案"每天另有 3 分钟免费试用"一致)</li>
<li>start 时预检:<code class="inline">trial_remaining + balance ≤ 0</code> → 拒绝并回 QUOTA_EXCEEDED</li>
<li>识别中每 2s 增量预扣 Redis 计数;若中途扣穿(余额归零)→ 当句允许说完(final 下发),随后回 QUOTA_EXCEEDED,避免说一半被掐断的体验</li>
<li>cancel(上滑取消)仍计量——音频已实际消耗 ASR 资源</li>
</ol>
<h3>5.3 支付入账时序</h3>
<pre class="diagram">客户端 后端 微信支付
│ POST /v1/orders │ │
│────────────────────▶│ 创建订单(pending) + 统一下单 │
│ │─────────────────────────────▶│
│ ◀ code_url/pay_params│◀────────────── prepay ─────│
│ (展示二维码/拉起收银台) │
│ │◀──── POST /v1/pay/notify ────│ 用户支付完成
│ │ 验签 → 事务{ 订单 pending→paid │
│ │ + ledger 入账 + 余额冗余列 } │
│ 轮询 GET /orders/:id │ → 应答微信 SUCCESS │
│ ◀ paid + 新余额 │ │</pre>
<ul>
<li><strong>幂等</strong>:回调以 <code class="inline">transaction_id</code> 唯一约束去重;订单状态机只允许 pending→paid,重复回调直接应答成功</li>
<li><strong>对账兜底</strong>:pending 超 5 分钟的订单主动调微信查单接口核对(定时任务),防回调丢失</li>
<li>桌面端"支付完成后自动开通"由订单轮询实现;移动端由支付回跳 + /v1/me 刷新实现</li>
</ul>
<h3>5.4 并发与一致性</h3>
<ul>
<li>实时计数在 RedisLua 脚本原子"检查+扣减",先试用桶后余额桶),会话结束异步落库 ledger;崩溃恢复时以 ledger 为准重建 Redis</li>
<li>同一用户多设备并发识别:MVP <strong>允许并发</strong>(计量各自独立、Redis 原子扣减保证不超扣),避免误伤 iPad + 手机场景;但<strong>单设备同时仅 1 路</strong>识别会话(见第九章识别限制)</li>
</ul>
</section>
<!-- ============ 6 ============ -->
<section class="card" id="schema">
<h2>六、数据表设计(PostgreSQL</h2>
<pre class="code">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 -- 每日聚合任务写入</pre>
<p>废弃 v0.1 的 <code class="inline">plans</code> / <code class="inline">subscriptions</code> / <code class="inline">usage_records</code>(后者并入 asr_sessions)。</p>
<div class="callout">完整性校验:任意时刻 <code class="inline">users.balance_seconds == SUM(balance_ledger.delta_seconds)</code>,对账任务每日核对并告警。</div>
</section>
<!-- ============ 7 ============ -->
<section class="card" id="redis">
<h2>七、Redis Key 设计</h2>
<table>
<tr><th style="width:36%">Key</th><th>类型 / TTL</th><th>用途</th></tr>
<tr><td><code class="inline">quota:{uid}:balance</code></td><td>string(无 TTL,懒加载自 DB</td><td>余额秒数实时计数,Lua 原子扣减</td></tr>
<tr><td><code class="inline">quota:{uid}:trial:{yyyymmdd}</code></td><td>string / TTL 48h</td><td>当日试用已用秒数,自然过期即重置</td></tr>
<tr><td><code class="inline">authqr:{state}</code></td><td>string(JSON) / TTL 120s</td><td>扫码登录状态机 pending→confirmed(+JWT)</td></tr>
<tr><td><code class="inline">jwt:block:{jti}</code></td><td>string / TTL=token 剩余有效期</td><td>登出 / 踢出黑名单</td></tr>
<tr><td><code class="inline">rate:{did}:asr:cnt</code></td><td>ZSET 滑动窗口 / 30min</td><td>设备 30 分钟内会话次数(≤30 次)</td></tr>
<tr><td><code class="inline">rate:{did}:asr:secs</code></td><td>ZSET 滑动窗口 / 30min</td><td>设备 30 分钟内累计识别秒数(≤1800s)</td></tr>
</table>
</section>
<!-- ============ 8 ============ -->
<section class="card" id="modules">
<h2>八、Go 模块结构</h2>
<pre class="code">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 端对齐的唯一真相源</pre>
</section>
<!-- ============ 9 ============ -->
<section class="card" id="security">
<h2>九、安全与风控</h2>
<ul>
<li><strong>JWT</strong>HS256(密钥从 Bitwarden 经部署注入),7 天有效 + 静默续签;jti 支持黑名单踢出</li>
<li><strong>支付回调</strong>wechatpay-go 平台证书自动验签;transaction_id 唯一约束防重放;金额与订单核对一致才入账</li>
<li><strong>扫码 state</strong>:一次性、120s TTL、confirmed 后即删,防钓鱼复用</li>
<li><strong>音频</strong>:不落盘、不留存(识别完即弃),隐私政策中声明;TLS 全链路</li>
<li><strong>识别限制(设备维度,值均服务端可配)</strong>
<ul>
<li>单会话 ≤ <strong>3 分钟</strong>,到点自动截断定稿上屏(SESSION_LIMIT,内容不丢,松开再按可继续)</li>
<li>单设备同时仅允许 <strong>1 路</strong>活跃识别会话(杜绝并发多流烧量)</li>
<li>设备 30 分钟滑动窗口:会话次数 ≤ <strong>30 次</strong> 且累计识别时长 ≤ <strong>30 分钟</strong>,超限回 RATE_LIMITED</li>
</ul>
注:30 次/30 分钟对高频聊天用户(每条消息按一次)可能偏紧,上线后观察 metric_daily 中 RATE_LIMITED 触发率再调参——这正是限额做成服务端配置的原因</li>
<li><strong>ASR 容灾</strong>:Gummy 连续失败 N 次熔断切火山豆包 Provider,恢复后切回</li>
<li><strong>反馈风控(v1.1</strong>:每用户 10 条/天;图片 magic bytes 校验 + 大小/数量/格式白名单;OSS bucket 私有读,仅内部处理端可访问</li>
<li><strong>打点边界(v1.1</strong>:事件白名单 + 单条 2KB 截断 + device_id/IP 限频;<strong>不采集识别文本与音频</strong>;匿名 device_id 与账号体系隔离,隐私政策中声明用途</li>
</ul>
</section>
<!-- ============ 10 ============ -->
<section class="card" id="verify">
<h2>十、验证方式</h2>
<ul>
<li><code class="inline">go test ./...</code>:quota Lua 脚本并发扣减、订单状态机幂等、扣费规则(先试用后余额、向上取整、扣穿当句不掐断)单测</li>
<li>WS 集成测试:脚本推送 wav 切片模拟音频流,断言 partial → final → usage 序列与扣费结果</li>
<li>支付:微信沙箱 + 构造回调报文测试验签 / 幂等 / 金额不符拒绝</li>
<li>配额场景:余额 0 + 试用剩 10s → 说 15s,断言试用扣满 180s 上限内、余额不变、最终 QUOTA_EXCEEDED 时机正确</li>
<li>对账:随机注入 Redis/DB 偏差,验证对账任务发现并告警</li>
<li>识别限制:单会话推流到 3 分钟断言自动截断且 final 正常下发;同设备第 2 路并发 start 被拒;30 分钟窗口构造第 31 次会话 / 累计超 1800s 断言 RATE_LIMITED</li>
<li>反馈(v1.1):multipart 上传 3 张图断言 OSS 落盘与表记录;第 4 张 / 6MB 图 / 伪造扩展名被拒;第 11 条触发 429</li>
<li>打点(v1.1):gzip 批量上报断言异步入库;未知事件名/超长 props 被丢弃或截断且仍返回 204;聚合任务对构造数据算出正确 P50/P95</li>
</ul>
</section>
<footer>dudu 后端与数据架构 v1.1 · 2026-06-11 · 配套文档:<a href="frontend-design.html">前端实现设计</a> · <a href="plan/design.html">MVP 总体方案 v0.2</a></footer>
</div>
</body>
</html>