Merge remote-tracking branch 'origin/master' into feat/pricing-plan
Project CI / Backend tests (pull_request) Failing after 1m51s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m10s
Project CI / Frontend tests (pull_request) Failing after 1m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 4m59s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m38s
Project CI / Repository checks (pull_request) Failing after 4m27s
Project CI / Native shell tests (pull_request) Successful in 7m43s

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
#	server-rs/crates/module-runtime/src/lib.rs
This commit is contained in:
2026-10-03 20:32:40 +08:00
273 changed files with 17327 additions and 5284 deletions
+2
View File
@@ -138,6 +138,8 @@
- [预览画布缩放滑杆](./【交互设计】预览画布缩放滑杆-2026-09-05.md)
- [图片画布撤销、恢复范围与操作提示方案](./【图片画布】撤销范围与操作提示方案-2026-07-17.md)
- [微信虚拟支付接入](./【技术方案】微信虚拟支付接入-2026-05-26.md)
- [外部产品支付服务接入](./【技术方案】外部产品支付服务接入-2026-10-03.md)
- [支付服务接入与收银台使用说明](./【使用说明】支付服务接入与收银台使用说明-2026-10-03.md)
- [SSE 客户端传输层收口约定](./technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md)
- [全站客服悬浮入口接入约定](./technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md)
- [图片画布素材导出方案](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)
@@ -52,7 +52,14 @@ DirectProject 已经解决过同一类问题([`【ADR】DirectProject命令接
`runtimeSessionInstallFailed` / `authClientInitFailed` 三个 catch 用本操作的固定文案,路径等敏感片段
由报告侧的 sanitize 换成占位符。
- `#[tauri::command]` 的 `Err` 直接携带该枚举(Tauri 2 的 `InvokeError(pub serde_json::Value)` 支持结构化错误)。
这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), DirectTurnEnqueueFailure>`),不是新约定。
这是 DirectProject 已有的做法(`enqueue_direct_codex_turn -> Result<(), EnqueueError>`),不是新约定。
DirectProject 的三条通道固定为三张类型表:入队拒绝 `EnqueueError`、宿主内部回合错误 `TurnError`
(不导出、不跨进程)、`turn.completed.failure` 载荷 `TurnFailure`;类型随所属深模块命名
(`agent/codex_app_server/turn_error.rs`、`agent/thread_manager/wire/failure.rs`),不再带 `Direct` 前缀;
回合终态判定 `TurnCompletion` 是宿主内部判别联合(`agent/thread_manager/turn_completion.rs`),不导出、不进 `wire/`。
`TurnFailure` 里仍带字符串的两臂专门标成"待清的债":`SuperErrorFromStringPlusStage`(`stage` 是 typed 枚举、
`detail` 仍是产生层字符串)与 `Unclassified`(连阶段都没有),名字故意起丑,`detail` typed 化后即改名;
`TurnError::classify`(返回 `TurnErrorClassified{ShouldStop, ShouldContinue}`)是**唯一**投影点,任何地方都不许再把 typed 失败重包成"阶段失败 + 预拼文案"。
- 变体按**可判定的事实**命名。服务端 400 只提供 `status + message`(`AppError.code` 仍是通用
`BAD_REQUEST`),所以 400 变体按"哪条请求的输入被拒"命名(如 `passwordLoginRejected`),
不假装能区分密码长度/手机号格式;**任何地方都不允许对错误文案做判断**。
@@ -43,6 +43,10 @@
{
"name": "Agent Integration",
"description": "远程 MCP、OpenAPI 和完整 Skill 包发现"
},
{
"name": "Payment",
"description": "平台统一收款与 Web 收银台"
}
],
"paths": {
@@ -786,6 +790,46 @@
}
},
"/api/external/v1/editor/assets/folders/{folderId}": {
"get": {
"tags": ["Editor Assets"],
"operationId": "getEditorAssetFolder",
"summary": "按 ID 读取当前账号素材文件夹",
"description": "要求 editor:asset scope。仅按 folderId 读取目录并校验当前账号归属,不读取素材列表或创建默认目录;空目录也可读取。目录不存在返回 404,无权访问返回 403。",
"security": [
{
"ExternalApiKey": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/FolderId"
}
],
"responses": {
"200": {
"description": "素材文件夹",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExternalEditorAssetFolderResponse"
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
},
"404": {
"$ref": "#/components/responses/NotFound"
}
}
},
"patch": {
"tags": ["Editor Assets"],
"operationId": "updateEditorAssetFolder",
@@ -1574,6 +1618,52 @@
}
}
}
},
"/api/external/v1/payment/orders": {
"post": {
"tags": ["Payment"],
"operationId": "createExternalPaymentOrder",
"summary": "创建支付订单并返回收银台地址",
"description": "使用支付应用 API Key 创建平台统一收款订单。支付金额按整数分传入;支付成功必须以平台通知或服务端查单为准。",
"security": [{"PaymentApiKey": []}],
"parameters": [{"$ref": "#/components/parameters/IdempotencyKey"}],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {"$ref": "#/components/schemas/PaymentOrderCreateRequest"}
}
}
},
"responses": {
"200": {
"description": "支付订单与收银台信息",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}}
},
"400": {"$ref": "#/components/responses/BadRequest"},
"401": {"$ref": "#/components/responses/Unauthorized"},
"403": {"$ref": "#/components/responses/Forbidden"},
"502": {"$ref": "#/components/responses/UpstreamError"}
}
}
},
"/api/external/v1/payment/orders/{orderId}": {
"get": {
"tags": ["Payment"],
"operationId": "getExternalPaymentOrder",
"summary": "查询支付订单",
"security": [{"PaymentApiKey": []}],
"parameters": [{"name": "orderId", "in": "path", "required": true, "schema": {"type": "string"}}],
"responses": {
"200": {
"description": "支付订单",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/PaymentOrderCreateResponse"}}}
},
"401": {"$ref": "#/components/responses/Unauthorized"},
"403": {"$ref": "#/components/responses/Forbidden"},
"404": {"$ref": "#/components/responses/NotFound"}
}
}
}
},
"components": {
@@ -1582,6 +1672,11 @@
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_sk"
},
"PaymentApiKey": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_pay"
}
},
"parameters": {
@@ -1707,6 +1802,47 @@
}
},
"schemas": {
"PaymentOrderCreateRequest": {
"type": "object",
"required": ["merchantOrderId", "title", "amountCents"],
"properties": {
"merchantOrderId": {"type": "string", "minLength": 1, "maxLength": 96},
"title": {"type": "string", "minLength": 1, "maxLength": 128},
"items": {"type": "array", "items": {"type": "object", "additionalProperties": true}},
"amountCents": {"type": "integer", "minimum": 1},
"currency": {"type": "string", "default": "CNY"},
"provider": {"type": "string", "enum": ["wechat_native"], "default": "wechat_native"}
},
"additionalProperties": false
},
"PaymentOrder": {
"type": "object",
"required": ["orderId", "appId", "merchantOrderId", "title", "items", "amountCents", "currency", "provider", "checkoutToken", "status", "checkoutUrl", "createdAt", "expiresAt", "updatedAt"],
"properties": {
"orderId": {"type": "string"},
"appId": {"type": "string"},
"merchantOrderId": {"type": "string"},
"title": {"type": "string"},
"items": {"type": "array", "items": {"type": "object", "additionalProperties": true}},
"amountCents": {"type": "integer", "minimum": 1},
"currency": {"type": "string"},
"provider": {"type": "string", "enum": ["wechat_native"]},
"providerTradeNo": {"type": ["string", "null"]},
"checkoutToken": {"type": "string"},
"status": {"type": "string", "enum": ["pending", "paying", "paid", "expired", "closed", "refunded"]},
"checkoutUrl": {"type": "string"},
"providerQrCode": {"type": ["string", "null"]},
"createdAt": {"type": "string", "format": "date-time"},
"expiresAt": {"type": "string", "format": "date-time"},
"paidAt": {"type": ["string", "null"], "format": "date-time"},
"updatedAt": {"type": "string", "format": "date-time"}
}
},
"PaymentOrderCreateResponse": {
"type": "object",
"required": ["order"],
"properties": {"order": {"$ref": "#/components/schemas/PaymentOrder"}}
},
"ExternalEditorProjectCreateRequest": {
"type": "object",
"properties": {
@@ -164,7 +164,7 @@
- 位置与层级:画布左下角(`left: 14px; bottom: 14px; z-index: 40`)。右下角是既有的缩放 / 撤销 Dock(`right: 14px; bottom: 14px`),左下角是画布上唯一两者都不占的稳定空位。工具栏是管理区 `.game-resource-book-manager` 的**直接子节点**、与画本场景并列,不进带 `scale()` 的场景层;二级菜单与「入口不可用原因」都贴着工具栏上沿弹出,不做内嵌内容。
- 外壳用共享 chrome(`packages/image-canvas-react` 的 `CanvasToolbar / CanvasToolbarGroup / CanvasChromeButton`),样式落在 AGC 的 `resourceCanvasChrome.css`;共享包只承接通用表现,不含业务规则。
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 接线:图片类入口走本地 IPC `start_local_project_asset_generation`(`kind` ∈ `image / character / spec / icon-spec / ui-prototype / art-spritesheet`;**提交即返回任务记录**,生成由 Rust 后台任务跑完写回项目,进度用 `list_local_project_asset_generations` 读回项目内账本 `.agent/runtime/asset-generation-tasks/tasks.json`);音频入口也走 `start_local_project_asset_generation`(同一份项目内任务账本;`kind` = `sound-effect` / `background-music`,并额外携带该次生成的请求身份 `idempotencyKey`,任务 id 即该次生成的 operation id;生成仍复用既有音频无源生成链路,`generationMode: 'create'`、`editKind` = `sound-effect` / `background-music`,不新增平台路由与请求体口径);「上传」复用 `upload_local_asset`,并带上当前栏目 `targetCategory`(上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;不带入口栏目素材就落进「待归类」、在上传它的那一栏里看不见,Issue 359)。生成 / 上传成功后一律用「配对读 `(revision, manifest)`」交给 `onManifestChange`,走既有 manifest 刷新与资源投影链路,不重算依赖图、不另写布局。
- 本地排队与进度可见:AGC 本地 durable 输出槽已按**精确动作指纹**分槽(不同 prompt / 素材名各自独立成槽,具备并行能力),但本批前端仍按「同一时刻只派发一条」排队——真并行派发需要并发收口设计(配对读 + manifest CAS + 聚焦意图互不覆盖),留待下一批;所以第一条未终态时第二条提交停在**前端本地队列**里(不调用提交 IPC,显示本地排队的「排队中。」),前一条终态后自动补发;任务状态与阶段文案(后端 `phaseDetail`)由任务账本提供,前端不拼阶段、不做百分比。进度面是**画布上常驻的可折叠任务侧栏**(位置与开合形态照抄网页端美术画布的任务侧栏的右上角锚点,颜色与外形仍走 AGC 平台 token;2026-09-21 由「左侧贴边 + 工具条入口」改为「右上角锚点 + 常驻开关」):展开是两个分栏「排队/生成中」与「已完成」(各带条数,「已完成」封顶 20 条 + 列表滚动 + 高度有界),关闭入口只保留头部那一枚 ×(底部重复的关闭按钮与其分割线已删除)、折叠即整块让出画布、只留那一枚右上角开关(工具条上不再有重复入口),开合只走画布右上角那一枚「生成任务 · N」开关(两个页签下都在;**展开后开关让位、只留面板**,收起走面板头部 × 或点画布外部);锚点是画布那一格网格里的条目(不是写死 `top` 的绝对定位),工具条换行变高也不会压上去;每项显示状态徽标 / 阶段文案 / 已耗时 / 素材名,可「定位到素材」。侧栏非模态(不铺全屏遮罩、不做焦点陷阱、不参与模态遮挡判据),位置在画布右上角锚点里(照抄美术画布那一处)、**覆盖式**(不 reflow 挤窄画布视口),提交受理后自动展开。锚点按工作面分档:资源栏目画布与 UI 编辑器用画布顶边那一档,运行表现层下移让开右上角的版本入口;锚点是**画布那一格网格里的条目**(不是写死 `top` 的绝对定位),所以工具条换行变高也不会压上去。定位动作**每次点击都终局化**:能定位就定位并选中;素材在别的栏目先切栏目;不在投影里给「素材已不在项目里 / 已登记但尚未同步」的结论;3 秒内有界兜底,不允许提示条永久停在「正在定位生成的素材…」。
- 面板形态:独立浮层(`ThemedModal`),**不在当前面板下面追加内容**;面板内不写功能说明或规则解释文案。**点「生成」即同步关闭面板**(不等 IPC、不等排队、不等生成),面板里**不出现**「排队中。」「正在生成。」「提交中…」这类阶段文案——阶段文案的唯一去处是任务侧栏与工具栏提示条。**只有「点击瞬间就失败」**(校验不过、权限拒绝、start IPC 立即报错)才自动重开面板并带回草稿与原因;**受理之后才失败**只在侧栏把该任务收口为失败 + 原因,不重开面板。关闭 ≠ 取消请求(请求挂在任务与账本上,不挂在面板生命周期上)。
- 参数口径:比例 / 尺寸选项来自网页端美术画布的纯模型(`src/components/image-editor/ImageCanvasGenerationModel.ts`),并按本地 IPC 白名单收窄(本地通道明确拒绝 `4:3`);默认档 `1:1 · 1K`,生成 UI 设计图沿用网页端 UI 设计面板的默认 `16:9 · 1K`。本地 IPC 没有 `model` 入参,因此面板**不渲染模型选择器**(渲染一个改不了请求的控件就是假控件)。
@@ -27,7 +27,7 @@
## 验证命令
1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture`
2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交
2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 ts-rs 绑定);生成物保持原始输出直接提交,不要再跑 prettier(见 `decision-log.md` 2026-10-03「生成绑定不再经 prettier」)
3. `npm run test -- apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts`
4. `npm run test -- apps/ai-game-creator-shell/tests/appSurface.test.ts`(`project-development.suite.ts` 由该入口注册,不能作为独立测试入口;同时验证现役 Design Agent 的会话恢复与审批界面。)
5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。
@@ -0,0 +1,31 @@
# Web 预检浏览器失败恢复实现计划
- Version: `1`
- Status: `active`
- Date: `2026-10-02`
- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md)
## 实现顺序
1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。
2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。
3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。
4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。
5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。
## 验证命令
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::`
- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests`
- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx`
- `npm run typecheck --workspace apps/ai-game-creator-shell`
- `npm run check:encoding`
- `git diff --check`
可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。
## 风险与回滚
- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。
- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。
- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。
@@ -0,0 +1,52 @@
# 【实施计划】支付应用与订单收银台
| 字段 | 值 |
| --- | --- |
| Milestone | `docs/project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md` |
| Status | completed |
| Owner | Codex |
## 修改边界
允许修改:
- 主规范、shared-contracts、OpenAPI、支付领域 module、spacetime-module / migration / bindings、spacetime-client facade、api-server payment module、platform-wechat provider。
- `src` 的 profile 支付入口、收银台页面、支付客户端和页面样式。
- `apps/admin-web` 的支付 API 类型、路由、页面和后台样式。
- 与本里程碑直接相关的测试和文档索引。
明确不修改:
- 现有个人钱包充值、微信虚拟支付和退款状态机的行为。
- 生产商户密钥、环境文件、个人配置和构建产物。
- 支付宝 provider、分账、提现、手续费和自动结算。
## 实现顺序
1. 从现有充值订单、微信 Native provider、外部 API Key、profile 路由和后台列表组件提取可复用边界,确认没有重复的公开订单模型。
2. 冻结支付 DTO、状态枚举、错误码、幂等语义和 OpenAPI,然后补契约测试。
3. 增加支付领域表、migration、procedure / facade 和 schema 绑定,先完成订单创建、查询、关闭与幂等。
4. 在 `platform-wechat` 增加支付服务需要的 Native 请求 / 响应和通知确认复用,保持商户配置只在服务端。
5. 在 `api-server` 接入外部 API、收银台 read model、微信通知、查单和外部回调 outbox;所有到账入口复用统一确认事务。
6. 增加个人中心支付应用 / Key / 订单页面和公共收银台,按截图参考实现桌面双栏与移动端纵向布局。
7. 增加后台支付概览、应用、订单、通知和回调投递页面,复用 `packages/shared` 后台公共组件。
8. 运行定向测试、schema / OpenAPI / 编码检查,启动 api-server 做 healthz 和 mock provider smoke;条件具备时执行真实沙箱二维码 smoke。
## 验证命令
1. `npm run check:encoding`
2. `git diff --check`
3. `npm run check:spacetime-schema`
4. `npm run typecheck`
5. `npm run admin-web:typecheck`
6. 支付领域和 api-server 定向 `cargo test`
7. 外部 API、profile、收银台和 admin 页面定向 Vitest
8. `npm run dev:api-server` 后检查 `/healthz`
9. mock provider + Playwright 收银台 smoke;真实商户沙箱可用时再补动态二维码验证
## 风险与回滚点
- 商户 API 证书或通知地址不可用时,保留 provider mock 证据,不把 mock 结果标成真实支付通过。
- schema 变更失败时停止在绑定生成前,保留主规范和计划,不能通过删除数据绕过迁移。
- 订单确认事务或回调 outbox 未完成时,关闭新支付渠道开关,不影响现有充值渠道。
- 发现需要余额、提现、分账或支付宝真实接入时,先更新主规范和后续里程碑,再扩展实现。
@@ -0,0 +1,41 @@
# Web 预检浏览器失败恢复
- Version: `1`
- Status: `active`
- Date: `2026-10-02`
- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架)
## 目标
修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。
## 边界
- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。
- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。
- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。
- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。
- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。
## 行为合同
1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。
2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。
3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。
4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。
5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。
## 验收标准
- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。
- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。
- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。
- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。
## 依赖
- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs`
- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs`
- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs`
- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts`
- 对应 Rust 与 Vitest 测试。
@@ -0,0 +1,59 @@
# 【里程碑】支付应用与订单收银台
| 字段 | 值 |
| --- | --- |
| Version | 1.0 |
| Status | completed |
| Date | 2026-10-03 |
| Parent Spec | `docs/【技术方案】外部产品支付服务接入-2026-10-03.md` |
## 目标
建立首个可运行的支付服务闭环:账号创建支付应用并管理 API Key,客户端或外部产品创建订单,用户进入 Web 收银台,平台通过微信 Native provider 生成动态二维码,通知 / 查单确认订单,之后通过签名回调通知接入方,后台可查询应用和订单。
## 范围
- 支付应用、API Key 元数据和账号归属。
- 外部支付订单、商品快照、支付尝试和状态机。
- 微信 Native 动态二维码 provider 适配;支付宝只保留渠道扩展结构,不纳入本里程碑的可用渠道。
- 公共收银台和移动端纵向布局。
- 支付平台通知、主动查单、确认幂等和接入方回调 outbox。
- 个人中心支付服务入口、应用配置页和订单列表。
- 后台支付总览、应用列表、订单列表、通知 / 回调失败列表。
- `/api/external/v1/payment/*` OpenAPI、共享 DTO、契约测试和支付领域 schema。
## 不在范围内
- 支付宝真实下单、退款、分账和沙箱联调。
- 外部产品余额、手续费、提现、自动结算和自动分账。
- 用户钱包充值商品的迁移或现有微信虚拟支付协议改造。
- 自动履约发货、数字权益回收和支付争议处理。
## 依赖与前置条件
- 平台微信 Native 商户号、API 证书 / 密钥和正式或沙箱通知地址可由部署环境注入。
- 当前微信支付 Native provider、充值订单状态与验签测试可复用。
- 外部回调域名必须通过应用配置的 HTTPS 和白名单校验。
- SpacetimeDB schema、migration、生成绑定和 API OpenAPI 可以在同一里程碑同步更新。
## 验收标准
- [x] 账号能创建、停用支付应用,API Key 仅在创建响应中展示一次,撤销后立即拒绝请求。
- [x] 外部 API 使用 `Idempotency-Key` 创建订单,重放返回原订单,字段冲突返回业务错误。
- [x] 订单金额以整数分保存,商品和金额快照创建后不可被应用配置改写。
- [x] 收银台使用不可猜测 token,桌面端双栏、移动端纵向显示商品、金额、二维码、过期和状态。
- [x] 微信 Native 下单返回的二维码对应当前本地平台订单号和金额。
- [x] 支付平台通知 / 查单确认前,客户端轮询或伪造回调不能把订单改为 `paid`。
- [x] 重复、乱序、错误金额、错误签名、错误应用归属的通知不会重复确认或改变订单。
- [x] 接入方回调异步投递、签名、重试和失败状态可查询。
- [x] 个人中心和后台均能按归属和权限查看应用、订单及失败记录。
- [x] `shared-contracts`、OpenAPI、schema 绑定和契约测试同步通过。
## 证据要求
2026-10-03 验收复核:支付专用 schema、typed facade、API、应用管理、provider-attempt claim、通知确认、签名回调 outbox、后台投递状态、profile/payment 路由和前端 build 已完成。项目 dev stack 发布后 `GET /healthz` 返回 HTTP 200,live schema 包含 `payment_order` / `payment_webhook_delivery`;真实商户沙箱二维码和真实微信通知因环境未注入商户凭据未验证,按“有可用沙箱时补充”处理。
- 自动化:Rust 领域 / API / provider 测试、前端页面与契约测试、schema 检查、OpenAPI 检查、编码和 diff 检查。
- 运行时:项目 dev stack 发布当前 schema 后,api-server 使用正确端口和 bootstrap 配置,`GET /healthz` 返回 HTTP 200;live schema 已包含 `payment_order` 与 `payment_webhook_delivery`;微信 provider mock 由现有 provider 测试覆盖。
- 未验证:真实商户沙箱动态二维码和真实微信通知,当前环境没有可安全使用的商户凭据;代码保留 provider 配置失败和支付状态失败关闭语义。
- 边界:权限、幂等、重放、金额不一致、回调失败、过期、未知通知和敏感日志检查。
@@ -9,6 +9,51 @@
- 影响范围:`packages/shared/src/utils/mudPoints.ts`、`PlatformMudPointWalletEntry`、`PlatformProfileRechargeModal`(池概览 3 列变 2 列、扣点顺序提示与泥点确认页文案随可见性切换)、`apps/admin-web/src/pages/AdminProfileWalletConfigPage.tsx`(每日免费允许填 0)、`server-rs/crates/module-runtime/{commands,errors,lib}.rs`;`daily_free_grant` / `daily_free_reset` 账本文案保留,历史流水不改写。
- 验证:`cargo check -p module-runtime`、`cargo test -p module-runtime profile_wallet_config_allows_zero_daily_free_points`、`cargo fmt --check`、根 `npm run typecheck`、`npm run admin-web:typecheck`、共享层与 admin 定向 vitest 22/22、`npm run check:encoding`、`git diff --check` 通过。
## 2026-10-03 AGC 栏目画布上传素材按入口栏目登记(Issue 359)
- 背景:AGC 客户端在资源栏目子画布(「UI 交互 / 角色与对象 / 场景与环境 / 音频」)左下角工具栏点「上传」后,提示条给出「已上传 1 个素材」,但当前栏目计数不变(仍「0 项」)、素材出现在「待归类」,用户看到的是"上传成功了但它从这一页消失了"。原因是上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套,而 `upload_local_asset` 原先不接受入口栏目。
- 决策:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(与生成入口 `start_local_project_asset_generation` 的 `targetCategory` **同一口径**:GUI 完成登记以入口栏目为准);前端 `uploadProjectAssetFilesAndReadSnapshot` 透传该字段,栏目画布工具栏上传取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。取值只接受共享分类枚举,非法值由原生失败关闭,前端不做伪分类。
- 边界:不传 `targetCategory` 时保持既有 kind 派生行为——资源面板(`ResourceCanvasPanelView`,跨栏目列表而不是栏目工具)、UI 编辑器图片导入(`import_ui_editor_local_files`)、聊天附件上传都不变。`upload_local_asset_at` 签名保持不变(委托到新的 `upload_local_asset_at_with_category`),既有约 25 处调用点与 Rust 单测零改动。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/{assets.rs,commands/desktop.rs}`、`apps/ai-game-creator-shell/src/view/project-development/{projectResourceLiveUpdateModel.ts,index.tsx}`、`apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts`、PRD §3.10、`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 载荷表)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`(S11a)、`pitfalls.md`。
- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --features=cocos-editor-execute,unity-editor-execute,godot-editor-execute --bin genarrative-ai-game-creator-shell assets::tests`(新增 `upload_registers_into_the_explicit_entry_category`:显式栏目 → `character`、不传 → `unclassified`、非法 `version` 失败关闭且不新增登记);`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(196 passed / 9 skipped,含新增「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`);`npm run agc:typecheck`、`npm run check:encoding`、`git diff --check`。
## 2026-10-03 生成绑定不再经 prettier:ts-rs 原始输出即提交形态
- 背景:`scripts/check-generated-bindings.mjs` 对 AGC 的 `chat/generated` / `services/generated` 在重生成后会就地跑一遍 `npx prettier --write` 再比较。这会直接改写生成文件,还把「Rust 声明真的变了」与「prettier 版本 / 配置造成的格式漂移」混在同一条告警里——本次报出的 `ThreadRequestKind.ts` / `TurnCompletedStatus.ts`「内容变化」无法复现为语义变化(已提交内容与当前 Rust 枚举一致),prettier 归一化把格式差异也报成了「与 Rust 声明不一致」;生成物被仓库格式化工具二次改写后,重跑 `cargo test export_bindings` 也不再幂等。
- 决策:生成绑定一律以 ts-rs 原始输出提交,不接受 prettier / eslint 等工具二次改写。`check-generated-bindings.mjs` 删掉 prettier 步骤,直接逐字节比较原始输出;`packages/shared/src/contracts/generated`、`chat/generated`、`services/generated`、`features/ui-editor/types` 四个目录统一登记进 `.prettierignore` 与 `.eslintrc.cjs` 的 `ignorePatterns`;`.prettierrc.json` 里为 `contracts/generated` 设的 `printWidth: 1000` / `singleQuote: false` 覆盖随之删除。
- 边界:ts-rs 原始输出在多行对象 / 枚举变体行尾带空格,`.gitattributes` 对四个生成目录设 `whitespace=-trailing-space`,CI 的 `git diff --check` 不再误报;手写文件的行尾空白检查不变。生成 `.ts` 仍提交进仓库供前端消费,并标 `linguist-generated=true`。
- 影响范围:`scripts/check-generated-bindings.mjs`、`.prettierignore`、`.prettierrc.json`、`.eslintrc.cjs`、`.gitattributes`、`apps/ai-game-creator-shell/src/{view/project-development/chat,services}/generated/**`。
- 验证:`cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`、`npm run check:generated-bindings`(shared-contracts 1 + AGC 104 = 105 个文件)、`npx prettier --check` 四个生成目录(全部跳过)、`npm run check:encoding`、`git diff --check`。
## 2026-10-03 回合错误的处置分类层与线程条目脱敏边界
- 背景:`TurnError::terminal_failure -> Option<TurnFailure>` 用一个 `None` 同时表达"没有失败"和"不是失败、要继续跑(返修 / 复核控制流)",调用方看到 `None` 只会理解成前者,`dispatch.rs` 两处只能 `.expect("回合失败必可投影成失败载荷")`;同时 ThreadManager 在搬运线程条目时做字段级脱敏与限长,前端要被截断,而且与失败载荷的脱敏是两套实现。
- 决策(分类层):`TurnError::classify(&self, root) -> TurnErrorClassified{ShouldStop(TurnFailure), ShouldContinue { detail: String }}` 取代 `terminal_failure`——真失败继续投影成 `TurnFailure`(脱敏 + 截断仍在这一处),控制流带自己的说明走 `ShouldContinue`;`dispatch.rs` 两处改成按分类 match,`ShouldContinue` 不写终态、不伪造失败。"这一轮怎么收场"仍只由 `TurnCompletion` 定义,private `SessionOutcome` 并入 `session_completion`。
- 决策(脱敏边界):ThreadManager **不做任何字段级脱敏与限长**——`wire/items.rs` 的 `bounded` / `detail_text` / `sanitize_detail_text` / `relativize_project_root_paths` / `thread_delta_text` 与三个字符上限常量删除,条目与流式增量原样透传;脱敏移到前端 `chat/conversation/directThreadSanitize`,在 bootstrap / consume / 历史切片进入聊天状态之前统一做(前端手里有 `projectPath`,能把项目内绝对路径归一成相对路径)。失败载荷(错误文案)的脱敏不属于 ThreadManager,保留在 `TurnError::classify` 投影时。订阅侧的 8 MiB / 8192 事件缓冲上限不动:那是背压,不是字段限长。
- 边界:错误尚未持久化,`TurnErrorClassified` 是纯宿主内部类型、不加 `Serialize` / `TS`;线上 `TurnFailure` 与 `ThreadEvent` 形状、占位符词表(`<absolute-path>` / `[redacted-secret]` / `[redacted-sensitive-field]` / `[redacted-config]` / `[redacted sensitive context]`)都不变。`TurnFailure` 名字加了 `// TODO badnaming` 待后续改。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{turn_error.rs,mod.rs},thread_manager/{dispatch.rs,turn_completion.rs,wire/{items.rs,failure.rs,tests.rs}},generation/prompt_context.rs}`、`apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs`、前端 `chat/{controller/useDirectThreadChatSubscription.ts,conversation/directThreadSanitize.ts}`、`chat/generated/{ThreadItem,TurnFailure}.ts`、`tests/directThreadSanitize.test.ts`。
- 验证:`cargo test -- agent:: --skip export_bindings`(949 passed;删掉一条已无对象的流式脱敏用例)、`npm run check:generated-bindings`、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests/directThreadSanitize.test.ts`(12 passed)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`;全量 vitest 的 `gameDistributionPublish*` / `recentProjectsHook` localStorage 失败为既有问题。
## 2026-10-02 DirectProject 失败载荷两臂改名与 typed 失败原样下发
- 背景:`turn.completed.failure` 的两个载荷臂把字符串债藏在像正常类型的名字后面(`TurnFailed { stage, detail: String }`、`TurnFailedUnclassified { detail: String }`);同时 `direct_runtime` 与 `thread_manager::dispatch` 各有一处收口把**任意** `TurnError` 重包成 `TurnFailed { stage: turn_failure_stage(), detail: 预拼收口文案 }`,typed 变体(模型调用 / 超时 / 通道断开)在下发前就被吃掉,前端只能看到 catch-all 与一个伪造的 `code-generation` 阶段。
- 决策(命名):两个载荷臂按"这是待清债"的既有约定起丑名字——`TurnFailed → SuperErrorFromStringPlusStage`(只有 `stage` 是 typed、`detail` 仍是产生层字符串)、`TurnFailedUnclassified → Unclassified`(连阶段都没有);线上 `type` 同步为 `superErrorFromStringPlusStage` / `unclassified`,生成文件 `TurnFailed.ts` / `TurnFailedUnclassified.ts` 删除。`stage` 留在变体内部,**不做成 wrapper 层的正交字段**:其余 8 个变体根本没有阶段,包到外层只会给它们编假值。
- 决策(透传):删掉上述两处收口重包,`Err(failure)` 原样透传;`.agent/runtime/errors`、应用日志与错误上报池的审计写盘保留,只是返回值不再当失败载荷的 `detail`。前端因此第一次能按真实变体(`modelCallFailed` / `timedOut` / `transportClosed`…)选文案。
- 边界:不改 `SuperErrorEnumFromStringTyped`(仍在 `is_retryable` / `is_model_repairable` 上做内部分类,detail 的 typed 化留给上游改造);不迁就存量字符串分流、不加别名;错误尚未持久化,线上 `type` 值直接改、不做迁移。
- 影响范围:`agent/codex_app_server/turn_error.rs`、`agent/direct_runtime/{mod.rs,user_input.rs}`、`agent/thread_manager/{dispatch.rs,turn_completion.rs,wire/failure.rs}`、前端 `chat/{conversation/directTurnFailure.ts,generated/**}`、`src/features/agent-runtime/model.ts`(注释)、`tests/directThreadChat.test.ts`。
- 验证:`cargo check`(bin)、`cargo test -- agent:: --skip export_bindings`(948 passed)、`cargo test export_bindings` 后 `npx prettier --write chat/generated/*.ts`、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`(`NODE_OPTIONS=--localstorage-file=…`,196 files / 1930 passed)、`npm run check:encoding`、`git diff --check`。
## 2026-10-02 DirectProject 回合错误命名化与 wire 模块拆分
- 背景:DirectProject 三条错误通道的类型还带着 `Direct` / `DirectCodex` 前缀(`DirectTurnError` / `DirectTurnEnqueueError` / `DirectTurnFailure`),且 `thread_manager/wire.rs`(1600+ 行)把条目投影、事件、失败载荷与终态判定混在一个文件里;`direct_turn_error.rs` 也留在 `agent/` 顶层而不是它服务的 `codex_app_server` 深模块旁边。
- 决策(命名):既然类型已经归到具名深模块,去掉冗余前缀——`DirectTurnError → TurnError`、`DirectTurnEnqueueError → EnqueueError`、`DirectTurnFailure → TurnFailure`、`DirectTurnDeadline → Deadline`、`DirectCodexFailureStage → FailureStage`、`DirectCodexNativeKind → NativeKind`、`DirectModelCallKind → ModelCallKind`;`direct_codex_user_item` 里的 `DirectCodexUserItem/UserMessageItem/UserRole/UserContentPart/UserAttachmentReferencePart/UserRuntimeRegionPart → UserItem/UserMessageItem/UserRole/UserContentPart/UserAttachmentReferencePart/UserRuntimeRegionPart`;`codex_app_server` 内部的 `DirectCodexTurnKind/DirectCodexTurnObservation/DirectTurnRunFailure/DirectTurnReport/DirectTurnTerminalContext/DirectTurnCancelView → TurnKind/TurnObservation/RunFailure/TurnReport/TerminalContext/TurnCancelView`;`direct_now_ms → now_ms`。载荷 struct 名仍是"变体裸名",`TurnFailure` 的投影点仍是 `TurnError::classify` 一处。
- 决策(模块归位):`agent/thread_manager/wire.rs` 提升为目录并按职责拆分——`wire/{mod,clock,items,turn,failure,tests}.rs`,`wire/` 只留**线上形状**(`TurnFailure` 载荷与 `ThreadEvent`);`agent/direct_turn_failure.rs` 的终态判定并入 `agent/thread_manager/turn_completion.rs`;`agent/direct_turn_error.rs`(三条错误表与分类)移到 `agent/codex_app_server/turn_error.rs`。`thread_manager::wire::*` 仍从 `mod.rs` 平铺 re-export,外部路径不变。
- 决策(终态类型):宿主侧的回合终态从 `struct TurnTerminal { status, failure }` 改为判别联合 `enum TurnCompletion { Completed, Interrupted, Aborted, Failed(TurnFailure) }`——`status` 由变体反推(投影回 `turn.completed` 时才变回字符串),失败必须带载荷、正常收场不许带;「有载荷就一定是失败」这条反推关系不变。`TurnCompletion` **不是线上形状**:不加 `Serialize` / `TS`、不导出、不下发,放在 `thread_manager/turn_completion.rs` 而不是 `wire/`;收尾阶段推出来的 `status` 字符串在这里一次性收进类型,认不出的值 fail closed(按未分类失败),不冒充正常收场。`thread_manager/mod.rs` 的过期回合释放兜底也改走 `TurnCompletion::Aborted.event(...)`。
- 边界:只改类型 / 函数 / 生成文件命名与模块位置,不改线上 JSON 形状(`TurnFailure` 的 `type` 判别值与字段名不变,`TurnCompletion` 只影响宿主内部);不迁就存量字符串分流,不保留别名或 `Display` 回落。`DirectTurnFailureKind`、`wire_kind()`、`Display for TurnError` 与 `From<TurnError> for String` 已在上一轮删除。
- 影响范围:`apps/ai-game-creator-shell/src-tauri/src/agent/{codex_app_server/{mod.rs,execution.rs,turn_error.rs},thread_manager/{mod.rs,dispatch.rs,wire/},direct_runtime/{mod.rs,user_input.rs},direct_codex_user_item/*,runtime_driver/entrypoints.rs,...}`、前端 `chat/{controller,conversation}/**` 与 `chat/generated/**`(旧绑定文件删除、新绑定文件随 `cargo test export_bindings` 生成)、`scripts/check-generated-bindings.mjs`、`tests/**`。
- 验证:`cargo check`(bin)、`cargo test -- agent::thread_manager`(69 passed)与 `cargo test -- terminal`(73 passed)、`npm run ai-game-creator-shell:typecheck`、`npx vitest run apps/ai-game-creator-shell/tests`(DirectProject 相关用例全绿;`gameDistributionPublish*` / `recentProjectsHook` 的 localStorage 失败为既有问题,与本次无关)、`npm run check:generated-bindings`(104 个文件)、`npm run check:encoding`、`git diff --check`。
## 2026-10-02 资源编辑远端失败的原始原因穿出到工具错误,资源编辑错误通道补一层 typed
- 背景:轮询到 `status=failed` 时客户端只读 `status`,丢掉平台在同一个响应里给的 `error`(契约 `ExternalEditorGenerationJobResponse.error`),统一写 `terminal_failure_code = remote-generation-failed` 并返回「remote-terminal-failed: 资源编辑生成失败」。平台的可行动原因就此消失:模型与用户卡片只看到一句「失败了」,重试路径(`ensure_resource_edit_phase_resumable`)也只有分类码。这违反 `pitfalls.md`「远端资源编辑终态必须指出唯一出口」里已写下的口径——「首次失败的原始拒绝说明继续由当次错误文案承担」;提交期 HTTP 400 分支(`editor_api_rejection_reason`)兑现了,轮询分支没有。另外 `remote-terminal-failed:` 只是文案前缀(全仓没有 `starts_with` 解析它),在第一句失败文案里与「失败」重复。
@@ -53,6 +53,8 @@
## 验证路由
AGC 生成准备只通过单目录 GET 确认当前账号绑定,不能重新依赖完整素材库,也不能把目录查询失败当成首次无绑定而创建目录。目录删除时保留证据并停止;`prepared / accepted / legacy-completed` 生成账本继续用冻结快照恢复。该查询同时覆盖站内会话与 External v1,后端新增接口先发布,旧 API 保持兼容;详见 AGC 实施计划和后端数据契约。
Windows 下的移动壳 smoke 通过 Node 启动从当前 workspace 包解析出的 Expo/EAS CLI,不直接 `spawnSync('npm.cmd')`;保留原配置与导出断言。具体入口和警告清理边界见本地开发运维文档。
AGC 的纯桌面/生产接入按模块排除测试编译,共享实现与受测入口保持可测试;拆分后同时检查普通目标和测试目标,并保留原有行为断言。命令注册与退出接线的源码检查跟随真实模块位置更新,不能因移动文件而漏检;不通过全局允许死代码或虚假调用消除告警。具体边界见开发运维文档“编译告警的保留边界与待优化项”。
+18 -4
View File
@@ -2,13 +2,21 @@
这里只记录对当前开发仍有用的症状、根因、排查方法和风险边界。同一事实保留一个当前口径;退役对象的专属过程与单轮测试结果由 Git 历史追溯。遇到旧路径或版本时,以现行代码和专题文档为准。
## 2026-10-03 AGC 栏目画布上传素材落「待归类」:kind 派生分类不等于入口栏目
- **现象**(Issue 359):在 AGC 资源栏目子画布(如「UI 交互」「角色与对象」)左下角工具栏点「上传」选图片 / 视频 / 代码类文件,提示条给出「已上传 1 个素材」,但当前栏目计数纹丝不动(仍「0 项」),素材出现在「待归类」。用户看到的是"上传成功了,可它就消失在这个页面里"。
- **原因**:上传登记的 manifest `kind` 只由**内容证据**推导(`assets.rs::uploaded_asset_kind`:图片 / 视频 / 代码 → `unclassified`,音频 → `audio`,文档 / 字体 → `document`),kind 派生分类与栏目词汇(`ui-interaction` / `character` / `scene` / `audio`)不是同一套;`upload_local_asset` 原先不接受入口栏目,GUI 工具栏上传只能落 kind 派生分类。
- **处理(现行口径)**:`upload_local_asset` 增加可选 `targetCategory`,Rust 走既有的 `register_local_asset_entry_with_category`(非法值失败关闭,不回退 kind 派生);栏目画布工具栏上传时取工具栏自己的栏目(`resourceCanvasBottomToolbarCategory`)。生成入口 `start_local_project_asset_generation` 的 `targetCategory` 是同一口径——GUI 完成登记以入口栏目为准。
- **判据/取证**:`cargo test --locked ... --bin genarrative-ai-game-creator-shell upload_` 的 `assets::tests::upload_registers_into_the_explicit_entry_category`(显式栏目 → `character`;不传 → `unclassified`;非法 `version` 失败关闭且不新增登记);appSurface「uploads toolbar files into the entry column so they stay visible where they were uploaded」断言工具栏上传载荷带 `targetCategory: 'character'`。
- **边界**:资源面板(跨栏目列表)、UI 编辑器图片导入、聊天附件上传都**不带**入口栏目,保持 kind 派生。任何新增的「某个栏目里的上传入口」都必须显式带上当前栏目,否则又会复现本坑。
## 2026-10-01 用户输错一次密码被当成"客户端出问题了"引导上报
- **现象**:登录页密码输错(或密码长度不合规)后弹出「发现问题」,报告面板「错误事件(2)」列出 `密码长度需要在 6 到 128 位之间 — auth · 1 次` 与 `手机号或密码错误 — auth · 1 次`,默认全选,与 react-render / 5xx / agent-runtime 终态失败视觉等价。
- **原因**:① 登录已下沉 Rust,`login_client_with_password` 等命令失败返回 `Err(String)`,Tauri 以**裸字符串**拒绝 `invoke`,前端拿不到任何类型信息;② `shouldCaptureClientError` 对非 object 值走默认 `return true`,`handleLoginSubmit` 的 catch 把预期业务拒绝报进了错误池。技术方案里"预期 4xx 登录/鉴权失败不进池"的口径早就成立,是错误通道的实现方式违背了它。
- **处理(现行口径)**:命令错误一律按具体变体结构化(`Result<_, ClientAuthError>` + ts-rs 导出),认证命令统一经 `invokeClientAuth` 调用:结构化拒绝原样装进已有的 `ClientAuthErrorWrapper`(只有一个 `error` 字段,值是判别联合),UI 在 catch 里按具体变体分流——认得的业务 / 会话变体只给用户反馈,系统变体原样 `throw` 经 `unhandledrejection` 入池,非结构化拒绝原样抛出;删除 `shouldCaptureClientError`。详见 [`【ADR】AGC命令错误结构化与错误报告口径-2026-10-01`](../../adr/【ADR】AGC命令错误结构化与错误报告口径-2026-10-01.md) 与 [`【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01`](../../adr/【ADR】AGC认证失败的JS侧载体与抛出时机-2026-10-01.md)。
- **判据/取证**:`npx vitest run apps/ai-game-creator-shell/tests/authFailureReporting.test.tsx`——登录返回结构化业务变体时 `report_client_error` 不被调用;系统变体只上报一次(`source` 取全局 `unhandledrejection` handler 的显式入参;载体不再携带 `action`)。Rust 侧 `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml auth_error` 钉住变体 `type` 与 400/401/429/5xx/网络映射。
- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物随后交给 prettier;ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。
- **形状约定**:`ClientAuthError` 顶层只放调用方要分流的类别,可枚举细分收进类型化枚举 `reason` 字段——无字段变体在 TS 里就是 `{ type: 'x' }`;带载荷变体是 `{ type: 'x' } & X`,`X` 由 ts-rs 导出到 `src/services/generated/X.ts`(Rust 侧是 newtype 变体持有同名结构体),细分原因枚举(`ServerAddressReason` / `AuthNetworkReason` / `AuthResponseInvalidReason`)同样由 ts-rs 生成。**不要手写这些类型**,也不要在前端再加一层分类 / 提示文案派生函数——判别一律写在 catch 子句里:`const failure = error.error; switch (failure.type)`,无字段 `case` 用本 catch 的固定文案,带载荷 `case` 先 `as X` 再读自己的字段,`reason` 是枚举时再 `switch (payload.reason)`,两处 `default` 都用 `expectNever` 保证漏接编译失败。改形状只能改 Rust 再跑 `cargo test` 重新导出,生成物保持 ts-rs 原始输出(不再经 prettier / eslint 二次改写,见本节「生成绑定」口径);ts-rs 只写文件、不删文件,变体从有载荷改成无字段时要手动清掉孤立的 `X.ts`(本次 `AuthResponseServerRejected.ts` 就是这样删的)。
- **Rust 侧不得把结构化错误降级成字符串**:`refresh_session_inner` 的非权威失败直接返回 `Err(ClientAuthError)`,视图不带 `errorMessage`;一旦折成 `String`,前端就只能拿文案判断,变体信息永久丢失。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/auth_session.rs`、`apps/ai-game-creator-shell/src/services/{clientAuth.ts,errorReporting.ts,platformSession.ts}`、`apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx`。
@@ -72,6 +80,7 @@
- **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。
- **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。
- **现状(2026-10-03 更新)**:逐 delta 的 Rust 脱敏已整体退役——ThreadManager 改为原样透传,流式正文与历史条目的脱敏统一移到前端 `directThreadSanitize`,在事件进聊天状态前逐段处理并保留换行。上面两条根因与"仍未修"针对的实现已不存在,保留作历史。
## 2026-10-01 文件列举被无关临时锁删除打断
@@ -5794,7 +5803,8 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后只恢复 `apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUser*.ts` 的仓库版本,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`;不要恢复整个 `generated/` 目录,以免误删 DirectThread 的现役绑定。绑定与前端形状冲突时以**已提交的 DirectCodexUser 绑定 + 前端**为基准排查。
- **验证**:恢复仓库版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的生成产物;它们不属于前端契约,发现后直接删除,不提交。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`(ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`beforeBuildCommand`)。
- **2026-09-24 补充(纯格式 churn 的一档)**:直接运行 AGC 的测试二进制(含全量套件,里面带 `export_bindings`)同样会把 `src/view/project-development/chat/generated/DirectThread*.ts` 重写成 raw ts-rs 形态——双引号、行尾空格、`};` 变 `, };`——于是 `git diff --check` 报一片 trailing whitespace,而 `npm run check:generated-bindings`(只覆盖 `packages/shared/src/contracts/generated`)不会报警。处理:对这组文件跑一次 `npx prettier --write "apps/ai-game-creator-shell/src/view/project-development/chat/generated/*.ts"` 做归一化,再确认 `git diff --stat` 为空——为空就说明这一档只是格式 churn,没有语义变化;不要为它盲目 `git checkout` 整个目录。该目录目前没有自动漂移门禁,靠「跑完测试后归一化 + diff 检查」把关。
- **2026-09-24 补充(纯格式 churn 的一档)**:直接运行 AGC 的测试二进制(含全量套件,里面带 `export_bindings`)同样会把 `src/view/project-development/chat/generated/DirectThread*.ts` 重写成 raw ts-rs 形态——双引号、行尾空格、`};` 变 `, };`——于是 `git diff --check` 报一片 trailing whitespace,而 `npm run check:generated-bindings`(只覆盖 `packages/shared/src/contracts/generated`)不会报警。处理(2026-10-03 更新,口径已变):不再跑 prettier 归一化——`chat/generated` 与 `services/generated` 的提交形态就是 ts-rs 原始输出;两个目录已进 `.prettierignore` + eslint `ignorePatterns`,`git diff --check` 由 `.gitattributes` 的 `whitespace=-trailing-space` 豁免,`npm run check:generated-bindings` 会逐字节比较原始输出。跑完 `cargo test` 后 `git diff` 为空即为幂等。
- **2026-10-02 补充(过滤跑也躲不开)**:`cargo test` 的位置参数是**子串匹配**,`-- agent::thread_manager::` 这类"只跑几个模块"的过滤会连带命中 `agent::thread_manager::wire::failure::export_bindings_turnfailure`,于是照样重写 `chat/generated/` 与 `src/services/generated/`(后者是 auth 绑定)。要按模块跑又不碰生成目录,用 `cargo test --bin genarrative-ai-game-creator-shell -- agent:: --skip export_bindings`;已经被重写时不要再做 prettier 归一化:两个目录的提交形态就是原始输出,按需重新生成或 `git checkout --`,再用 `npm run check:generated-bindings` 确认无漂移。
## 2026-09-16 Node 26 下 vitest 的 jsdom 用例拿不到 window.localStorage
@@ -6277,7 +6287,11 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **根因 2(缺落盘)**:assistant 回复只存在于 SDK 事件流里。codex 路径由 `finish_direct_project_collect_history` 写进 `.agent/conversations/project.jsonl`,cc 没有对应步骤,所以即使回合收口成功,UI 也读不到回复。
- **根因 3(工具被拒)**:sidecar 用 `permissionMode: 'dontAsk'` 且没有 `allowedTools`,宿主 MCP 工具(`mcp__agc__*`)一律被直接拒绝,模型只能回"没有权限"。
- **根因 4(界面看不到回复)**:聊天区是按 `item.completed` 事件流投影的(codex 路径在 `rawResponseItem/completed` 时下发 `ThreadItem::Message`),只把回复落进 `project.jsonl` 不会让本轮出现在界面上——用户看到"用户气泡 + 本轮结束于 … · 耗时",回复只在重进项目时从历史读出来。
- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 解析成功后必须①把回复按 `{"type":"message","role":"assistant","id":"direct-codex:<clientTurnId>:assistant","content":[{"type":"output_text","text":…}]}` 落进项目历史,②用**同一个 id** 下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`(落盘失败按回合失败收口);sidecar 按 `mcp__<server>` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。
- **根因 5(验收反馈复用 assistant ID)**:同一 client turn 进入 `ReviewRequired` 后会再次调用 cc。首次回复已经占用 `direct-codex:<clientTurnId>:assistant`,第二次不同正文沿用该 ID 会被历史层正确拒绝为冲突,随后却被错误投影成 `runtime-unclassified`。真实诊断中可见「写入本项目对话历史失败:…assistant」且历史已经有该条回复。
- **根因 6(cc 字符串错误覆盖了上游分类)**:DirectProject 的 Claude Code 路由原本把 sidecar 返回的所有字符串都包装成 `LlmError::Transport`,因此 HTTP 429、401、408、5xx、sidecar 超时、空回执和无效 JSON 都显示成「执行通道未能建立或已断开」。
- **根因 7(内部 Claude MCP 桥误用外部只读模式)**:Claude Code sidecar 使用的 loopback MCP 原本调用 `start_external_client_tool_bridge(..., false)`,桥状态 `direct_turn_execution=false` 且没有 `begin_user_turn()` 授权;`agc_register_delivery_contract`、`agc_delivery_status`、`agc_update_plan` 每次都会返回 `ToolRequiresDirectTurn`,模型收到错误后又重复注册计划,最终陷入反馈死循环直到超时。
- **根因 8(MCP 全局单槽位)**:外部 MCP 注册表原本只有一个 `Option<ExternalMcpServer>`,不同项目的 Claude 回合会互相 abort;一个回合结束时的全局 stop 还可能误停另一个项目的桥。
- **现行口径**:cc 成功出口由放行侧补写 `DirectTurnTerminal::completed()`(`finish_if_unfinished` 幂等,codex 已写过终态时是空操作);cc 每次解析成功后都把实际落盘的回复 item id 同步下发 `ThreadEvent::item_completed(ThreadItem::Message{role:"assistant"})`。首个回复沿用 `direct-codex:<clientTurnId>:assistant`,同一回合的反馈回复遇到内容冲突时追加 `:assistant:<uuid>`,相同内容仍按原 ID 幂等;落盘失败按回合失败收口。Claude Code 的失败文本先投影到与 Codex 相同的 `LlmError` 分类:HTTP 状态、sidecar 超时、空回执和无效 JSON 分别复用上游、超时、空响应和反序列化语义;上游状态摘要与重试建议按状态码给出。内部 Claude Direct MCP 必须使用 `direct_turn_execution=true` 的工具桥并持有 `begin_user_turn()` guard;用户手动启动的外部 MCP 仍保持非 Direct 模式。MCP 注册表按 canonical project root 分桶,停止操作再核对 server token,迟到的旧回合不能误停同项目的新桥。sidecar 按 `mcp__<server>` 前缀整体放行请求里声明的 MCP 服务器(权限策略在宿主侧执行)。
- **诊断口径**:`agent.direct_turn.host_dropped` / `agent.direct_turn.panic` 里的令牌字段必须写 `tt=`,写 `turnToken=` 会命中脱敏标记,整行变成 `<sensitive diagnostic details redacted>`,离线只剩"说不出原因"的 HostDropped。
- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs <expr>`):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`<!doctype html>` / `<html lang="zh-CN">`)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。
- **验证**:dev 栈里用 CDP 注入真实回合(`node %TEMP%\agc-cdp.mjs <expr>`):①读文件轮 `claude-parse-done chars=108`,`.agent/conversations/project.jsonl` 出现 `direct-codex:cdp-…:assistant` 条目,回复内容与 `game/index.html` 前两行(`<!doctype html>` / `<html lang="zh-CN">`)逐字一致(证明宿主工具真的执行了);②聊天视图打开时注入 `只回三个字:收到了`,DOM 断言(`document.body.innerText`)同时出现用户气泡 `11:40:05`、助手回复 `收到了` 与 `本轮结束于 11:40:16 · 耗时 10.7秒`(证明 `item.completed` 实时投影生效,不必重进项目);同一日志不再出现新的 `host_dropped`。另有 `agent::claude_code_cli::tests::direct_claude_feedback_reply_does_not_fail_on_a_reused_client_turn_id` 回归覆盖同一回合两次不同回复。`cargo test … -- claude_code_cli::tests direct_turn_failure::tests` 19 passed。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs`、`apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs`、`apps/ai-game-creator-shell/agent-sidecar/src/index.mjs`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

@@ -6,7 +6,7 @@
## 1. 一句话
功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`;生成 / 上传成功后一律走既有 manifest 刷新与资源定位。
功能画布 = 资源栏目页;栏目页左下角渲染一条由栏目 `category` 决定的工具栏,图片类与音频入口都走本地 `start_local_project_asset_generation`(提交即返回、后台生成,见 2026-09-20 音频并入后台任务账本),上传复用 `upload_local_asset`(带当前栏目 `targetCategory`,素材登记进上传它的那一栏);生成 / 上传成功后一律走既有 manifest 刷新与资源定位。
## 2. 入口矩阵(事实源)
@@ -42,7 +42,7 @@
| 生成 UI 设计图 | 同上 | `kind: 'ui-design'`,默认 `16:9 · 1K`;前置同上 |
| 生成背景音乐 | `start_local_project_asset_generation` | `kind: 'background-music'`、`idempotencyKey`;任务 id 即该次生成的 operation id |
| 生成音效 | `start_local_project_asset_generation` | `kind: 'sound-effect'`、其余同上 |
| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes }` |
| 上传 | `upload_local_asset` | `{ projectPath, fileName, mediaType, bytes, targetCategory }`;`targetCategory` = 当前栏目(Issue 359) |
`start_local_project_asset_generation` 的完整参数是 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath, idempotencyKey }`(Rust `src-tauri/src/asset_generation_tasks.rs`);图片类入口沿用 `{ projectPath, projectId, taskId, kind, prompt, aspectRatio, imageSize, assetName, outputPath }` 逐字不变,音频入口只带 `{ projectPath, projectId, taskId, kind, prompt, assetName, idempotencyKey }`(`idempotencyKey` = 该次生成的幂等键,任务 id 即 operation id;原生命令内部仍复用既有音频无源生成链路,不新增平台路由与请求体口径),**提交即返回**一条任务记录(`taskId / status / phaseDetail / assetId / error` 等);生成在 `tauri::async_runtime::spawn` 出来的后台任务里跑,账本落在项目内 `.agent/runtime/asset-generation-tasks/tasks.json`,进度由 `list_local_project_asset_generations` 读回。截图面板不再「等生成结束」,所以**点「生成」即同步关闭面板**(不等 IPC),面板内不出现阶段文案;只有「点击瞬间就失败」才带草稿重开(关闭 ≠ 取消)。
@@ -80,8 +80,8 @@
- `project.jsonl` 与项目主对话共用信封类型,不要为了"可见但不喂模型"新增行结构。
- 埋点 `settle` 早于成绩入库会静默丢事件(未来"进历史但不喂模型"的条目同理要落在注入侧,不在读取侧)。
- `cargo test export_bindings` 会重写全部 `chat/generated/`(引号风格漂移),跑完要 `git checkout --`
掉不是本次新增的文件。
- `cargo test export_bindings` 会重写全部 `chat/generated/`;生成目录的提交形态就是 ts-rs 原始输出
(2026-10-03 起已从 prettier / eslint 排除),跑完用 `git diff` 为空确认幂等,不要做 prettier 归一化。
- 本机 rust 全量 `--bins` 测试会挂在 mock server 的 `inet_csk_accept` 上,用 `--bins "agent::"` 之类过滤跑。
## review 收口第二轮(2026-09-24)
@@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
- 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。
- 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。
- 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。
- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。
- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。
- 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。
### 跑酷固定基线
@@ -188,13 +188,15 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema
### 环境与工作流
- 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-environment-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。
- 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。
- 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。
### 分层验证与预算
- 复用客户端浏览器与现有固定玩法场景。视觉检查采集双端画面/布局/资源/诊断;玩法检查分别在 desktop/mobile 执行明确的固定场景和真实输入,按视口保存结果。旧报告缺少移动端玩法结果时保持未知,不补通过。报告必须声明检查层级;视觉通过不能宣称玩法通过,固定场景通过也不能宣称覆盖未执行的完整关卡。
- 浏览器工具回执在 `mode=gameplay` 时新增 `gameplayResults` 双端有界投影,每个视口包含 `viewport`、`passed`、`diagnostics`、`assertions` 及 `initialPhase` / `initialSequence` / `initialLevel`、`finalPhase` / `finalSequence` / `finalLevel`。`assertions` 仅列当前固定场景的断言名称和通过状态;每端最多公开 4 条诊断,每条最多 512 字符。缺少某端结果时仍返回该视口 `passed=false`,诊断说明证据缺失,断言为空且六个状态字段为 `null`;断言的 `passed=false` 可包含未执行,不能据此断言该断言已独立执行且失败。`mode=visual` 的 `gameplayResults` 为空,并在摘要中明确玩法未执行。摘要从同一投影给出每端首条诊断和首个未通过断言,不能与结构化结果相矛盾。工具回执不得展开完整宿主报告;`reportPath` 只作宿主证据引用。宿主报告结构、验收门禁、持久化预算和私有路径保护保持原合同,旧回执不回填新字段。
- 回执验收覆盖 start 禁用、点击后 phase 不符合和玩法成功;逐视口核对持久报告与摘要中的状态、首条诊断和首个未通过断言,并检查成功、失败两条最终 MCP 回包路径。
- 输入或碰撞改变先做定点玩法检查;纯图像/颜色变化做视觉检查;首次交付和影响闭环的修改做所需玩法验证。新增失败或相关代码变化才重跑对应层,不因改说明文字重复完整验证。
- 内置试玩和客户端托管的外部 Node/npm 验证共用当前 clientTurnId 的持久化预算。客户端分配递增执行序号,模型提供的旧 attempt 仅作兼容输入,不能减少计数或重置预算;同一轮错误反馈、工具切换和进程重启均不能刷新已消费次数。
- 新增本地 validation.maxRuns(默认 3,正整数)独立于 llm.maxRetries;显式配置原样使用,不按角色或运行模式改写。超限直接返回已用/上限和最近证据,停止新的验证。预检与正常构建不计作重复试玩。
@@ -329,7 +331,7 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
- 参考选择范围为同一项目已登记图片,可跨栏目、多选,无规范前置时最多 5 张,有规范前置时最多 4 张用户参考(总计最多 5 张);复用资源引用选择组件,不允许文档、音视频、占位或跨项目素材。本地生成命令补最小引用 ID 参数并转换为当前账号绑定下的远端资源 ID,沿用图片生成 API 已有 `referenceImageSrcs`。需要规范图的普通图片请求合并并去重规范引用,总数不超过现有 API 限制;只接受单规范引用的图集操作不显示用户参考选择器,原生提交拒绝额外参考而非静默丢弃。不得降级成纯提示词。
- 占位由宿主按项目与独立草稿 ID 管理,提交后关联任务 ID;失败重试使用同一占位。切项目清理未提交草稿与界面位置,已提交任务继续沿用账本恢复,重开后不承诺恢复未持久化的占位位置。迟到结果先核对项目和任务归属;只有本会话仍存在的占位才应用最新位置。删除占位只隐藏展示,不取消后台任务或丢弃正式结果。
- 参考必须是原生可解码的栅格图片,SVG 不进入参考候选;原生在上传任何引用前预校验整组素材的归属、受控路径、文件及解码,失败不静默丢图。需要重新上传当前账号绑定的参考遵循 `asset.upload` 权限。manifest 读侧的引用形状检查不证明远端账号归属,实际生成始终通过当前账号 binding 解析,不凭历史来源 ID 发起请求。
- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。
- 图片类 GUI 生成通过可选 `targetCategory` 在原生登记时写入入口栏目,使用既有 manifest `category` 字段及合法分类词表;不传时保留按 kind 派生的行为,Agent 不传。该值不改变远端生成内容及计费幂等槽,仅决定本地生成结果分类;重试保持原占位栏目。同路径重新生成时,主产物按本次入口栏目更新分类(包含覆盖此前手动分类),图集附属切片保持既有独立分类规则。GUI 上传(栏目画布底部工具栏的 `upload_local_asset`)与图片类生成**同一口径**:带可选 `targetCategory` 在当前栏目登记,不传(资源面板、UI 编辑器导入、聊天附件)时保持按内容证据派生 kind 的行为。
- 生成面板打开后,占位和面板需处于当前画布标题栏与底部工具栏之间;面板复用公共外观,空间不足时面板内部滚动,提交按钮可达。音频/BGM 占位绑定原有 operation/idempotency 身份,进行中不允许换身份重复提交;失败可用原身份重试,成功结果与图片一样接管占位。关闭未提交浮层保留可继续编辑的草稿,删除占位才丢弃该草稿。
- 整理范围为当前栏目页全部资源;“所有资源”页为当前项目所有可展示资源,总览不新增整理行为。重排结果成为自动坐标,可撤销恢复原坐标与手动标记;历史仅保留当前会话,切项目清空。多选仅作用于当前画布可见选中资源,不携带筛选隐藏或跨栏目残留选择;取消手势恢复拖动前坐标,切项目清空选择。
- 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。
@@ -946,6 +948,10 @@ Agent Runtime 负责:
- 2026-07-10 补充:后台任务工具箱已加入 `preview.start`。Agent 可在 loop 中自行请求启动当前项目的本地 HTTP 预览;Runtime 会复用 `preview.start` 策略、项目写锁、共享 `PreviewRegistry`、manifest 预览状态、`.agent/logs/preview.log` 和 run trace 追加逻辑,并把 `agent.runtime.preview.start` 写入 `.agent/agent.db`。该 observation 只向 LLM 返回 localhost URL 与端口,不返回用户项目绝对路径。
- External Editor 的远端项目和资源属于服务 principal,不属于本地项目的跨账号身份。`.agent/manifest.json` 中 `source.canvasProjectId / resourceId / assetObjectId` 只记录生成来源,不得作为当前登录账号的 active editable binding。客户端在 `.agent/runtime/external-editor-bindings/` 私有保存账号级投影:项目键至少包含服务 origin、principal 和本地 `projectId`,资源键再包含远端项目、本地 asset ID、源文件 SHA-256、媒体类型与 canonical asset kind;值保存该 principal 下的远端 project/folder/resource/object 身份。Token 轮换继续复用同一 principal binding,账号、服务、源字节或 canonical kind 变化必须重新建立;同名本地项目和项目改名不得参与权威匹配。
- binding 的 lookup key 与稳定 payload 内容指纹分开校验;远端 project/folder/resource/object ID、object key 或尺寸被单独改写时必须失败关闭。同一本地项目和 principal 的首次项目 binding 建立必须串行化覆盖“复读 binding → 必要的远端创建 → 原子安装 binding”,不得因并发任务创建孤儿画布。External v1 项目、素材目录和项目资源创建请求携带由 binding key 派生的稳定 `Idempotency-Key`;服务端在 owner 与接口命名空间内生成稳定 ID,并允许同键同正文安全重试,使远端响应后、本地 sidecar 落盘前崩溃也不会重复创建。
- 素材生成准备的目录确认采用单目录读取:站内 `GET /api/editor/assets/folders/{folderId}` 与开发者 `GET /api/external/v1/editor/assets/folders/{folderId}` 返回现有 `{folder: ...}` 数据结构,后端按目录主键读取并校验 owner,不读取账户素材、不创建默认目录、不执行媒体修复。External 入口要求 `editor:asset` scope;缺失目录返回 404,越权返回 403,未认证返回 401。原素材库快照及目录创建、修改、删除接口的合同不变;不修改持久化表结构。先部署新增后端接口,再发布依赖该接口的客户端;新客户端不回退完整素材库。
- 已有当前账号项目 binding 或恢复 partial 的目录身份时,在新的生成准备中按该 ID 确认目录并核对响应 ID;合法空目录也可继续。首次没有可复用目录身份时直接沿用稳定幂等创建和冻结正文,不先列举素材库,不按同名认领目录。partial 继续只承载中间恢复进度,成功写入完整 binding 后删除;既有生成账本的 `prepared / accepted / legacy-completed` 恢复仍使用冻结快照,不重新查询或创建目录。
- 单目录查询超时、断网、服务异常、鉴权失效、越权或会话切换时停止准备并保留现有 binding / 恢复证据,不解释为目录缺失、不自动 POST 新目录。明确 404 时提示绑定目录不存在并停止,不用原键复活目录、不换键重建或重发生成;自动重新绑定与素材分页、关键词筛选、按素材 ID 导入不属于该行为。验证覆盖全库不可用而单目录正常、空目录、首次建绑、缺失与越权、查询失败及切号,并保留并发单次创建、创建响应丢失、冻结正文及生成账本恢复回归。
- 单目录合同的定向验证使用 AGC Rust `external_canvas_context`、`canvas_generation_tests`、`project::resource_editor::tests`、`background_removal_tests` 与 `direct_tools_mcp::tests`,工具链 HTTP 夹具按单目录 GET 返回 `data.folder`;后端使用 `external_editor`、`external_mcp` 和 `protected_route_matrix_keeps_auth_methods_and_head_behavior`,覆盖 OpenAPI 自动增加单目录 MCP 工具后的目录数量及名称唯一性。`npm run check:editor-idempotency-procedures` 在独立 SpacetimeDB 中验证真实目录读取、归属、删除与只读行为,host 单测不能代替该运行时证据。
- 当前 binding wire schema 为 v2,payload fingerprint 必填。已落盘的 v1 project/resource JSON 只能经独立严格 wire 结构读取,在 lookup key、principal、本地 source、远端字段、对象键、时间和尺寸等不变量全部通过后重算指纹并原子回写 v2。不得用 `serde(default)` 把未知或篡改文档混入迁移。
- 已完成的本地正式资源以本地文件和 manifest asset 身份为权威。切换账号后,当前账号没有资源 binding 时,必须从本地字节上传、confirm 并登记到当前账号的远端项目后再编辑或派生;任何请求都不得把旧账号的 `canvasProjectId / resourceId / objectKey` 与新账号 Token 组合。新绑定不得覆盖历史来源,也不得迁移或删除旧账号远端对象。已经 `prepared / accepted / running` 的生成或编辑 operation 仍冻结原 principal、原正文和原 operation;切换账号只能停止恢复并保留对账证据,不能改绑、重放或重复扣费。
- 远端提交返回 `operationId` 后先持久化 accepted 身份,再在每次 poll、download 和本地 commit 前复验冻结会话;同步本地 commit 持有冻结会话租约,使切号只能发生在完整安装前或安装后。Tauri 手工生成同样使用稳定的 durable operation slot,不得使用进程内临时幂等键。历史资源编辑账本从 manifest 远端 resource ID 迁移到本地 asset ID 时,只允许在本地 asset、路径和内容摘要仍一致时兼容旧指纹,新请求不得恢复信任 manifest 远端 ID。
File diff suppressed because one or more lines are too long
@@ -0,0 +1,310 @@
# 支付服务接入与收银台使用说明
> 适用版本:2026-10-03 支付应用与订单收银台里程碑
>
> 本说明面向三类使用者:平台账户管理员、接入平台支付的游戏或外部产品开发者、负责查看订单与回调的后台运营人员。
## 1. 服务范围
平台提供一套统一支付服务:接入方提交商品、订单和金额,平台使用已配置的微信支付商户号创建 Native 支付订单,并返回网页收银台地址与动态二维码。用户在网页中扫码付款,平台收到微信支付结果后更新订单,再向接入方配置的回调地址通知。
当前里程碑的边界如下:
| 能力 | 当前状态 |
| --- | --- |
| 微信 Native 扫码支付 | 已支持 |
| 游戏客户端或外部产品接入 | 已支持,统一使用 External v1 API |
| 网页收银台 | 已支持,桌面端双栏、移动端上下布局 |
| 订单记账、查单、回调通知 | 已支持 |
| 后台订单与回调投递记录 | 已支持 |
| 自动分账、自动提现、人工结算工作流 | 本里程碑不包含,暂由平台人工处理 |
| 支付宝、退款、周期扣款 | 本里程碑不包含 |
金额以整数分传递,默认币种为 `CNY`。生产环境必须使用平台已经开通接口的微信支付商户号;前端和外部产品都不能直接持有商户证书或调用微信支付接口。
## 2. 一次完整支付流程
1. 平台账户管理员在“我的 → 支付服务”创建一个支付应用。
2. 平台生成该应用的支付 API Key。完整 Key 只在创建成功时显示一次,应立即复制到接入方服务端的密钥管理系统。
3. 外部产品服务端使用 API Key 和 `Idempotency-Key` 创建订单。
4. 平台服务端向微信支付商户号下单,返回 `checkoutUrl` 和 `providerQrCode`。
5. 外部产品将用户跳转到 `checkoutUrl`,或在自己的客户端中打开该网页。
6. 用户使用微信扫描收银台二维码付款。
7. 微信支付通知平台,平台校验订单号、金额和支付状态后将订单改为 `paid`。
8. 如果应用配置了成功回调地址,平台通过回调投递器通知外部产品;外部产品再按自己的业务规则发放道具、虚拟币或权益。
9. 外部产品遇到网络超时或回调延迟时,使用查单接口恢复订单状态,不要换一个幂等键重新下单。
## 3. 创建和管理支付应用
### 3.1 打开入口
登录平台后进入“我的”,选择“支付服务”。未登录时会看到登录入口:
![支付服务未登录入口](technical/assets/payment-service-20261003/profile-payment-login.png)
图 1:支付服务入口截图。截图来自本地前端预览环境,未登录状态不会读取或展示任何支付应用。
### 3.2 创建应用
在“创建接入应用”中填写:
| 字段 | 填写规则 |
| --- | --- |
| 应用名称 | 用于区分游戏或外部产品,例如“星港商城”或“我的游戏支付” |
| 支付成功回调地址 | 可选。建议填写接入方服务端的 HTTPS 地址,例如 `https://game.example.com/payment/callback` |
回调地址必须使用 HTTPS;本地开发可以使用 loopback HTTP 地址。不能包含用户名、密码或 URL fragment。保存后平台会创建应用和默认支付 API Key。
### 3.3 保存 API Key
创建成功后页面会显示完整 API Key,并提示“完整 API Key 只显示这一次”。操作要求:
1. 立即点击复制按钮。
2. 只保存到接入方服务端的环境变量或密钥管理服务。
3. 不要提交到 Git、客户端安装包、网页源码、截图或工单。
4. API Key 泄露时,在“支付 API Key”区域撤销旧 Key,再创建新的支付应用 Key。
页面只展示 Key 前缀、所属应用、权限、创建时间、最后使用时间和撤销状态,不再次展示完整密钥。
### 3.4 停用应用和撤销 Key
- “我的支付应用”中的“停用”会阻止该应用继续创建支付订单;历史订单和后台记录保留。
- “支付 API Key”中的“撤销 Key”会使该 Key 立即失效;已经创建的订单不因撤销 Key 自动取消。
- 更换 Key 后,必须同步更新接入方服务端配置,并保留一次查单验证。
## 4. 外部产品接入
### 4.1 公共地址和鉴权
以部署域名作为 API 根地址,以下示例中的 `$BASE_URL` 替换为实际地址:
```text
$BASE_URL/api/external/v1/payment/orders
```
请求头:
```http
Authorization: Bearer tnr_pay_xxxxxxxxxxxxxxxxx
Content-Type: application/json
Idempotency-Key: game-order-20261003-000001
```
支付 API Key 的格式前缀为 `tnr_pay_`。`Idempotency-Key` 要求为 1-128 个可打印 ASCII 字符且不能包含空格;它代表一次业务下单意图。网络结果不确定时,必须复用原值。
### 4.2 创建订单
```bash
curl -X POST "$BASE_URL/api/external/v1/payment/orders" \
-H "Authorization: Bearer $PAYMENT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: game-order-20261003-000001" \
-d '{
"merchantOrderId": "game-order-20261003-000001",
"title": "星港商城 - 600 水晶",
"items": [
{"sku": "crystal-600", "name": "600 水晶", "quantity": 1}
],
"amountCents": 19600,
"currency": "CNY",
"provider": "wechat_native"
}'
```
请求字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `merchantOrderId` | 是 | 接入方自己的订单号;建议使用可追踪的 ASCII 字符串,微信 Native 外部订单号最终不超过 32 个字符 |
| `title` | 是 | 收银台展示的商品或订单标题 |
| `items` | 否 | 商品明细,会被原样记账;不参与平台金额计算 |
| `amountCents` | 是 | 应付金额,整数分;例如 `19600` 表示人民币 `196.00` |
| `currency` | 否 | 当前使用 `CNY` |
| `provider` | 否 | 当前使用 `wechat_native` |
金额由服务端订单快照决定。客户端传来的商品明细不能作为最终收款金额,接入方应在自己的服务端完成价格校验后再调用创建订单。
### 4.3 创建响应
成功响应为 HTTP `200`,核心字段如下:
```json
{
"order": {
"orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a",
"merchantOrderId": "game-order-20261003-000001",
"title": "星港商城 - 600 水晶",
"amountCents": 19600,
"currency": "CNY",
"provider": "wechat_native",
"status": "paying",
"checkoutToken": "pay_xxxxxxxxx",
"checkoutUrl": "/pay/pay_xxxxxxxxx",
"providerQrCode": "weixin://wxpay/bizpayurl?...",
"expiresAt": "2026-10-03T07:05:00Z"
}
}
```
接入方应优先使用完整部署域名拼接 `checkoutUrl`,例如 `https://www.example.com/pay/pay_xxxxxxxxx`。不要把 `providerQrCode` 直接当作支付成功依据;它只是动态二维码内容,支付状态仍以平台订单为准。
### 4.4 查询订单
```bash
curl "$BASE_URL/api/external/v1/payment/orders/6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a" \
-H "Authorization: Bearer $PAYMENT_API_KEY"
```
建议在收银台打开后每 3-5 秒查询一次,直到状态进入终态:
| 状态 | 含义 | 接入方动作 |
| --- | --- | --- |
| `pending` | 已创建,尚未完成支付平台下单 | 继续查单;如长时间不变,查看后台或服务日志 |
| `paying` | 已拿到支付平台二维码,等待用户付款 | 展示收银台并等待通知或查单 |
| `paid` | 已确认到账 | 只在此状态发放游戏商品或权益 |
| `expired` | 订单超过有效期 | 不发货,提示用户重新下单 |
| `closed` | 订单已关闭 | 不发货 |
| `refunded` | 已退款 | 按接入方规则撤销或冻结权益 |
同一个 `Idempotency-Key` 携带相同请求体会返回首次订单;同一个 Key 携带不同请求体会返回 `409`。遇到连接超时,不要生成新 Key 盲目重试。
## 5. 网页收银台
外部产品拿到 `checkoutUrl` 后,可以:
- 游戏客户端使用系统浏览器或内嵌 WebView 打开。
- Web 产品使用新窗口、弹窗或当前页面跳转。
- 自己展示商品说明,但最终金额应以平台收银台显示金额为准。
桌面端收银台左侧展示订单号、商品标题、付款提示和等待状态,右侧展示应付金额、微信 Native 动态二维码和支付渠道:
![支付收银台桌面版](technical/assets/payment-service-20261003/payment-checkout.png)
图 2:支付收银台桌面版。此图使用浏览器会话内的模拟订单 `测试游戏道具 / ¥196.00`,用于说明布局,不对应真实商户订单。
移动端会自动改为上下布局,用户可以在较窄屏幕中滚动查看金额、二维码和支付说明:
![支付收银台移动版](technical/assets/payment-service-20261003/payment-checkout-mobile.png)
图 3:支付收银台移动版。二维码内容由页面的 QR 组件根据模拟支付链接生成;生产环境二维码由微信支付商户接口返回。
收银台的支付提示:
1. 用户必须完全按照页面显示金额付款,少付或多付都可能无法自动确认。
2. 付款后页面会轮询订单并自动更新状态,但接入方仍应以服务端查单或回调为最终依据。
3. 订单过期后不要继续使用旧二维码,应重新创建订单。
## 6. 支付成功回调
### 6.1 平台支付通知
微信支付通知地址由平台环境变量 `WECHAT_PAYMENT_NOTIFY_URL` 配置,生产环境必须是外网可访问的 HTTPS 地址,例如:
```text
https://www.example.com/api/payment/wechat/notify
```
该地址接收微信支付商户通知。平台会校验微信通知签名、商户订单号、金额、支付状态和交易号,校验通过后才把本地订单改为 `paid`。
### 6.2 外部产品回调
如果创建支付应用时填写了“支付成功回调地址”,平台会向该地址投递 JSON:
```json
{
"event": "payment.paid",
"orderId": "6e7d8c9a0b1c4d2e9f8a7b6c5d4e3f2a",
"appId": "payapp_xxxxxxxxx",
"merchantOrderId": "game-order-20261003-000001",
"amountCents": 19600,
"currency": "CNY",
"provider": "wechat_native",
"providerTradeNo": "4200000000000000000000000000",
"status": "paid",
"paidAt": "2026-10-03T07:01:12Z"
}
```
回调请求头:
```http
X-Genarrative-Signature: sha256=<hex>
X-Genarrative-Event: payment.paid
X-Genarrative-Delivery-Id: delivery_xxxxxxxxx
```
首期签名规则为:使用创建该支付应用时生成的 API Key 哈希作为 HMAC 密钥,对原始请求体字节执行 `HMAC-SHA256`,并将结果编码为小写十六进制;请求头值为 `sha256=` 加十六进制结果。接入方必须使用原始 body 验签,不要先反序列化再重新序列化。独立可轮换的 callback secret 属于后续安全增强,不要在当前版本按未实现字段接入。
回调处理要求:
1. 先验签,再按 `orderId` 或 `merchantOrderId` 查询自己的订单。
2. 对 `deliveryId` 和业务订单号做幂等处理,重复通知返回成功,不重复发货。
3. 成功处理后返回任意 HTTP `2xx`,建议返回 `204`。
4. 非 `2xx` 会进入平台回调重试;平台最多尝试 8 次,最终失败会出现在后台“外部回调投递”表中。
## 7. 后台查看订单和回调
管理员进入后台,在侧边栏选择“支付订单”,或打开后台路由 `#payment-orders`。页面分为两张表:
### 7.1 外部回调投递
用于确认平台是否成功通知了接入方,包含:投递 ID、订单号、应用、回调地址、投递状态、尝试次数、最近错误和更新时间。
常见状态:
- `pending`:等待投递或等待下一次重试。
- `delivered`:接入方返回了成功状态码。
- `failed`:达到重试上限,需人工排查回调地址、验签和接入方服务状态。
### 7.2 订单记录
用于查看外部订单号、平台订单号、应用、商品标题、金额、渠道、订单状态、创建时间和支付时间。金额显示为币种和两位小数,订单状态以服务端记录为准。
本阶段后台用于收款记账和通知核对,不提供自动给外部产品打款或分账按钮。需要结算时,按订单记录和商户账单人工处理。
## 8. 生产配置清单
部署前由后端运维配置,不要把下列值写入前端:
```text
WECHAT_PAY_ENABLED=true
WECHAT_PAY_PROVIDER=real
WECHAT_PAYMENT_NOTIFY_URL=https://<公开域名>/api/payment/wechat/notify
```
同时配置现有微信支付 V3 商户信息、商户证书、私钥、平台证书或平台证书密钥等环境变量。真实商户配置必须通过部署系统的密钥注入完成。`WECHAT_PAY_PROVIDER=mock` 只用于本地开发和页面联调,不能用于生产收款。
上线前至少验证:
1. 使用真实商户沙箱或小额测试单创建 Native 订单。
2. 微信扫码后平台订单能变为 `paid`。
3. `WECHAT_PAYMENT_NOTIFY_URL` 能收到并确认微信通知。
4. 接入方回调能通过验签,并且重复回调不会重复发货。
5. 后台订单表和回调投递表都有对应记录。
## 9. 常见问题
| 现象 | 优先检查 |
| --- | --- |
| 返回 `401` | Authorization 是否是支付应用的 Key;Key 是否已撤销;是否误用了普通 External API Key |
| 返回 `403` | 支付应用是否已停用;Key 是否具备订单创建或查询 scope |
| 返回 `409` | 是否复用了已有 `Idempotency-Key` 但请求体不同;网络超时场景应复用原请求体和原 Key |
| 返回“缺少 `WECHAT_PAYMENT_NOTIFY_URL`” | 生产真实 provider 必须配置公开 HTTPS 通知地址;本地 mock 才使用默认 loopback 地址 |
| 一直是 `pending` | 查看微信下单是否成功、后台订单是否有 provider 码、部署日志和商户配置;不要直接给用户发货 |
| 用户已付款但接入方未发货 | 先调用查单接口;再看后台“外部回调投递”的状态和最近错误;必要时人工补偿,但要保留幂等记录 |
| 二维码无法识别 | 检查是否把 `providerQrCode` 原样交给 QR 组件;不要对内容做 URL 编码或截断 |
| 本地截图能显示、生产无二维码 | 截图使用的是 mock 数据;生产必须确认微信商户接口返回了 `code_url`,并检查支付服务配置 |
## 10. 安全与责任边界
- API Key 只能放在外部产品服务端,不能放进游戏客户端、浏览器 localStorage 或公开仓库。
- 客户端传回的“支付成功”事件只能作为 UI 提示,不能作为发货凭据。
- 发货必须由外部产品服务端根据平台查单或验签回调执行,并以订单号做幂等。
- 不要记录完整 API Key、微信商户私钥、微信通知原文中的敏感字段或用户支付凭据。
- 截图中的订单号、金额和二维码均为演示数据;生产环境会由后端订单和平台微信商户接口动态生成。
## 11. 相关契约
- [外部产品支付服务接入技术方案](./【技术方案】外部产品支付服务接入-2026-10-03.md)
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json)
- [支付应用与订单收银台里程碑](./project-memory/plans/【里程碑】支付应用与订单收银台-2026-10-03.md)
@@ -59,7 +59,7 @@ npm run check:server-rs-ddd
`spacetime-client` 的 Cargo `lib.path` 指向 `src/active.rs`,现役 mapper 聚合入口是 `src/active/mapper.rs`;原旧 facade 和 mapper 已删除。
当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。
当前 mapper 按现役调用领域拆分为 `admin_account.rs`、`admin_dashboard.rs`、`ai.rs`、`assets.rs`、`auth.rs`、`editor_agent.rs`、`editor_project.rs`、`error_reports.rs`、`external_api_key.rs`、`external_generation.rs`、`game_distribution.rs`、`llm_router_account.rs`、`payment.rs`、`runtime.rs`、`runtime_profile.rs` 和 `story.rs`;每个模块只承接对应现役 procedure / result 类型的映射。跨领域轻量 helper 和共享 record 放在 `common.rs`;不得把领域聚合或跨层 JSON 兼容结构放入 mapper。
## API 路由分组
@@ -451,6 +451,30 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- 2026-09-01 修订:每次账号认证、Router Key 准备或 LLM 请求解析既有账号时,api-server 都会在同一 owner 级 provisioning 锁内检查固定套餐 `plan_id=1`。没有 active 订阅、订阅已过期或 `end_time` 距当前 Unix 秒不超过 24 小时时,使用管理员接口新建一条订阅;超过 24 小时则复用现有订阅。订阅检查不进入 Responses 流式 chunk,管理员 Token 缺失时仅保留启动告警并跳过续期检查。
- LLM Router 计费:读取 Router 用户 `used_quota`,以 `50000 quota = 1 泥点` 结算(`500000 quota = 1 USD`,美元数值直接乘 10,不乘汇率)。上游调用前建立首次基线并补结算,成功响应后同步;不足整数部分、扣费失败和余额不足未支付部分留到后续累计同步。扣钱包、写 `llm_router_consume` 流水与推进 checkpoint 在同一事务完成;流水展示“LLM 调用消耗”。历史费用不追扣。详细契约见 `technical/【技术方案】LLM累计额度结算-2026-09-05.md`。
- 2026-09-05 修订:`/api/llm/responses` 与 `/api/llm/chat/completions` 仍在 Router provisioning 前用钱包总余额阻止零余额账号创建或续期;解析凭据后、上游调用前再执行累计额度同步,以扣除退款占用后的剩余可消费余额为准。余额为 `0` 时返回 `409 MUD_POINTS_INSUFFICIENT`;余额或额度同步失败时失败关闭。上游已成功时,后置同步失败只记录错误并留待下次调用前补结算,不把成功模型响应改写为失败。
### `payment_app`
- Rust 结构体:`PaymentApp`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。
- 作用:平台账号创建的外部支付应用,保存应用归属、展示名称、回调地址、启用状态和更新时间。应用使用平台统一商户主体,不保存支付平台私钥。
- 索引:`by_payment_app_owner_user_id` 用于个人中心应用列表。
### `payment_api_key`
- Rust 结构体:`PaymentApiKey`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。
- 作用:支付应用 API Key 元数据。明文只在创建支付应用响应中返回一次,表内仅保存摘要、可见前缀、作用域、使用时间和撤销状态。
- 索引:`by_payment_api_key_app_id`、`by_payment_api_key_owner_user_id`;`key_hash` 唯一用于支付 API 鉴权。
### `payment_order`
- Rust 结构体:`PaymentOrder`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。
- 作用:客户端游戏和外部产品的统一收款订单。保存不可变商品 / 金额快照、应用归属、接入方订单号、幂等查找键、微信 Native `code_url`、平台交易号、支付状态、过期时间和到账时间。订单确认只能由验签通知或服务端查单进入 `paid`,不与 `profile_recharge_order` 共享入账路径。
- 索引:`by_payment_order_app_id`、`by_payment_order_owner_user_id`、`by_payment_order_status`;`idempotency_lookup`、`merchant_order_lookup`、`checkout_token` 唯一用于幂等和公共收银台访问。
### `payment_webhook_delivery`
- Rust 结构体:`PaymentWebhookDelivery`;源码:`server-rs/crates/spacetime-module/src/payment_storage.rs`。
- 作用:支付成功后的外部回调 durable outbox。保存 callback URL、签名、固定 payload、租约、尝试次数、下次可投递时间、最近错误和 delivered / failed 终态;api-server worker 通过 claim / complete procedure 投递。
- 索引:`by_payment_webhook_delivery_status_available`、`by_payment_webhook_delivery_owner_user_id`、`by_payment_webhook_delivery_order_id`;`delivery_id` 按订单唯一幂等。
- Windows 私有文件准备:AGC 自有 AppData、凭据目录和 `.agent` 运行态继续使用 managed 范围;用户通过原生选择器明确选中的项目根或文件,若 owner/DACL 仅因权限不足无法读取,则由一次性 UAC helper 在严格复核普通文件/目录、非 reparse/symlink、路径类型和目标 TokenUser 后接管并收紧为当前用户私有 DACL。项目放在当前 profile 之外(例如其他磁盘)不再因为路径位置被拒绝;未经过原生选择器或 AGC 项目根入口的内部路径仍不获得任意提权资格。
### `game_distribution_game`
@@ -617,6 +641,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复
- Rust 结构体:`EditorAssetFolder`
- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`
- 说明:图片画布账号级素材文件夹表,归属于用户账号而不是 project;首次读取素材库时自动创建系统默认“项目素材”文件夹。文件夹支持重命名、折叠和删除,系统默认文件夹不能删除。
- 单目录读取:`GET /api/editor/assets/folders/{folderId}` 与 `GET /api/external/v1/editor/assets/folders/{folderId}` 经同一 `spacetime-client` facade / 只读 procedure 按目录主键和 owner 校验,返回现有目录 DTO;不读取素材、不创建默认目录或修复媒体。External 要求 `editor:asset` scope;未认证 401、越权 403、缺失 404。既有全库、目录创建/修改/删除 API 合同和持久表结构保持不变;先部署后端再发布依赖新接口的 AGC 客户端,不提供全库回退。
- 索引:`by_editor_asset_folder_owner_user_id`。
### `editor_asset`
@@ -0,0 +1,202 @@
# 外部产品支付服务接入
更新时间:`2026-10-03`
## 目标
为 Genarrative 提供一套由平台统一收款的支付服务,支持:
- 客户端制作的游戏通过公开接口创建订单并打开 Web 收银台。
- 外部产品通过应用身份、API Key 和签名调用同一套订单接口。
- 平台使用已开通的微信支付 / 支付宝商户接口生成动态支付二维码,并以服务端验签、查单或支付通知确认到账。
- 用户在自己的账户中查看支付服务,创建和配置接入应用,管理 API Key、回调地址和订单。
- 后台查看支付应用、订单、支付通知、回调投递和异常状态。
- 首期只负责收款、记账和结果通知;外部产品的余额、手续费、提现和自动分账不在首期范围内,人工结算依据后台账单处理。
## 非目标
- 不把外部产品订单记入现有的个人钱包充值订单,不向购买者自动发放泥点或会员权益。
- 不允许客户端或外部产品直接声明“支付成功”改变订单状态。
- 不保存或向前端下发商户私钥、API v3 密钥、支付宝应用私钥、支付平台 access token 等敏感凭据。
- 不在首期实现平台余额、外部产品提现、手续费规则、自动分账、佣金和税务结算。
- 不使用个人收款码、静态收款码或无法验签查单的人工转账作为支付服务正式渠道。
## 入口与边界
### 参与方
- 平台:持有并配置自己的微信支付 / 支付宝商户账户,维护支付服务、订单和通知。
- 接入方:平台账号下创建的游戏或外部产品应用,使用平台发放的 API Key 创建订单。
- 购买者:在公共 Web 收银台查看商品信息并完成扫码支付,可不登录平台账号。
- 支付平台:微信支付 Native、支付宝当面付等已开通的商户 API,负责实际收款、通知和查单。
### 入口
- 账号侧:`/profile/payment`,展示支付服务状态、应用列表、API Key、回调配置和订单入口。
- 公共收银台:`/pay/{checkoutToken}`,由服务端返回订单快照、可用支付方式和动态二维码。
- 外部 API:`/api/external/v1/payment/orders` 及同一资源下的查单、关闭和收银台地址接口。
- 支付平台通知:按渠道配置独立的 HTTPS 通知入口,统一进入支付订单确认流程。
- 后台:增加支付服务概览、接入应用、支付订单、通知与回调投递页面,并受现有后台 Tab 权限控制。
### 职责边界
- `api-server`:HTTP 鉴权、公开 API、收银台 BFF、支付平台通知、回调投递编排和错误 envelope。
- `module-*`:金额、订单状态、幂等、归属、回调重放和人工复核等领域规则。
- `spacetime-module`:支付应用、订单、支付尝试、通知观察和投递 outbox 的持久化与原子事务。
- `spacetime-client`:为 api-server 提供支付领域 procedure 的 typed facade。
- `platform-*`:微信 / 支付宝的下单、二维码、验签、解密、查单和响应解析。
- `shared-contracts` / `packages/shared`:账号页、后台页和外部 API 共用的 DTO,不保存业务真相。
- 前端:展示后端返回的订单状态,收银台轮询或 SSE 只用于刷新,不自行判定到账。
## 接入应用与账户配置
### 应用
每个应用属于一个平台账号,至少包含:
- `appId`、应用名称、产品类型、启用状态和创建 / 更新时间。
- 允许使用的支付渠道和金额上下限。
- 支付成功回调地址、回调签名密钥版本、回调重试策略。
- 可选的返回地址;必须按 HTTPS、域名白名单和协议校验,不能接受任意用户提交的跳转地址。
- 应用级订单前缀和展示品牌信息,不能覆盖平台商户主体。
### API Key
- 创建时只展示一次完整 secret,数据库只保存 hash、可见前缀、scope、创建时间、最后使用时间、撤销时间和版本。
- 首期 scope 至少区分 `payment:orders:create`、`payment:orders:read`、`payment:orders:close`。
- Key 只属于一个应用,撤销后立即禁止创建订单、查单和关闭操作。
- 请求使用 `Authorization: Bearer` 或约定的 API Key 头,并要求 `Idempotency-Key`。
- 管理员页面可以查看安全元数据,不能查看完整 secret。
### 商户配置
- 商户号、应用 ID、证书序列号、通知地址和支付平台密钥只由服务端环境变量或受控密文配置提供。
- 用户账户页面只展示已启用的支付渠道和配置状态,不允许用户上传或读取平台级商户私钥。
- 后台只展示脱敏的商户配置和连通性状态;密钥写入日志、追踪事件和错误响应均禁止。
- 每个渠道必须明确 `enabled / unavailable / misconfigured` 状态,未完成配置的渠道不出现在收银台可选项。
## 订单与金额契约
### 创建订单
接入方提交:
- 应用 API Key、幂等键和接入方订单号。
- 商品标题、商品明细、商品数量、币种和金额,金额使用整数分。
- 接入方用户标识和可选的业务 metadata;metadata 有字节上限,禁止放入凭据、Data URL 或大段正文。
- 支付成功后的业务回调要求由应用配置提供,不能在每次请求中绕过已审核的地址白名单。
服务端执行:
1. 校验应用、API Key、scope、幂等键、金额范围、币种和商品字段。
2. 以 `appId + idempotencyKey` 建立唯一幂等记录;相同请求重放返回原订单,相同幂等键但字段不一致则失败。
3. 生成平台订单号、不可猜测的 `checkoutToken` 和过期时间,保存不可变商品与金额快照。
4. 调用选定的支付平台创建订单,要求平台返回的商户订单号、金额和渠道与本地快照一致。
5. 返回平台无关的订单摘要、收银台 URL、过期时间和可用渠道,不返回商户请求签名或支付平台敏感字段。
### 状态
订单状态为:
`pending → paying → paid`
并允许从 `pending / paying` 进入 `expired / closed`,从 `paid` 进入 `refunded`(退款能力在后续里程碑接入)。支付通知、查单和人工复核只能按允许的状态转移推进;重复通知、乱序通知和重复查单必须幂等。
本地 `paid` 必须同时满足:
- 平台通知验签或主动查单成功。
- 商户订单号、平台交易号、应用、渠道、金额和币种与本地订单一致。
- 支付平台确认时间合法且来自平台响应,不能使用服务器当前时间补齐。
## Web 收银台
收银台使用服务端生成的 `checkoutToken`,页面展示:
- 商品名称、商品明细、订单号、应付金额和过期时间。
- 可用支付渠道,例如“微信支付 / 支付宝”,渠道由商户配置和订单约束共同决定。
- 当前渠道的动态二维码。二维码由平台商户 API 以当前订单金额和平台订单号生成,不复用静态码。
- “待支付、支付确认中、支付成功、已关闭、已过期、支付异常”等后端状态。
- 查询状态、复制金额、返回应用等操作;返回地址只使用应用白名单中的地址。
收银台不展示平台密钥、用户 API Key、完整回调地址和内部错误文本。浏览器轮询或 SSE 只能读取公开订单状态;出现“支付成功”页面前仍需经过平台回调或服务端查单确认。
页面视觉采用截图中的双栏收银台结构作为参考:左侧是订单信息和金额提示,右侧是二维码和渠道状态;移动端改为纵向布局。截图只作为布局参考,不作为支付协议、金额或商户身份依据。
## 通知、查单与外部回调
- 平台支付通知入口按渠道独立配置,先验签 / 解密,再解析订单和金额,再进入统一的订单确认事务。
- 通知成功后快速返回平台要求的成功响应;外部产品回调通过 outbox 异步投递,不能让第三方回调慢速阻塞支付平台通知。
- 外部回调 payload 至少包含平台订单号、接入方订单号、应用 ID、支付状态、金额、币种、平台交易号和确认时间。
- 首期回调签名使用 `HMAC-SHA256(sha256(rawApiKey), rawBody)`,HTTP 头为 `X-Genarrative-Signature: sha256=<hex>`、`X-Genarrative-Event` 和 `X-Genarrative-Delivery-Id`;接入方验签后按订单号和 delivery id 幂等消费。独立可轮换 callback secret 作为后续安全增强项,不在本里程碑伪称已实现。
- 投递按有限次数退避重试,记录 HTTP 状态、响应摘要、下一次重试时间和最终失败原因;不得记录完整 secret、签名原文或敏感请求体。
- 查询订单只返回应用归属范围内的订单;公共收银台只返回公开展示字段和状态。
- 支付确认与唯一回调 outbox 在同一数据库事务提交,回调 payload 从已确认订单构建;失败不会留下 paid 但缺 outbox 的半完成状态。每个投递使用唯一租约代次,过期 worker 不能覆盖新 worker 结果。
- 外部下单使用不超过 32 字符的平台订单号和冻结的过期时间,独立通知 URL 为 `/api/payment/wechat/notify`;下单 claim 在库内完成,结果不确定时先查单,同一商户单号不因重试更换。
- 应用、订单、回调 procedure 都要求已注册后端运行服务身份,匿名 SpacetimeDB 调用不得创建或确认订单。回调地址要求 HTTPS、禁止凭据和内网地址,发送时再次检查解析地址且禁止跳转;仅隔离测试环境可以允许 loopback fixture。
- 支付平台通知、主动查单和后台人工重试全部复用同一条幂等确认路径,不各自实现入账。
## 后台数据页
后台新增支付服务入口,至少包含:
- 总览:订单数、已支付金额、待确认订单、通知失败、外部回调失败和按渠道统计。
- 接入应用:应用状态、所属账号、渠道、回调地址脱敏、Key 状态和最近使用时间。
- 支付订单:应用、商品、接入方订单号、平台订单号、金额、渠道、状态、创建 / 支付时间和平台交易号。
- 通知与投递:验签结果、通知类型、订单关联、重复次数、投递状态和下次重试时间。
- 详情与人工处理:展示本地快照和平台事实的对照;人工操作必须记录管理员、原因、时间和期望状态,不能直接改订单金额或伪造支付成功。
分页列表必须使用后端分页和排序参数;金额使用整数分并在展示层格式化,时间统一转成人可读时间,不能把 SATS enum、Option 或微秒 Timestamp 原样展示。
## 安全、幂等与失败关闭
- 所有金额和商品快照以服务端持久化值为准,客户端传回的金额只作为展示输入,不能作为支付成功依据。
- 订单创建、支付平台下单、通知确认和外部回调投递分别设置幂等键,重放不会重复创建订单、记账或发放商品。
- 支付平台超时、网络断开、响应解析失败或结果不确定时,订单保持待确认并进入查单 / 人工复核,不自动标记失败或再次创建新订单。
- 未知通知、金额不一致、订单归属不一致、签名失败和平台交易号冲突必须拒绝确认,并保留脱敏诊断事实。
- 日志只记录稳定 HMAC 引用、状态、金额、渠道、阶段和错误分类,不记录二维码原文、密钥、签名、完整订单请求或敏感 metadata。
- 所有用户、应用、订单、回调和后台查询都检查归属和权限;外部 API 不允许通过猜测订单号访问其它应用数据。
## 契约与迁移
### API / DTO / OpenAPI
- 新增支付服务公开 API 时同步更新 `docs/openapi/genarrative-external-v1.openapi.json`、`shared-contracts`、契约测试和公开发现文档。
- 内部账号接口放在 `/api/profile/payment/*`,后台接口放在 `/admin/api/payment/*`,支付平台通知不走用户鉴权。
- 公开 DTO 不暴露 provider 原始响应、密钥、完整二维码 payload 或内部数据库 ID。
### SpacetimeDB
计划增加独立的支付领域表,具体表结构在里程碑实施前冻结:
- 支付接入应用和 API Key 元数据。
- 平台支付订单和不可变商品快照。
- 渠道支付尝试与平台交易号。
- 支付通知观察记录。
- `payment_webhook_delivery` 外部回调投递 outbox 与投递结果。
新增字段必须追加到已有 Rust 表结构体末尾并设置明确默认值;不得重排、改名或删除现有充值表字段。修改 schema 时同步 migration、表目录、生成绑定并运行 `npm run check:spacetime-schema`。
### 兼容策略
现有个人钱包充值、微信虚拟支付和充值退款链路保持原契约。支付服务用独立订单前缀、表和 API namespace 标识,不把历史充值订单迁移成外部支付订单。
## 首期验收标准
| 条款 | 验收方式 | 证据 |
| --- | --- | --- |
| 账号可创建支付应用并管理 API Key | 浏览器 smoke、接口测试 | 个人中心页面、Key 只展示一次、撤销立即生效 |
| 客户端游戏和外部产品可创建订单 | API 契约测试 | 同一幂等键返回原订单,字段冲突被拒绝 |
| 收银台展示不可篡改的商品和金额 | 浏览器 smoke | `checkoutToken` 只能读取公开字段,金额来自服务端快照 |
| 商户账户生成动态二维码 | provider mock + 真实沙箱 smoke | 二维码订单号和金额与本地订单一致 |
| 支付结果以服务端事实确认 | 通知 / 查单测试 | 客户端伪造成功不会改变订单,通知重放不重复确认 |
| 外部产品收到成功通知 | outbox 测试与回调 smoke | 签名、重试、最终失败和幂等投递可观测 |
| 后台可查看运营数据 | 后台页面测试 | 应用、订单、通知和回调列表分页、筛选、详情可用 |
| 权限与敏感数据安全 | 负向测试、日志检查 | 跨应用访问、失效 Key、错误金额、错误签名均失败且无密钥泄漏 |
| 首期不产生自动分账 | 领域测试与文档核对 | 没有余额、提现或自动分账写路径 |
## 未决问题与决策
- 首期接入渠道:优先复用现有微信 Native 动态二维码链路;支付宝作为同一收银台的第二个 provider,只有商户 API 配置和验签协议完成后才显示。
- 首期结算:平台收款、记账、通知,外部产品人工结算;手续费、余额和提现另立规范。
- 商户主体:使用平台自己的商户号;接入应用不能选择或替换商户主体。
- 退款:首期只展示支付事实和人工处理入口,正式退款与外部产品权益回收另立里程碑。