合并最新 master 到作品管理分支
Project CI / Backend tests (pull_request) Failing after 39s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m44s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 3m43s
Project CI / Repository checks (pull_request) Failing after 40s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 5m52s
Project CI / Frontend tests (pull_request) Successful in 3m17s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m12s
Project CI / Native shell tests (pull_request) Successful in 6m41s

This commit is contained in:
2026-10-03 17:34:23 +08:00
105 changed files with 8656 additions and 10 deletions
+7
View File
@@ -52,6 +52,7 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminPaymentOrderListResponse,
AdminProjectSnapshotChannelsResponse,
AdminProjectSnapshotListQuery,
AdminProjectSnapshotListResponse,
@@ -804,6 +805,12 @@ export function listAdminRechargeOrders(
);
}
export function listAdminPaymentOrders(token: string) {
return request<AdminPaymentOrderListResponse>('/admin/api/payment/orders', {
token,
});
}
export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
+33
View File
@@ -1054,6 +1054,39 @@ export interface AdminWechatPaymentCheckPayload {
knownRefundsRefreshed: number;
}
export interface AdminPaymentOrderEntry {
orderId: string;
appId: string;
ownerUserId: string;
merchantOrderId: string;
title: string;
amountCents: number;
currency: string;
provider: string;
providerTradeNo: string | null;
status: string;
createdAt: string;
expiresAt: string;
paidAt: string | null;
}
export interface AdminPaymentOrderListResponse {
entries: AdminPaymentOrderEntry[];
webhooks: AdminPaymentWebhookEntry[];
}
export interface AdminPaymentWebhookEntry {
deliveryId: string;
orderId: string;
appId: string;
callbackUrl: string;
status: string;
attemptCount: number;
availableAt: string;
lastErrorMessage: string | null;
updatedAt: string;
}
export interface AdminRechargeRefundPreviewResponse {
order: AdminRechargeOrderEntryPayload;
paymentCheck: AdminWechatPaymentCheckPayload;
+7
View File
@@ -36,6 +36,7 @@ import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage'
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
import { AdminOverviewPage } from '../pages/AdminOverviewPage';
import { AdminPaymentOrderPage } from '../pages/AdminPaymentOrderPage';
import { AdminProfileWalletConfigPage } from '../pages/AdminProfileWalletConfigPage';
import { AdminProjectSnapshotsPage } from '../pages/AdminProjectSnapshotsPage';
import { AdminRechargeOrderPage } from '../pages/AdminRechargeOrderPage';
@@ -297,6 +298,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'payment-orders' ? (
<AdminPaymentOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
+1
View File
@@ -51,6 +51,7 @@ const routeIcons = {
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'payment-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
+2
View File
@@ -14,6 +14,7 @@ export type AdminRouteId =
| 'tasks'
| 'recharge-products'
| 'recharge-orders'
| 'payment-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
@@ -53,6 +54,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tasks', label: '任务配置', hash: '#tasks' },
{ id: 'recharge-products', label: '充值商品', hash: '#recharge-products' },
{ id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders' },
{ id: 'payment-orders', label: '支付订单', hash: '#payment-orders' },
{
id: 'editor-generation-pricing',
label: '模型定价',
@@ -0,0 +1,198 @@
import {
AdminEmptyState,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminStatusPill,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useState } from 'react';
import {
formatAdminApiError,
listAdminPaymentOrders,
} from '../api/adminApiClient';
import type {
AdminPaymentOrderEntry,
AdminPaymentWebhookEntry,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
type AdminPaymentOrderPageProps = {
token: string;
onUnauthorized: (message?: string) => void;
};
function statusTone(status: string): 'ok' | 'pending' | 'error' {
if (status === 'paid') return 'ok';
if (status === 'closed' || status === 'expired') return 'error';
return 'pending';
}
export function AdminPaymentOrderPage({
token,
onUnauthorized,
}: AdminPaymentOrderPageProps) {
const [entries, setEntries] = useState<AdminPaymentOrderEntry[]>([]);
const [webhooks, setWebhooks] = useState<AdminPaymentWebhookEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState('');
const load = useCallback(() => {
setIsLoading(true);
setError('');
void listAdminPaymentOrders(token)
.then((response) => {
setEntries(response.entries ?? []);
setWebhooks(response.webhooks ?? []);
})
.catch((nextError: unknown) => {
handlePageError(nextError, onUnauthorized, (message) =>
setError(message || formatAdminApiError(nextError)),
);
})
.finally(() => setIsLoading(false));
}, [onUnauthorized, token]);
useEffect(() => {
load();
}, [load]);
return (
<AdminPage wide>
<AdminPageHeading
title="支付订单"
description="平台统一收款的外部产品订单与到账状态。"
actions={
<button
className="admin-button admin-button--secondary"
type="button"
onClick={load}
>
刷新
</button>
}
/>
<AdminListPanel
title="外部回调投递"
count={`${webhooks.length} 条`}
busy={isLoading}
emptyText={<AdminEmptyState>暂无外部回调记录。</AdminEmptyState>}
columns={[
{
key: 'delivery',
label: '投递',
render: (entry) => (
<>
<strong>{entry.deliveryId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{ key: 'app', label: '应用', render: (entry) => entry.appId },
{
key: 'callback',
label: '回调地址',
render: (entry) => entry.callbackUrl,
},
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill
tone={
entry.status === 'delivered'
? 'ok'
: entry.status === 'failed'
? 'error'
: 'pending'
}
>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'attempts',
label: '尝试次数',
render: (entry) => entry.attemptCount,
},
{
key: 'error',
label: '最近错误',
render: (entry) => entry.lastErrorMessage ?? '-',
},
{
key: 'updated',
label: '更新时间',
render: (entry) => entry.updatedAt,
},
]}
rows={webhooks}
rowKey={(entry) => entry.deliveryId}
/>
{error ? (
<div className="admin-alert admin-alert--error">{error}</div>
) : null}
<AdminListPanel
title="订单记录"
count={`${entries.length} 条`}
busy={isLoading}
emptyText={
<AdminEmptyState>
暂无支付订单,外部产品创建订单后会显示在这里。
</AdminEmptyState>
}
columns={[
{
key: 'order',
label: '订单',
render: (entry) => (
<>
<strong>{entry.merchantOrderId}</strong>
<small className="admin-muted-block">{entry.orderId}</small>
</>
),
},
{
key: 'app',
label: '应用',
render: (entry) => (
<>
<strong>{entry.title}</strong>
<small className="admin-muted-block">{entry.appId}</small>
</>
),
},
{
key: 'amount',
label: '金额',
render: (entry) =>
`${entry.currency} ${(entry.amountCents / 100).toFixed(2)}`,
},
{ key: 'provider', label: '渠道', render: (entry) => entry.provider },
{
key: 'status',
label: '状态',
render: (entry) => (
<AdminStatusPill tone={statusTone(entry.status)}>
{entry.status}
</AdminStatusPill>
),
},
{
key: 'created',
label: '创建时间',
render: (entry) => entry.createdAt,
},
{
key: 'paid',
label: '支付时间',
render: (entry) => entry.paidAt ?? '-',
},
]}
rows={entries}
rowKey={(entry) => entry.orderId}
/>
</AdminPage>
);
}
+2
View File
@@ -128,6 +128,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)
@@ -43,6 +43,10 @@
{
"name": "Agent Integration",
"description": "远程 MCP、OpenAPI 和完整 Skill 包发现"
},
{
"name": "Payment",
"description": "平台统一收款与 Web 收银台"
}
],
"paths": {
@@ -1614,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": {
@@ -1622,6 +1672,11 @@
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_sk"
},
"PaymentApiKey": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "tnr_pay"
}
},
"parameters": {
@@ -1747,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": {
@@ -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,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 配置失败和支付状态失败关闭语义。
- 边界:权限、幂等、重放、金额不一致、回调失败、过期、未知通知和敏感日志检查。
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

@@ -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`
@@ -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 配置和验签协议完成后才显示。
- 首期结算:平台收款、记账、通知,外部产品人工结算;手续费、余额和提现另立规范。
- 商户主体:使用平台自己的商户号;接入应用不能选择或替换商户主体。
- 退款:首期只展示支付事实和人工处理入口,正式退款与外部产品权益回收另立里程碑。
+55
View File
@@ -0,0 +1,55 @@
export type PaymentApp = {
appId: string;
name: string;
callbackUrl: string | null;
enabled: boolean;
createdAt: string;
updatedAt: string;
};
export type PaymentApiKey = {
keyId: string;
appId: string;
name: string;
keyPrefix: string;
scopes: string[];
createdAt: string;
lastUsedAt: string | null;
revokedAt: string | null;
updatedAt: string;
};
export type PaymentAppCreateResponse = {
app: PaymentApp;
apiKey: string;
key: PaymentApiKey;
};
export type PaymentAppListResponse = {
apps: PaymentApp[];
keys: PaymentApiKey[];
};
export type PaymentOrder = {
orderId: string;
appId: string;
merchantOrderId: string;
title: string;
items: unknown[];
amountCents: number;
currency: string;
provider: string;
providerTradeNo: string | null;
checkoutToken: string;
status: 'pending' | 'paying' | 'paid' | 'expired' | 'closed' | 'refunded';
checkoutUrl: string;
providerQrCode: string | null;
createdAt: string;
expiresAt: string;
paidAt: string | null;
updatedAt: string;
};
export type PaymentOrderCreateResponse = {
order: PaymentOrder;
};
+1
View File
@@ -7,6 +7,7 @@ export * from './contracts/editorScene';
export * from './contracts/externalGeneration';
export * from './contracts/gameDistribution';
export type * from './contracts/hyper3d';
export * from './contracts/payment';
export * from './contracts/runtime';
export * from './http';
export * from './llm/narrativeLanguage';
+10
View File
@@ -2201,6 +2201,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
path if path.starts_with("/admin/api/agc-templates/") => AnyTab(&["agc-templates"]),
"/admin/api/dashboard" => AnyTab(&["dashboard"]),
"/admin/api/overview" => AnyTab(&["overview"]),
"/admin/api/payment/orders" => AnyTab(&["payment-orders"]),
"/admin/api/external-api-keys" => AnyTab(&["tables"]),
"/admin/api/debug/http" => AnyTab(&["debug"]),
"/admin/api/tracking/events" => AnyTab(&["tracking"]),
@@ -2856,6 +2857,15 @@ async fn fetch_admin_dashboard_rows(state: &AppState, sql: &str) -> Result<Vec<V
extract_first_sql_rows(payload)
}
pub(crate) async fn fetch_admin_payment_rows(
state: &AppState,
sql: &str,
) -> Result<Vec<Value>, AppError> {
fetch_admin_dashboard_rows(state, sql)
.await
.map_err(|message| AppError::from_status(StatusCode::BAD_GATEWAY).with_message(message))
}
/// 用户详情累计充值的单次读取上限:单个用户的历史订单远少于该值。
const ADMIN_USER_RECHARGE_SUMMARY_ROW_LIMIT: u32 = 500;
+5
View File
@@ -23,6 +23,7 @@ use crate::{
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
modules,
payment::handle_payment_wechat_notify,
request_context::{RequestContext, attach_request_context, resolve_request_id},
response_headers::propagate_request_id_header,
state::{AppState, BackpressureState},
@@ -71,6 +72,10 @@ pub fn build_router(state: AppState) -> Router {
get(handle_wechat_virtual_payment_message_push_verify)
.post(handle_wechat_virtual_payment_notify),
)
.route(
"/api/payment/wechat/notify",
post(handle_payment_wechat_notify),
)
// HTTP 背压在业务路由外侧快拒绝,避免过载请求继续占用 SpacetimeDB facade 与业务执行资源。
.layer(middleware::from_fn_with_state(
BackpressureState::from_ref(&state),
@@ -8,7 +8,9 @@ use axum::{
response::Response,
};
use serde_json::json;
use spacetime_client::ExternalApiKeyAuthenticateRecordInput;
use spacetime_client::{
ExternalApiKeyAuthenticateRecordInput, PaymentApiKeyAuthenticateRecordInput,
};
use tracing::warn;
use crate::{
@@ -24,6 +26,9 @@ pub struct ExternalApiPrincipal {
owner_user_id: String,
key_id: String,
scopes: Vec<String>,
payment_app_id: Option<String>,
callback_url: Option<String>,
key_hash: String,
}
impl ExternalApiPrincipal {
@@ -33,6 +38,9 @@ impl ExternalApiPrincipal {
owner_user_id: owner_user_id.to_string(),
key_id: "external-api-key-test".to_string(),
scopes: scopes.iter().map(|scope| (*scope).to_string()).collect(),
payment_app_id: None,
callback_url: None,
key_hash: String::new(),
}
}
@@ -44,9 +52,21 @@ impl ExternalApiPrincipal {
self.key_id.as_str()
}
pub fn payment_app_id(&self) -> Option<&str> {
self.payment_app_id.as_deref()
}
pub fn has_scope(&self, scope: &str) -> bool {
self.scopes.iter().any(|item| item == scope)
}
pub fn payment_callback_url(&self) -> Option<&str> {
self.callback_url.as_deref()
}
pub fn payment_key_hash(&self) -> &str {
self.key_hash.as_str()
}
}
pub async fn require_external_api_key(
@@ -60,10 +80,11 @@ pub async fn require_external_api_key(
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key_hash = hash_external_api_key(raw_key.as_str());
let key = state
.authenticator()
.authenticate_external_api_key(ExternalApiKeyAuthenticateRecordInput {
key_hash: hash_external_api_key(raw_key.as_str()),
key_hash: key_hash.clone(),
used_at_micros: current_utc_micros(),
})
.await
@@ -79,6 +100,55 @@ pub async fn require_external_api_key(
owner_user_id: key.owner_user_id,
key_id: key.key_id,
scopes: key.scopes,
payment_app_id: None,
callback_url: None,
key_hash: String::new(),
};
request.extensions_mut().insert(principal.clone());
let mut response = next.run(request).await;
response.extensions_mut().insert(principal);
Ok(response)
}
pub async fn require_payment_api_key(
State(state): State<ExternalApiAuthState>,
mut request: Request,
next: Next,
) -> Result<Response, AppError> {
let request_id = request
.extensions()
.get::<RequestContext>()
.map(|context| context.request_id().to_string())
.unwrap_or_else(|| "unknown".to_string());
let raw_key = extract_external_api_bearer(request.headers())?;
let key_hash = hash_external_api_key(raw_key.as_str());
let (app, key) = state
.authenticator()
.authenticate_payment_api_key(PaymentApiKeyAuthenticateRecordInput {
key_hash: key_hash.clone(),
used_at_micros: current_utc_micros(),
})
.await
.map_err(|error| {
warn!(
%request_id,
error = %error,
"支付 API Key 校验失败"
);
AppError::from_status(StatusCode::UNAUTHORIZED)
.with_message("支付 API Key 不存在或已失效")
})?;
let scopes = serde_json::from_str::<Vec<String>>(key.scopes_json.as_str()).map_err(|_| {
AppError::from_status(StatusCode::UNAUTHORIZED).with_message("支付 API Key 权限配置无效")
})?;
let principal = ExternalApiPrincipal {
owner_user_id: app.owner_user_id,
key_id: key.key_id,
scopes,
payment_app_id: Some(app.app_id),
callback_url: app.callback_url,
key_hash,
};
request.extensions_mut().insert(principal.clone());
+5
View File
@@ -63,6 +63,9 @@ mod modules;
mod openai_image_generation;
mod password_entry;
mod password_management;
mod payment;
mod payment_admin;
mod payment_webhook;
mod phone_auth;
mod platform_errors;
mod process_metrics;
@@ -694,6 +697,8 @@ fn spawn_common_app_state_background_workers(state: &AppState) {
fn spawn_http_app_state_background_workers(state: &AppState, process_role: ProcessRole) {
spawn_common_app_state_background_workers(state);
crate::payment_webhook::PaymentWebhookWorker::new(state.spacetime_client().clone())
.spawn_worker();
crate::error_reports::spawn_cleanup_worker(state.clone());
if should_start_profile_recharge_expiration_listener(process_role) {
spawn_profile_recharge_expiration_listener(state.clone());
@@ -25,6 +25,7 @@ use crate::{
admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review,
admin_update_wallet_restriction,
},
payment_admin::admin_list_payment_orders,
runtime_profile::{
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
admin_get_profile_wallet_config, admin_list_profile_invite_codes,
@@ -83,6 +84,7 @@ pub fn router(state: AppState) -> Router<AppState> {
),
("/admin/api/me", get(admin_me)),
("/admin/api/overview", get(admin_overview)),
("/admin/api/payment/orders", get(admin_list_payment_orders)),
("/admin/api/dashboard", get(admin_dashboard)),
(
"/admin/api/debug/http",
@@ -7,7 +7,9 @@ use axum::{
use crate::{
editor_project::EDITOR_LAYOUT_REQUEST_BODY_MAX_BYTES,
external_api_auth::{require_external_api_key, require_external_mcp_api_key},
external_api_auth::{
require_external_api_key, require_external_mcp_api_key, require_payment_api_key,
},
external_assets_api::{
confirm_external_asset_object, create_external_direct_upload_ticket,
get_external_asset_read_url,
@@ -33,6 +35,7 @@ use crate::{
download_external_skill_archive, get_external_agent_integration_manifest,
get_external_skill_entry,
},
payment::{create_external_payment_order, get_external_payment_order},
state::AppState,
};
@@ -45,7 +48,8 @@ pub fn router(state: AppState) -> Router<AppState> {
require_external_mcp_api_key,
));
let auth = middleware::from_fn_with_state(state, require_external_api_key);
let auth = middleware::from_fn_with_state(state.clone(), require_external_api_key);
let payment_auth = middleware::from_fn_with_state(state.clone(), require_payment_api_key);
let protected_routes = [
(
"/api/external/v1/assets/direct-upload-tickets",
@@ -157,6 +161,15 @@ pub fn router(state: AppState) -> Router<AppState> {
.fold(Router::new(), |router, (path, methods)| {
router.route(path, methods.route_layer(auth.clone()))
});
let payment_router = Router::new()
.route(
"/api/external/v1/payment/orders",
post(create_external_payment_order).route_layer(payment_auth.clone()),
)
.route(
"/api/external/v1/payment/orders/{order_id}",
get(get_external_payment_order).route_layer(payment_auth),
);
Router::new()
.route("/api/external/v1/openapi.json", get(openapi_json))
@@ -173,6 +186,7 @@ pub fn router(state: AppState) -> Router<AppState> {
get(download_external_skill_archive),
)
.merge(protected_router)
.merge(payment_router)
.merge(mcp_router)
}
@@ -9,6 +9,10 @@ use crate::{
create_external_api_key, ensure_llm_router_api_key, list_external_api_keys,
revoke_external_api_key,
},
payment::{
create_payment_app, get_public_payment_checkout, list_payment_apps, revoke_payment_api_key,
update_payment_app,
},
profile_identity::update_profile_identity,
runtime_profile::{
claim_profile_task_reward, confirm_wechat_profile_recharge_order,
@@ -59,6 +63,29 @@ pub fn router(state: AppState) -> Router<AppState> {
require_bearer_auth,
)),
)
.route(
"/api/profile/payment/apps",
get(list_payment_apps).post(create_payment_app).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/profile/payment/apps/{app_id}",
axum::routing::patch(update_payment_app).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/profile/payment/keys/{key_id}",
axum::routing::delete(revoke_payment_api_key).route_layer(
middleware::from_fn_with_state(state.clone(), require_bearer_auth),
),
)
.route(
"/api/payment/checkout/{checkout_token}",
get(get_public_payment_checkout),
)
.route(
"/api/profile/wallet-ledger",
get(get_profile_wallet_ledger).route_layer(middleware::from_fn_with_state(

Some files were not shown because too many files have changed in this diff Show More