# 架构设计 | 项目 | 内容 | |---|---| | 版本 | v0.1 草案 | | 范围 | DSH 客户端插件 + Cloudflare 后端 + 共享引擎/协议 | --- ## 1. 总体架构 ``` ┌────────────────────────────────────────────────────────────────┐ │ DeepSeek Harness Web GUI (http://127.0.0.1:3080, 用户本机) │ │ ┌──────────────────────────────────────────────┐ │ │ │ dsh-doudizhu 插件(Cordis bundle + 客户端注入)│ │ │ │ ┌───────────┐ ┌──────────┐ ┌─────────────┐ │ │ │ │ │ 大厅/牌桌 UI│ │ 规则引擎 │ │ 网络层 WS/REST│ │ │ │ │ │ (React) │ │ (纯TS) │ │ (客户端) │ │ │ │ │ └───────────┘ └──────────┘ └─────────────┘ │ │ │ │ 服务端插件:本地工具/DSH Agent 桥接(Phase2) │ │ │ └────────────────────────┬─────────────────────┘ │ └───────────────────────────┼────────────────────────────────────┘ │ HTTPS / WSS(公网) ▼ ┌────────────────────────────────────────────────────────────────┐ │ Cloudflare(云端后端) │ │ ┌──────────────┐ ┌───────────────────────────────┐ │ │ │ Workers API │──▶│ Durable Object:房间 Room │ │ │ │ (Hono/REST) │ │ ·权威状态机 ·WS 网关 ·断线托管 │ │ │ │ 鉴权/匹配/经济/ │ └───────────────────────────────┘ │ │ │ 埋点/Admin │ ┌───────────────────────────────┐ │ │ └──────┬───────┘ │ Durable Object:玩家 Player │ │ │ │ │ ·余额/领取幂等/段位 │ │ │ │ └───────────────────────────────┘ │ │ ┌──────▼───────────────┬───────────────┬──────────────┐ │ │ │ D1:players/ledger/ │ KV:队列/会话 │ R2:头像(Phase2)│ │ │ │ matches/analytics │ /临时token │ │ │ │ └──────────────────────┴───────────────┴──────────────┘ │ └────────────────────────────────────────────────────────────────┘ (Phase 2 增量) ┌──────────────────────────────┐ │ 另一台 DSH(别人的 DSH) │ │ 同一插件 → 作为「受控客户端」 │ │ 连入房间,轮到时由本地 headless │ │ 会话决策出牌 │ └──────────────────────────────┘ ``` **关键原则** 1. **服务端权威**:所有影响经济与对局结果的判断(出牌合法性、结算、领取)由云端裁决,客户端只做展示与预校验,防止篡改。 2. **规则引擎可移植**:牌型/合法性判断写成纯函数,客户端与服务端共用同一份实现(通过 `shared/` 或独立包),单测保证一致。 3. **DSH 风格**:UI 是 DSH Web 界面的原生一部分;M1 通过 `document.body` portal 挂载,不新开独立窗口。 --- ## 2. 客户端(DSH 插件) ### 2.1 插件形态(依据 DSH 插件机制实测) - 根 `package.json` 声明 `dsh.bundle.patch`(指向 `cordis.patch.yml`),安装后加入 profile 的 bundles 栈。 - `dsh.client.inject` 声明注入的 DSH 客户端依赖:`@deepseek-ai/dsh-client-runtime`、`@deepseek-ai/dsh-client-ui-slots`、`@deepseek-ai/dsh-client-locale`、`@deepseek-ai/dsh-client-web-react` 等(参照 dsh-better-sidebar 的 peerDependencies 与注入方式)。 - 客户端入口当前导出空的服务依赖列表,通过 `document.body` + `createRoot` 挂载浮动入口和全局面板,不依赖 shell slots;这是 M1 的 ADR-007 实现。后续若需要接入侧栏 tab/顶部入口,再按 DSH UI slots API 增加正式挂载点。 ### 2.2 模块划分(`src/`) 当前 M1 的实际代码结构如下;M2/M3 的网络层、钱包页和 Agent 桥接仍是目标结构,不代表当前已实现: ``` src/ ├── index.ts # Cordis 服务端插件入口;M1 仅做加载确认 └── client/ ├── index.tsx # React 挂载入口 ├── App.tsx # 大厅、牌桌、结算与本地状态交互 └── brandAssets.ts # 内嵌品牌 SVG Data URI shared/ ├── config.ts # 桌别、段位、签到、抽水、倒计时配置 └── engine/ # 纯函数斗地主规则引擎与单测 worker/ # M2 Cloudflare Workers 后端(当前为规划占位) ``` M2 目标仍包括 `/api/me/profile`、匹配队列、房间 WebSocket、Token 流水和服务端权威结算;M3 再加入 DSH Agent 桥接。 ### 2.3 与 DSH 主流程共存 - 插件为独立面板/入口,不劫持主对话;M1 游戏状态在 React 组件状态与 `localStorage` 中,M2 再抽出共享 store 对接云端状态。 - 长任务提示(Phase 2 UX):监听 DSH 会话运行状态(如 `sessions` 服务),在长任务时显示轻提示,点击进大厅。**不强制弹窗,可关闭。** --- ## 3. 后端(Cloudflare) ### 3.1 服务清单(Workers 入口,Hono 路由) | 端点 | 方法 | 说明 | 鉴权 | |---|---|---|---| | `/api/me` | GET | 玩家档案(昵称/头像/段位/余额/peak) | UID token | | `/api/me/profile` | PUT | 更新昵称/头像 | UID token | | `/api/daily` | POST | 每日领取 2,000(幂等) | UID token + 限流 | | `/api/ledger` | GET | Token 流水 | UID token | | `/api/lobby/queue` | POST/DELETE | 进入/退出匹配队列 | UID token | | `/api/room/:id` | GET | 房间信息/加入 | UID token | | `/ws/room/:id` | WS | 对局实时通道 | UID token(首次帧鉴权) | | `/api/analytics` | POST | 埋点上报(批量) | 独立 ingest key | | `/api/admin/*` | GET | 报表(只读) | Admin key | | `/api/health` | GET | 健康检查 | 无 | ### 3.2 Durable Objects **Room(每局一个实例)** - 持有权威对局状态机:阶段(lobby→calling→playing→settled)、手牌、出牌历史、当前玩家、超时计时。 - WebSocket 网关:所有参与者连接到此 DO;广播状态增量;处理重连(按 UID 认领座位)。 - 断线处理:心跳超时 → 标记托管;重连恢复。Phase 1 托管 = 规则引擎自动出最小合法牌;Phase 2 = DSH Agent 接管。 - 生命周期:房间结算后写 D1 → 进入 idle → DO 自动回收(Alarm 兜底超时销毁)。 **Player(每玩家一个实例,或合并进 D1 + 短事务)** - 经济操作串行化:余额读取/扣减/结算原子化,避免并发双花。 - 每日领取幂等(`claimed_date` 检查 + KV 锁)。 - > 可选:Phase 1 直接用 D1 事务也可;DO 用于高并发下串行化经济操作,M1 评估。 ### 3.3 持久化(D1) ```sql -- 玩家 CREATE TABLE players ( uid TEXT PRIMARY KEY, -- 匿名 UID nickname TEXT NOT NULL, avatar_id TEXT NOT NULL, -- 'default-01' | 'default-02' | 'user--.webp' balance INTEGER NOT NULL DEFAULT 0, -- 当前余额 peak_balance INTEGER NOT NULL DEFAULT 0, -- 历史最高(段位/保底依据) rank_id INTEGER NOT NULL DEFAULT 1, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); -- Token 流水(只增,不可改) CREATE TABLE token_ledger ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT NOT NULL, type TEXT NOT NULL, -- daily | game_in | game_out | rake | rescue delta INTEGER NOT NULL, -- ± balance_after INTEGER NOT NULL, ref_id TEXT, -- 关联对局/领取幂等键 created_at INTEGER NOT NULL ); CREATE INDEX idx_ledger_uid ON token_ledger(uid, id DESC); -- 每日领取幂等 CREATE TABLE daily_claims ( uid TEXT NOT NULL, day TEXT NOT NULL, -- 'YYYY-MM-DD' (UTC+8) amount INTEGER NOT NULL, PRIMARY KEY (uid, day) ); -- 对局 CREATE TABLE matches ( id TEXT PRIMARY KEY, -- 房间 id table_id TEXT NOT NULL, -- 桌别 base_stake INTEGER NOT NULL, multiplier INTEGER NOT NULL, rake INTEGER NOT NULL, players TEXT NOT NULL, -- JSON: [{uid, seat, role, result, delta}] winner TEXT, finished_at INTEGER NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX idx_matches_uid ON matches(players); -- 埋点事件(见 docs/数据埋点.md) CREATE TABLE analytics_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, event TEXT NOT NULL, uid TEXT, ts INTEGER NOT NULL, -- 毫秒 props TEXT, -- JSON server_ts INTEGER NOT NULL ); CREATE INDEX idx_analytics_event_ts ON analytics_events(event, ts); ``` ### 3.4 KV - 匹配队列状态(uid → 桌别,短 TTL)。 - 邀请码 → 房间映射(Phase 2)。 - 短期限流计数。 ### 3.5 R2 与自定义头像(**暂缓**) - 用户决定暂缓(启用 R2 需绑定支付方式);第一阶段只用默认头像(`assets/` 的 DeepSeek 蓝/黑品牌图)。 - 设计保留(未来做时参考): 1. 客户端选图 → canvas 裁剪方形 → 转 **WebP**(`canvas.toBlob('image/webp', 0.8)`)→ 压缩到 256×256 / ≤128KB → `multipart/form-data` 上传。 2. 服务端校验:MIME 白名单 + 魔术字节(**拒收 SVG**,防脚本注入)+ 大小上限(如 ≤512KB 防异常重传)。 3. 写入 R2:`avatars//.webp`(内容寻址,相同内容不重复存储)。 4. 更新 `players.avatar_id`,返回公开可访问 URL。 - 说明:WebP 转码放客户端(浏览器原生能力),服务端只校验+存储;若后续需要服务端强制重编码,可引入 `@napi-rs/image`。 ### 3.6 匿名身份与鉴权 - 客户端生成 UID(crypto.randomUUID)+ 设备指纹(Phase 2),存 localStorage。 - 后端签发自签 token(JWT/HS256,`AUTH_SECRET`),含 `uid`、过期时间;首次 `/api/me` 用 UID 换 token。 - WS 首帧携带 token 校验;所有业务接口带 `Authorization: Bearer`。 - 限流:按 UID + IP(Workers Rate Limiting 或 KV 计数)。 --- ## 4. 通信协议(WS,客户端↔Room) ### 4.1 消息封装 ```jsonc { "v": 1, "t": "", "id": "", "d": { ... } } ``` ### 4.2 消息类型(初版) | type | 方向 | 说明 | |---|---|---| | `hello` | C→S | 首帧鉴权:token、seat 认领 | | `ready` | C→S | 准备(匹配开局后) | | `call` | C→S | 叫地主/抢地主 | | `play` | C→S | 出牌(牌数组);服务端校验合法性 | | `pass` | C→S | 过 | | `hint` | C→S | 请求提示(服务端或本地算) | | `concede` | C→S | 认输 | | `state` | S→C | 全量状态(重连/开局下发) | | `patch` | S→C | 状态增量(他人出牌等) | | `turn` | S→C | 轮到你 + 倒计时 | | `settle` | S→C | 结算结果 | | `error` | S→C | 错误(非法操作等) | - 版本字段 `v` 便于协议演进。 - 客户端收到任何 `play`/`call` 都先用本地引擎校验展示,但**最终合法性以服务端裁决为准**。 --- ## 5. 规则引擎设计 - 纯函数、无 I/O、无状态(输入对局状态+动作 → 输出新状态或错误)。 - 与客户端/服务端解耦:放 `shared/` 或独立包 `@dsh-doudizhu/engine`,两边通过构建引入同一源码。 - 关键接口: - `deal(rng) → { hands, bottom, landlord? }` - `legalPlays(hand, lastPlay, isFirst) → Play[]` - `apply(state, action) → { state | error }` - `evaluate(playA, playB) → -1|0|1` - `score(round, players) → LedgerDelta[]` - 单测覆盖:全部牌型、大小比较、非法出牌、春天/反春、倍数叠加、结算账目守恒(三方净和 = -rake)。 --- ## 6. 安全与风控 | 威胁 | 对策 | |---|---| | 客户端伪造余额/出牌 | 服务端权威;结算/领取只在服务端算;余额只读展示 | | 重复领取签到 | D1 主键 (uid, day) 幂等 + KV 锁 + 限流 | | 伪造他人 UID | 后端签发签名 token,UID 不可被客户端指定(首次建档校验) | | 脚本刷 Token | 设备指纹 + 单 UID 每日领取上限 + 异常流水告警 | | 对局作弊(伙牌/透视) | Phase 1 仅匿名+随机匹配降低伙牌动机;Phase 2 匿名匹配强制随机座位 | | 中间人篡改 WS | WSS + token 鉴权;消息含签名可选(Phase 2) | | DDoS/滥用 | Workers Rate Limiting + Cloudflare 防护 + ingest key 保护 | --- ## 7. 部署与配置 ``` worker/ ├── src/ │ ├── index.ts # Hono 入口,路由装配 │ ├── auth.ts # 自签 token │ ├── room.ts # DO: Room │ ├── player.ts # DO: Player(或 D1 事务) │ ├── economy.ts # 结算/领取/抽水 │ ├── avatar.ts # 头像上传校验 + R2 读写(暂缓,待 R2 启用) │ ├── analytics.ts # 埋点 ingest │ ├── admin.ts # 报表端点 │ └── types.ts # 共享类型 ├── migrations/ # D1 迁移(schema.sql) ├── wrangler.toml.example └── package.json.example ``` - `wrangler.toml`:含 `account_id`、D1 binding、KV binding、R2 binding、DO class 注册、routes/zone。**真实文件 gitignore,仓库只留 `.example`。** - 环境变量(`wrangler secret`):`AUTH_SECRET`、`ANALYTICS_INGEST_KEY`、`ADMIN_KEY`。 - 本地开发:`wrangler dev` + 插件端 `dsh dev`(tsdown watch / dev:web HMR)联调。 - CORS:允许 DSH 插件所在源(本机 `127.0.0.1:3080` 及以后部署的静态源)。 --- ## 8. Phase 2 架构增量(DSH Agent) - **座位抽象**:Room 状态中每座位 = `{ controller: 'human' | 'agent' , agentSource?: 'self' | 'remote' }`。协议不变,只是 `play` 动作来源不同。 - **本机 Agent(自己的 DSH)**:当轮到自己 DSH 时,插件的 `agent/` 模块调用本机 DSH headless 会话(`dsh --profile headless "<决策提示>"` 或经本地 bridge),把返回解析成牌型动作;超时兜底。 - **远端 Agent(别人的 DSH)**:对方 DSH 以受控客户端身份连入同一房间,对方插件在本地跑同样的 headless 决策。 - **双 AI 观战**:房主插件充当「观战客户端」,两个 Agent 座位各由其宿主 DSH 决策,房主只收 `patch` 渲染。 - 提示词模板放 `agent/prompts.ts`,可调:手牌、出牌历史、剩余牌数、叫分状态 → 输出 JSON `{action:"play"|"pass"|"call", cards:[...], reason:"..."}`。 --- ## 9. 目录结构(仓库总览) ``` dsh-doudizhu/ ├── docs/ # 需求/策划/架构/路线图/埋点 ├── src/ # 插件源码(engine/client/agent/server) ├── worker/ # Cloudflare 后端 ├── shared/ # 客户端/服务端共享类型与常量(含配置阈值) ├── assets/ # 美术资源 ├── scratch/ # 草稿(gitignored) ├── package.json # 插件清单 ├── cordis.patch.yml ├── tsconfig.json / tsdown.config.ts └── .gitignore ``` --- ## 10. 技术选型 | 项 | 选择 | 理由 | |---|---|---| | 客户端 | React 18 + TS + Cordis 插件 | DSH Web 原生栈(参照 dsh-better-sidebar),注入成本最低 | | 规则引擎 | 纯 TS | 双端共用、可单测、无运行时依赖 | | 实时 | WebSocket + Durable Objects | CF 原生支持 WS 长连接、有状态房间、自动回收 | | REST | Hono | 轻量、TypeScript 友好、Workers 官方生态 | | 持久化 | D1 (SQLite) | 关系型、事务支持(经济结算原子性)、免费额度充足 | | 缓存/队列 | KV | 匹配队列、限流、临时状态 | | 文件 | R2 | 头像存储(暂缓,待 R2 启用) | | 部署 | wrangler | 官方 CLI | | 埋点 | 自建 D1 管道 + Cloudflare Web Analytics | 见 docs/数据埋点.md | --- ## 11. 决策记录(ADR 摘要) | # | 决策 | 理由 | 状态 | |---|---|---|---| | ADR-001 | 服务端权威经济/结算 | 防作弊,匿名场景下不能信任客户端 | 已定 | | ADR-002 | 规则引擎双端共用纯函数 | 一致性 + 可测 | 已定 | | ADR-003 | 3 人标准斗地主(Phase 1) | 用户确认 | 已定 | | ADR-004 | 仓库 public | 用户确认,便于 `dsh plugin add` 与上架 | 已定 | | ADR-005 | Agent 座位走同一协议 | Phase 2 扩展成本最低 | 已定(待 M1 验证) | | ADR-006 | 埋点自建 D1 + Web Analytics 补充 | 无外部依赖、可控、贴合 CF 全栈 | 已定 | | ADR-007 | M1 使用 `document.body` + `createRoot` portal 挂载 | 不依赖 shell slots,后续可按需接入 | 已定(M1) | | ADR-008 | 自定义头像暂缓(需 R2/绑卡),第一阶段只用默认头像 | 用户决定,避免绑定支付方式;设计保留 | 暂缓 | | ADR-009 | 默认头像与牌背用 DeepSeek 品牌蓝/黑图 | 用户提供;统一品牌辨识度 | 已定 |