40760aa884
- 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>
413 lines
27 KiB
HTML
413 lines
27 KiB
HTML
<!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
|
||
│ HTTPS(auth/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 <JWT></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":"<JWT>","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":"<OpenSDK code>"}</code> → <code class="inline">{"token":"<JWT>","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 | quota(guest 由无 token 推得)
|
||
}</pre>
|
||
<p><code class="inline">account_state</code> 服务端判定:余额 > 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 文字内容,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": "今天反馈次数已达上限" }</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 < 500ms 等)超标告警;原始事件保留 90 天定期清理,量大后可平迁 ClickHouse</li>
|
||
</ul>
|
||
</section>
|
||
|
||
<!-- ============ 4 ============ -->
|
||
<section class="card" id="ws">
|
||
<h2>四、流式识别协议 v1.1(WSS /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 分钟滑动窗口超限(会话 > 30 次或累计识别时长 > 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>实时计数在 Redis(Lua 脚本原子"检查+扣减",先试用桶后余额桶),会话结束异步落库 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>
|