Merge remote-tracking branch 'origin/master' into feat/video-BGfilter

# Conflicts:
#	docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
2026-07-10 02:57:50 +00:00
394 changed files with 5836 additions and 2966 deletions
@@ -24,6 +24,14 @@
- 验证方式:`cargo test -p api-server editor_screen_background character_animation --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、真机对源角色图跑视觉决策与候选危险度表、抽帧后采样序列帧背景色确认落在冷区安全集。
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-09 充值订单过期改为 SpacetimeDB scheduled 表触发
- 背景:旧充值过期处理使用 api-server 后台轮询 worker claim 普通 schedule 表,非 HTTP 的 external-generation-worker / controller 进程也可能启动同一过期任务;扩外部生成 worker 会意外放大微信查单 / 关单流量,并且本地过期后若微信仍可支付,容易出现“微信扣款但本地拒绝入账”的风险。
- 决策:新建原生 scheduled 表 `profile_recharge_order_expiration_timer`,创建真实微信 pending 充值订单时写入 5 分钟 timerscheduled reducer 到点只把仍为 `pending` 的订单改为 `expired` 并写 `expired_at`。HTTP `api-server` 订阅 `profile_recharge_order``Pending -> Expired` 更新并执行微信查单补偿;`SUCCESS` 允许 `Expired -> Paid` 入账,未支付或远端已终态只记录检查结果,本地保持 `expired``external-generation-worker` 和 controller 不处理充值过期。
- 影响范围:`profile_recharge_order``profile_recharge_order_expiration_timer`、充值订单状态契约、`spacetime-client` bindings/facade、`api-server` 充值过期监听器、微信支付查单 / 关单、个人中心充值前端、后台表查询和运维文档。
- 验证方式:`npm run spacetime:generate``npm run check:spacetime-schema``cargo check -p api-server --manifest-path server-rs/Cargo.toml`、充值过期 listener / 微信支付 / shared contracts / 前端充值定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-07-09 会员有效期与周期泥点重置分离
- 背景:账户会员制度新增 Starter / Basic / Pro / Ultimate 四档后,会员有效期、周期限时泥点和普通永久泥点容易被混成同一条时间线;升级场景尤其容易误把“补差额”实现成延长会员或重算 reset time。
+17 -1
View File
@@ -247,6 +247,14 @@
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
## 图片画布裁扩后刷新或去背景丢图先查项目资源化
- 现象:从规范图裁切 / 裁扩出新图层后立即执行去除背景,去背景完成时原裁扩图从画布消失;刷新后裁扩图仍不在,但去背景占位可能变成结果图。
- 原因:裁扩结果由浏览器 canvas 本地渲染为 `data:image/png`。如果项目态先把 `local-resource-*` 图层加入画布,`serializeLayer` 不会保存 `src``resolveProjectResourceCreateImageSrc` 又会跳过内联 Data URL,后续应用后端 project snapshot 或刷新 hydrate 时找不到对应 `editor_project_resource`,该源图层就会被过滤。去背景带 `canvasCompletion` 时后端只负责把结果写入生成占位,不会恢复这个未资源化的裁扩源层。
- 处理:项目上下文中的裁扩结果必须在加入画布前先上传 OSS / asset object,再创建 `editor_project_resource`,并用服务端返回的 `resourceId/objectKey/assetObjectId` 创建裁扩图层;随后去背景的 `sourceResourceId` 和图片读取都指向正式资源。queue 模式下去背景完成后,如果首次读取的项目快照中对应 `generation-dialog` 仍是 `generating` 或缺少 `generatedLayerId`,前端短暂等待后再读取一次项目快照。
- 验证:`npm run test -- src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx -- --runInBand` 应覆盖裁扩先上传再创建项目资源,以及去背景队列完成后对未完成占位进行二次项目读取。
- 关联:`src/components/image-editor/useImageCanvasGenerationWorkflow.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 图片画布项目封面上传失败要有本地展示兜底
- 现象:画布项目已反复打开、保存或操作,但 `/project` 列表卡片仍只显示“项目”占位,没有封面图。
@@ -2058,10 +2066,18 @@
- 现象:后台“表查询”查看 `profile_recharge_order` 时,`kind``status` 显示为空数组 `[]`,例如充值订单原始行里 `points_60` 的类型和状态都不可读。
- 原因:SpacetimeDB HTTP SQL 对无载荷枚举会返回 SATS 形态 `[variant_index, []]`;后台通用 normalizer 曾把任何 `[0, value]` 都当作 `Option::Some(value)` 展开,导致 `[0, []]` 最终只剩 `[]`
- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership``profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded`
- 处理:通用表查询解析应先按表名和列名识别已知业务枚举,再落回 Option / Timestamp 通用展开;例如 `profile_recharge_order.kind` 映射为 `points` / `membership``profile_recharge_order.status` 映射为 `pending` / `paid` / `failed` / `closed` / `refunded` / `expired`
- 验证:执行 `cargo test -p api-server admin_database -- --nocapture`,并确认后台详情弹层的 `raw` 与表格 `cells` 都显示业务字符串。
- 关联:`server-rs/crates/api-server/src/admin.rs``docs/technical/ADMIN_DATABASE_TABLE_QUERY_2026-05-08.md`
## 充值订单过期补偿不要放进外部生成 worker
- 现象:外部生成 worker/controller 扩容后,微信充值过期查单和关单流量也被同步放大;排查时还会误去外部生成 worker 日志里找支付过期任务。
- 原因:支付过期是账户资金链路,不是外部内容生成队列;旧实现把充值过期轮询 worker 挂在通用后台任务启动函数里,非 HTTP 角色也会启动。
- 处理:充值订单过期由 SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点把 `pending` 改为 `expired`,只有 HTTP `api-server` 订阅 `profile_recharge_order``Pending -> Expired` 更新并查微信补偿。未支付终态本地保持 `expired`,不要再改写成 `closed`;微信成功支付通知或补偿查单仍可把 `Expired -> Paid` 入账。
- 验证:确认 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` / `external-generation-controller` 不启动充值过期监听;创建 pending 充值单后只由 scheduled reducer 产生 `expired`HTTP api-server listener 记录 `expiration_checked_at` 或补入账。
- 关联:`server-rs/crates/api-server/src/profile_recharge_expiration_listener.rs``server-rs/crates/spacetime-module/src/runtime/profile.rs``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布
- 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
@@ -52,7 +52,7 @@
- `editor_project_resource` 表保存工程画布引用过的资源快照:`resourceId``projectId``ownerUserId`、OSS / asset object 引用、图片尺寸、来源类型、prompt、actualPrompt、model、provider、taskId、sourceResourceId、`assetKind``generationInputs`、创建时间和更新时间。上传素材被拖入画布时会复制为 project resource,图层只引用 resourceId;图片、图标和 UI 素材生成 BFF 在请求携带 `projectId` 时由后端直接创建新 resource,并把 `resourceId` 随生成响应返回给前端。图片生成请求如果同时携带 `canvasCompletion`(生成器 `dialogId`、标题和占位框,或无 dialog 的右侧完成占位),BFF / worker 在生成成功后必须直接读取当前项目布局,优先使用最新 `generation-dialog` 占位框位置;只有当前布局仍存在对应 `generation-dialog` 时才插入轻量结果图层、把生成器标记为 `idle` 并写入 `generatedLayerId`,沿用后端当前 viewport 保存布局,再返回或刷新最新项目快照;前端只应用该快照刷新显示,不把生成完成态作为本地业务真相,也不在项目加载时根据资源行推断完成态。有项目上下文但后端没有返回项目快照时,前端不得本地补结果图层,只保留当前生成器交互状态等待下一次项目刷新。
- 项目封面图是画布当前视口栅格化后的静态快照资源,不在项目列表页临时重放 `layers + viewport`。前端在项目加载后和防抖保存 layout 时生成 320x240 PNG,走私有 OSS / asset object 上传,再创建 `editor_project_resource`,其中 `assetKind="project-cover-snapshot"``sourceType="uploaded"``/project``/creation` 最近项目卡只读取最新封面快照资源渲染;没有封面快照时显示普通项目占位,不回退为实时画布组合。
- 图片、音频、视频和角色动画帧文件本体继续走 OSS / asset object;浏览器读取私有 generated 对象统一经 `/api/assets/read-url` 换签,签名 URL 可在 session 内复用,但不得作为持久化真相。`/api/assets/read-url` 属于页面展示层高频后台请求,前端统一在 `assetReadUrlService` 内做同 key pending 去重、session 缓存和跨组件节流;UI 设计切片、角色动画帧或大量素材恢复时不得绕过该服务并发换签,否则单页可在同一秒内打满发布入口 `genarrative_api_rps` burst。
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色``抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId``publicationGameInfo``publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution``originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
@@ -197,10 +197,10 @@ npm run check:server-rs-ddd
7. 会员有效期和周期重置时间是两条独立时间线。`expires_at` 只决定会员是否生效;`cycle_resets_at` 只决定当前周期限时泥点何时重置。会员升级只更新档位并补齐当前周期限时泥点差额,不延长 `expires_at`,不移动 `cycle_resets_at` 和周期天数。同级会员购买只从当前 `expires_at` 延长有效期,不发放额外当前周期泥点,也不移动重置时间。
8. 会员周期刷新发生在个人中心、充值中心、任务中心、账单读取和钱包扣费入口:到达 `cycle_resets_at` 时先清除上周期剩余限时泥点,再发放当前会员档位周期额度;会员过期时清除剩余限时泥点并把状态降为普通。周期发放和重置流水分别使用 `membership_period_grant``membership_period_reset`
9. `paymentChannel` 缺失、未知或冒用小程序支付设备时必须拒绝;真实微信渠道只允许 `wechat_mp``wechat_mp_virtual``wechat_jsapi``wechat_h5``wechat_native`,生产配置不得把真实支付静默降级为 `mock`
10. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`微信内浏览器使用 `wechat_jsapi`;普通 Web 使用 `wechat_native`历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
10. access JWT 只携带最小设备快照 `device.client_type``device.client_runtime``device.client_platform`。充值下单按该快照拦截小程序渠道:小程序只允许 `wechat_mp` / `wechat_mp_virtual`移动网页和微信内 H5 走 `wechat_h5`;桌面网页和桌面微信走 `wechat_native``wechat_jsapi` 仅保留后端能力,未接微信开放平台前不由前端自动选择。历史普通 Web 登录态若缺少设备快照也允许继续进入 JSAPI / H5 / Native 渠道的后续支付配置校验,但不放宽小程序虚拟支付。
11. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。
12. 微信 Native 下单显式传 `time_expire`当前有效期为 5 分钟,并通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
13. 普通微信支付渠道的新建 pending 充值订单会写入 `profile_recharge_order_expiration_schedule`。到期处理由 `api-server` 后台 worker claim 调度行后调用微信查单;只有微信返回 `SUCCESS` 才补确认入账,返回 `NOTPAY` / `CLOSED` / `REVOKED` / `PAYERROR` 才关闭本地订单,查询失败或 `USERPAYING` 保留租约等待重试。SpacetimeDB module 不直接发起微信 HTTP 请求
12. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。
13. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 订阅 `profile_recharge_order``Pending -> Expired` 更新并执行微信查单补偿:`SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired``CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`external-generation-worker` / controller处理充值过期;`wechat_mp_virtual` 到期只记录虚拟渠道不可查,后续真实支付通知仍允许 `Expired -> Paid`
## 创作入口泥点扣费契约
@@ -704,12 +704,19 @@ npm run check:server-rs-ddd
- Rust 结构体:`ProfileRechargeOrder`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:账户充值订单事实源。`status` 包含 `pending``paid``failed``closed``refunded``expired`;过期补偿字段 `expired_at``expiration_checked_at``expiration_provider_state``expiration_last_error` 用于记录本地过期和微信查单结果。
### `profile_recharge_order_expiration_schedule`
- Rust 结构体:`ProfileRechargeOrderExpirationSchedule`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:普通微信充值订单到期查单调度表。表内只保存待检查订单、计划检查时间和 worker 短租约;支付成功或本地关闭后删除对应行
- 作用:普通微信充值订单到期查单调度表,保留 schema 兼容但当前不再写入;当前过期路径改由原生 scheduled 表 `profile_recharge_order_expiration_timer` 触发
### `profile_recharge_order_expiration_timer`
- Rust 结构体:`ProfileRechargeOrderExpirationTimer`
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
- 作用:充值订单原生 scheduled 表,`scheduled_at` 到点触发 `expire_profile_recharge_order_timer``order_id` 唯一,支付成功、本地关闭或 scheduled reducer 执行后删除对应行。
### `profile_redeem_code`
@@ -71,6 +71,8 @@ Windows 本地如果已在 `%LOCALAPPDATA%\Genarrative\ffmpeg\bin` 安装 FFmpeg
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY``WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods``productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`
普通微信充值订单本地有效期为 5 分钟。SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点后只把仍为 `pending` 的订单改为 `expired`HTTP `api-server` 通过订阅 `profile_recharge_order``Pending -> Expired` 更新执行微信查单补偿。`external-generation-worker``external-generation-controller` 不运行充值过期逻辑,也不应因为扩容外部生成 worker 放大微信查单或关单流量。查账时本地未支付终态保持 `expired`,不再改写为 `closed``expiration_checked_at``expiration_provider_state``expiration_last_error` 用于判断 HTTP 监听器是否已经完成补偿。
微信小程序订阅消息生成结果通知使用 `WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_ENABLED``WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID``WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 配置。当前模板为 `AI创作生成结果通知`;H5 在生成动作发起前先进入生成进度态并立即继续生成动作,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权,用户接受、拒绝或返回都不能阻塞生成,且原生页不改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。后端只在玩法草稿生成成功或失败终态后用微信登录保存的 openid 调用 `subscribeMessage.send`,发送失败只打 warning,不影响生成主链路。模板 `thing1` 字段发送玩法模板名,例如 `拼图``敲木鱼``抓大鹅``number6` 字段发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`。模板 `time4` 字段固定发送北京时间 `YYYY-MM-DD HH:mm`,不要使用内部微秒时间戳、秒级时间戳或带时区后缀的 RFC3339 字符串,否则微信会返回 `argument invalid! data.time4.value invalid`。当前已接入拼图、敲木鱼、抓大鹅、跳一跳、方洞、视觉小说的草稿生成终态;分槽素材生成或发布动作不得直接复用生成结果通知,避免一次作品生成产生多条订阅消息。
如果本地 `GET /api/creation-entry/config` 返回 `No such procedure`,或 `api-server` 日志出现 `no such table: puzzle_gallery_card_view` / `no such table: wooden_fish_gallery_card_view` 这类公开 view 缺失,通常是 `.env.local` 指向的 SpacetimeDB 库还没有发布当前 `spacetime-module`,或当前 CLI 身份无权发布该库。debug 构建的 `api-server` 会临时使用后端默认入口配置兜底,避免创作作品架整块消失;正式修复仍应切换到拥有目标库权限的 SpacetimeDB 身份后重新运行 `npm run dev` 完成发布,或用 gitignored 的 `spacetime.local.json` 指向可发布的本地库。
@@ -587,6 +589,9 @@ SELECT * FROM profile_wallet_ledger WHERE user_id = '<user_id>' ORDER BY created
-- 充值订单
SELECT * FROM profile_recharge_order ORDER BY created_at DESC LIMIT 50;
-- 未完成过期补偿的充值订单
SELECT * FROM profile_recharge_order WHERE status = 'expired' AND expiration_checked_at IS NULL ORDER BY expired_at ASC;
-- 充值商品配置
SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
```
+6 -1
View File
@@ -117,7 +117,8 @@ export type ProfileRechargeOrderStatus =
| 'paid'
| 'failed'
| 'closed'
| 'refunded';
| 'refunded'
| 'expired';
export type ProfileRechargeProduct = {
productId: string;
@@ -174,6 +175,10 @@ export type ProfileRechargeOrder = {
createdAt: string;
pointsDelta: number;
membershipExpiresAt: string | null;
expiredAt?: string | null;
expirationCheckedAt?: string | null;
expirationProviderState?: string | null;
expirationLastError?: string | null;
};
export type ProfileRechargeCenterResponse = {
+1
View File
@@ -2447,6 +2447,7 @@ fn normalize_admin_database_known_enum(
2 => "failed",
3 => "closed",
4 => "refunded",
5 => "expired",
_ => return None,
},
_ => return None,
+30 -7
View File
@@ -77,7 +77,7 @@ mod phone_auth;
mod platform_errors;
mod process_metrics;
mod profile_identity;
mod profile_recharge_expiration_worker;
mod profile_recharge_expiration_listener;
mod prompt;
mod public_work;
mod puzzle;
@@ -134,7 +134,7 @@ use crate::{
config::{AppConfig, ProcessRole},
external_generation_worker::run_external_generation_worker,
external_generation_worker_controller::run_external_generation_worker_controller,
profile_recharge_expiration_worker::spawn_profile_recharge_expiration_worker,
profile_recharge_expiration_listener::spawn_profile_recharge_expiration_listener,
state::{AppState, AppStateInitError},
tracking_outbox::TrackingOutbox,
wallet_refund_outbox::WalletRefundOutbox,
@@ -202,7 +202,7 @@ async fn run_worker_only(config: AppConfig) -> Result<(), io::Error> {
"初始化 external generation worker 状态失败:{error}"
))
})?;
spawn_app_state_background_workers(&state);
spawn_common_app_state_background_workers(&state);
info!(
process_role = process_role.as_str(),
"api-server 以非 HTTP 角色启动"
@@ -247,7 +247,7 @@ async fn run_http_role(config: AppConfig) -> Result<(), io::Error> {
let (router, shutdown_context, worker_state) = match restore_app_state_for_startup(config).await
{
Ok(state) => {
spawn_app_state_background_workers(&state);
spawn_http_app_state_background_workers(&state, process_role);
let tracking_outbox = state.tracking_outbox();
let wallet_refund_outbox = state.wallet_refund_outbox();
let worker_state = process_role
@@ -410,9 +410,8 @@ async fn finalize_shutdown(context: ShutdownContext) {
}
}
fn spawn_app_state_background_workers(state: &AppState) {
fn spawn_common_app_state_background_workers(state: &AppState) {
state.puzzle_gallery_cache().spawn_cleanup_task();
spawn_profile_recharge_expiration_worker(state.clone());
if let Some(outbox) = state.tracking_outbox() {
outbox.spawn_worker();
}
@@ -421,6 +420,17 @@ fn spawn_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);
if should_start_profile_recharge_expiration_listener(process_role) {
spawn_profile_recharge_expiration_listener(state.clone());
}
}
fn should_start_profile_recharge_expiration_listener(process_role: ProcessRole) -> bool {
process_role.runs_http()
}
fn build_tcp_listener(
bind_address: SocketAddr,
listen_backlog: i32,
@@ -551,7 +561,8 @@ fn is_valid_env_key(key: &str) -> bool {
mod tests {
use super::{
AUTH_STORE_STARTUP_RETRY_INTERVAL, is_valid_env_key, protected_env_keys_from,
should_restore_auth_store_for_startup, strip_env_value,
should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener,
strip_env_value,
};
use crate::config::ProcessRole;
@@ -611,4 +622,16 @@ mod tests {
ProcessRole::ExternalGenerationController
));
}
#[test]
fn profile_recharge_expiration_listener_is_limited_to_http_roles() {
assert!(should_start_profile_recharge_expiration_listener(ProcessRole::Api));
assert!(should_start_profile_recharge_expiration_listener(ProcessRole::All));
assert!(!should_start_profile_recharge_expiration_listener(
ProcessRole::ExternalGenerationWorker
));
assert!(!should_start_profile_recharge_expiration_listener(
ProcessRole::ExternalGenerationController
));
}
}
@@ -0,0 +1,345 @@
use std::{collections::HashSet, time::Duration};
use module_runtime::{
PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL,
RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus,
};
use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder};
use shared_kernel::{offset_datetime_to_unix_micros, parse_rfc3339};
use tokio::time::sleep;
use tracing::{debug, info, warn};
use crate::{state::AppState, wechat::pay::current_unix_micros};
const PROFILE_RECHARGE_EXPIRATION_LISTENER_RECONNECT_DELAY: Duration = Duration::from_secs(5);
const PROFILE_RECHARGE_EXPIRATION_CATCH_UP_LIMIT: u32 = 100;
const PROFILE_RECHARGE_EXPIRATION_RETRY_DELAYS: [Duration; 3] = [
Duration::from_secs(5),
Duration::from_secs(15),
Duration::from_secs(30),
];
pub fn spawn_profile_recharge_expiration_listener(state: AppState) {
tokio::spawn(async move {
run_profile_recharge_expiration_listener(state).await;
});
}
async fn run_profile_recharge_expiration_listener(state: AppState) {
loop {
match state
.spacetime_client()
.subscribe_profile_recharge_order_expiration_events()
.await
{
Ok(mut subscription) => {
info!("profile recharge expiration listener connected");
run_profile_recharge_expiration_catch_up(&state).await;
loop {
match subscription.recv().await {
Ok(order) => {
let state = state.clone();
tokio::spawn(async move {
process_expired_profile_recharge_order_with_retries(state, order)
.await;
});
}
Err(error) => {
warn!(
error = %error,
"profile recharge expiration listener disconnected"
);
break;
}
}
}
}
Err(error) => {
warn!(
error = %error,
"profile recharge expiration listener failed to subscribe"
);
}
}
sleep(PROFILE_RECHARGE_EXPIRATION_LISTENER_RECONNECT_DELAY).await;
}
}
async fn run_profile_recharge_expiration_catch_up(state: &AppState) {
let mut processed_count = 0usize;
let mut seen_order_ids = HashSet::new();
loop {
let mut orders = match state
.spacetime_client()
.list_unchecked_expired_profile_recharge_orders(
PROFILE_RECHARGE_EXPIRATION_CATCH_UP_LIMIT,
)
.await
{
Ok(orders) => orders,
Err(error) => {
warn!(
error = %error,
"profile recharge expiration catch-up failed to list unchecked orders"
);
return;
}
};
if orders.is_empty() {
if processed_count == 0 {
debug!("profile recharge expiration catch-up found no unchecked orders");
}
return;
}
let listed_len = orders.len();
orders.retain(|order| seen_order_ids.insert(order.order_id.clone()));
if orders.is_empty() {
return;
}
let batch_len = orders.len();
processed_count += batch_len;
info!(
order_count = batch_len,
total_count = processed_count,
"profile recharge expiration catch-up processing unchecked orders"
);
let mut handles = Vec::with_capacity(batch_len);
for order in orders {
let state = state.clone();
handles.push(tokio::spawn(async move {
process_expired_profile_recharge_order_with_retries(state, order).await;
}));
}
for handle in handles {
if let Err(error) = handle.await {
warn!(
error = %error,
"profile recharge expiration catch-up task failed to join"
);
}
}
if listed_len < PROFILE_RECHARGE_EXPIRATION_CATCH_UP_LIMIT as usize {
return;
}
tokio::task::yield_now().await;
}
}
async fn process_expired_profile_recharge_order_with_retries(
state: AppState,
order: RuntimeProfileRechargeOrderRecord,
) {
let order_id = order.order_id.clone();
for (attempt_index, delay) in std::iter::once(Duration::ZERO)
.chain(PROFILE_RECHARGE_EXPIRATION_RETRY_DELAYS)
.enumerate()
{
if !delay.is_zero() {
sleep(delay).await;
}
match process_expired_profile_recharge_order_once(&state, &order).await {
Ok(()) => return,
Err(error) => {
warn!(
order_id = order_id.as_str(),
attempt = attempt_index + 1,
error = %error,
"profile recharge expiration compensation attempt failed"
);
record_profile_recharge_expiration_error(&state, &order_id, error.to_string())
.await;
}
}
}
}
async fn process_expired_profile_recharge_order_once(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
) -> Result<(), ExpirationCompensationError> {
if order.status != RuntimeProfileRechargeOrderStatus::Expired {
return Ok(());
}
if order.payment_channel == PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL {
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some("VIRTUAL_UNQUERYABLE".to_string()),
Some("wechat_mp_virtual has no v3 transaction query".to_string()),
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
return Ok(());
}
let wechat_order = match state
.wechat_pay_client()
.query_order_by_out_trade_no(&order.order_id)
.await
{
Ok(order) => order,
Err(WechatPayError::OrderNotExist(message)) => {
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some("ORDER_NOT_EXIST".to_string()),
Some(message),
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
return Ok(());
}
Err(error) => return Err(error.into()),
};
if wechat_order.out_trade_no != order.order_id {
return Err(ExpirationCompensationError::Runtime(format!(
"wechat query returned mismatched out_trade_no: {}",
wechat_order.out_trade_no
)));
}
match wechat_order.trade_state.as_str() {
"SUCCESS" => mark_expired_profile_recharge_order_paid(state, order, wechat_order).await,
"NOTPAY" => {
state
.wechat_pay_client()
.close_order_by_out_trade_no(&order.order_id)
.await?;
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some("NOTPAY".to_string()),
None,
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
Ok(())
}
"CLOSED" | "REVOKED" | "PAYERROR" => {
mark_profile_recharge_expiration_checked(
state,
&order.order_id,
Some(current_unix_micros()),
Some(wechat_order.trade_state),
None,
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
Ok(())
}
trade_state => Err(ExpirationCompensationError::Runtime(format!(
"wechat trade_state is not final for expiration compensation: {trade_state}"
))),
}
}
async fn mark_expired_profile_recharge_order_paid(
state: &AppState,
order: &RuntimeProfileRechargeOrderRecord,
wechat_order: WechatPayNotifyOrder,
) -> Result<(), ExpirationCompensationError> {
let paid_at_micros = wechat_order
.success_time
.as_deref()
.and_then(|value| parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
state
.spacetime_client()
.mark_profile_recharge_order_paid(
order.order_id.clone(),
paid_at_micros,
wechat_order.transaction_id,
)
.await?;
state.publish_profile_recharge_order_update(order.order_id.clone());
info!(
order_id = order.order_id.as_str(),
"expired profile recharge order compensated as paid"
);
Ok(())
}
async fn mark_profile_recharge_expiration_checked(
state: &AppState,
order_id: &str,
checked_at_micros: Option<i64>,
provider_state: Option<String>,
last_error: Option<String>,
) -> Result<(), ExpirationCompensationError> {
state
.spacetime_client()
.mark_profile_recharge_order_expiration_checked(
order_id.to_string(),
checked_at_micros,
provider_state,
last_error,
)
.await?;
Ok(())
}
async fn record_profile_recharge_expiration_error(
state: &AppState,
order_id: &str,
error: String,
) {
if let Err(mark_error) = mark_profile_recharge_expiration_checked(
state,
order_id,
None,
None,
Some(error),
)
.await
{
warn!(
order_id,
error = %mark_error,
"failed to record profile recharge expiration compensation error"
);
}
}
#[derive(Debug)]
enum ExpirationCompensationError {
WechatPay(WechatPayError),
Spacetime(spacetime_client::SpacetimeClientError),
Runtime(String),
}
impl From<WechatPayError> for ExpirationCompensationError {
fn from(error: WechatPayError) -> Self {
Self::WechatPay(error)
}
}
impl From<spacetime_client::SpacetimeClientError> for ExpirationCompensationError {
fn from(error: spacetime_client::SpacetimeClientError) -> Self {
Self::Spacetime(error)
}
}
impl std::fmt::Display for ExpirationCompensationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::WechatPay(error) => write!(formatter, "wechat pay error: {error}"),
Self::Spacetime(error) => write!(formatter, "spacetime error: {error}"),
Self::Runtime(message) => formatter.write_str(message),
}
}
}
impl std::error::Error for ExpirationCompensationError {}
@@ -1,207 +0,0 @@
use std::{process, time::Duration};
use module_runtime::RuntimeProfileRechargeOrderExpirationScheduleSnapshot;
use platform_wechat::pay::WechatPayError;
use shared_kernel::offset_datetime_to_unix_micros;
use time::OffsetDateTime;
use tokio::time::MissedTickBehavior;
use tracing::{debug, info, warn};
use crate::state::AppState;
const PROFILE_RECHARGE_EXPIRATION_WORKER_INTERVAL: Duration = Duration::from_secs(15);
const PROFILE_RECHARGE_EXPIRATION_WORKER_LEASE: Duration = Duration::from_secs(90);
const PROFILE_RECHARGE_EXPIRATION_WORKER_CLAIM_LIMIT: u32 = 20;
pub fn spawn_profile_recharge_expiration_worker(state: AppState) {
tokio::spawn(async move {
run_profile_recharge_expiration_worker(state).await;
});
}
async fn run_profile_recharge_expiration_worker(state: AppState) {
let worker_id = format!("api:{}:profile-recharge-expiration", process::id());
let mut interval = tokio::time::interval(PROFILE_RECHARGE_EXPIRATION_WORKER_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
interval.tick().await;
let now_micros = current_unix_micros();
let lease_expires_at_micros = now_micros
+ i64::try_from(PROFILE_RECHARGE_EXPIRATION_WORKER_LEASE.as_micros())
.unwrap_or(90_000_000);
let schedules = match state
.spacetime_client()
.claim_profile_recharge_order_expiration_schedules(
worker_id.clone(),
now_micros,
lease_expires_at_micros,
PROFILE_RECHARGE_EXPIRATION_WORKER_CLAIM_LIMIT,
)
.await
{
Ok(schedules) => schedules,
Err(error) => {
warn!("充值订单过期检查 claim 失败:{error}");
continue;
}
};
if schedules.is_empty() {
debug!("充值订单过期检查暂无到期任务");
continue;
}
for schedule in schedules {
process_profile_recharge_expiration_schedule(&state, schedule).await;
}
}
}
async fn process_profile_recharge_expiration_schedule(
state: &AppState,
schedule: RuntimeProfileRechargeOrderExpirationScheduleSnapshot,
) {
let order_id = schedule.order_id.clone();
let wechat_order = match state
.wechat_pay_client()
.query_order_by_out_trade_no(&order_id)
.await
{
Ok(order) => order,
Err(WechatPayError::OrderNotExist(message)) => {
warn!(
order_id = order_id.as_str(),
error = message.as_str(),
"过期前微信查单确认订单不存在,关闭本地 pending 订单"
);
close_order_not_found_profile_recharge_order(state, &order_id).await;
return;
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"过期前微信查单失败,将等待租约过期后重试:{error}"
);
return;
}
};
if wechat_order.out_trade_no != order_id {
warn!(
order_id = order_id.as_str(),
provider_order_id = wechat_order.out_trade_no.as_str(),
"过期前微信查单返回的商户订单号不匹配,将等待租约过期后重试"
);
return;
}
match wechat_order.trade_state.as_str() {
"SUCCESS" => {
let paid_at_micros = wechat_order
.success_time
.as_deref()
.and_then(|value| shared_kernel::parse_rfc3339(value).ok())
.map(offset_datetime_to_unix_micros)
.unwrap_or_else(current_unix_micros);
match state
.spacetime_client()
.mark_profile_recharge_order_paid(
order_id.clone(),
paid_at_micros,
wechat_order.transaction_id,
)
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, &order_id).await;
state.publish_profile_recharge_order_update(order_id.clone());
info!(
order_id = order_id.as_str(),
"过期检查发现微信已支付,已补确认入账"
);
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"过期检查确认已支付订单失败:{error}"
);
}
}
}
"NOTPAY" | "CLOSED" | "REVOKED" | "PAYERROR" => {
match state
.spacetime_client()
.close_profile_recharge_order(order_id.clone(), current_unix_micros())
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, &order_id).await;
state.publish_profile_recharge_order_update(order_id.clone());
info!(
order_id = order_id.as_str(),
trade_state = wechat_order.trade_state.as_str(),
"充值订单到期且微信确认未支付,已关闭本地订单"
);
}
Err(error) => {
warn!(
order_id = order_id.as_str(),
"关闭过期充值订单失败:{error}"
);
}
}
}
"USERPAYING" => {
info!(
order_id = order_id.as_str(),
"微信订单仍在支付中,将等待租约过期后重试"
);
}
trade_state => {
warn!(
order_id = order_id.as_str(),
trade_state, "微信订单状态暂不适合本地关闭,将等待租约过期后重试"
);
}
}
}
async fn close_order_not_found_profile_recharge_order(state: &AppState, order_id: &str) {
match state
.spacetime_client()
.close_profile_recharge_order(order_id.to_string(), current_unix_micros())
.await
{
Ok(_) => {
complete_profile_recharge_expiration_schedule(state, order_id).await;
state.publish_profile_recharge_order_update(order_id.to_string());
info!(
order_id,
reason = "ORDER_NOT_EXIST",
"过期检查发现微信订单不存在,已关闭本地充值订单"
);
}
Err(error) => {
warn!(
order_id,
reason = "ORDER_NOT_EXIST",
"关闭微信不存在的过期充值订单失败:{error}"
);
}
}
}
async fn complete_profile_recharge_expiration_schedule(state: &AppState, order_id: &str) {
if let Err(error) = state
.spacetime_client()
.complete_profile_recharge_order_expiration_schedule(order_id.to_string())
.await
{
warn!(order_id, "删除充值订单过期检查任务失败:{error}");
}
}
fn current_unix_micros() -> i64 {
offset_datetime_to_unix_micros(OffsetDateTime::now_utc())
}
@@ -26,7 +26,7 @@ use module_runtime::{
RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType,
RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind,
};
use platform_wechat::pay::WechatPayNotifyOrder;
use platform_wechat::pay::{WechatPayError, WechatPayNotifyOrder};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::Sha256;
@@ -368,7 +368,10 @@ pub async fn confirm_wechat_profile_recharge_order(
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
if !matches!(
order.status,
RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired
) {
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
@@ -381,13 +384,26 @@ pub async fn confirm_wechat_profile_recharge_order(
));
}
let wechat_order = state
let order_was_expired = order.status == RuntimeProfileRechargeOrderStatus::Expired;
let wechat_order = match state
.wechat_pay_client()
.query_order_by_out_trade_no(&order.order_id)
.await
.map_err(|error| {
runtime_profile_error_response(&request_context, map_wechat_pay_error(error))
})?;
{
Ok(wechat_order) => wechat_order,
Err(WechatPayError::OrderNotExist(_)) if order_was_expired => {
return Ok(json_success_body(
Some(&request_context),
build_wechat_profile_recharge_order_confirmation(center, order),
));
}
Err(error) => {
return Err(runtime_profile_error_response(
&request_context,
map_wechat_pay_error(error),
));
}
};
if wechat_order.out_trade_no != order.order_id {
return Err(runtime_profile_error_response(
&request_context,
@@ -448,7 +464,7 @@ pub async fn stream_wechat_profile_recharge_order_events(
"order",
&initial_response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
@@ -483,7 +499,7 @@ pub async fn stream_wechat_profile_recharge_order_events(
"order",
&response,
));
if order.status != RuntimeProfileRechargeOrderStatus::Pending {
if is_wechat_profile_recharge_order_terminal_for_confirmation(&order) {
yield Ok::<Event, Infallible>(wechat_profile_recharge_sse_json_event(
"done",
&WechatProfileRechargeOrderDoneEvent {
@@ -1252,10 +1268,27 @@ fn build_profile_recharge_order_status(status: RuntimeProfileRechargeOrderStatus
RuntimeProfileRechargeOrderStatus::Failed => "failed",
RuntimeProfileRechargeOrderStatus::Closed => "closed",
RuntimeProfileRechargeOrderStatus::Refunded => "refunded",
RuntimeProfileRechargeOrderStatus::Expired => "expired",
}
.to_string()
}
fn is_wechat_profile_recharge_order_terminal_for_confirmation(
order: &RuntimeProfileRechargeOrderRecord,
) -> bool {
if order.status == RuntimeProfileRechargeOrderStatus::Pending {
return false;
}
if order.status == RuntimeProfileRechargeOrderStatus::Expired
&& order.expiration_checked_at_micros.is_none()
{
return false;
}
true
}
fn wechat_profile_recharge_sse_json_event<T>(event_name: &str, payload: &T) -> Event
where
T: Serialize,
@@ -1691,6 +1724,10 @@ fn build_profile_recharge_order_response(
created_at: record.created_at,
points_delta: record.points_delta,
membership_expires_at: record.membership_expires_at,
expired_at: record.expired_at,
expiration_checked_at: record.expiration_checked_at,
expiration_provider_state: record.expiration_provider_state,
expiration_last_error: record.expiration_last_error,
}
}
@@ -2125,7 +2162,9 @@ mod tests {
use super::{
build_wechat_virtual_pay_params, calc_wechat_virtual_payment_pay_signature_with_key,
calc_wechat_virtual_payment_user_signature_with_key,
format_profile_wallet_ledger_source_type, normalize_admin_invite_code_metadata,
format_profile_wallet_ledger_source_type,
is_wechat_profile_recharge_order_terminal_for_confirmation,
normalize_admin_invite_code_metadata,
};
use axum::{
@@ -2895,6 +2934,12 @@ mod tests {
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
expired_at: None,
expired_at_micros: None,
expiration_checked_at: None,
expiration_checked_at_micros: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = RuntimeProfileRechargeCenterRecord {
@@ -3004,6 +3049,12 @@ mod tests {
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
expired_at: None,
expired_at_micros: None,
expiration_checked_at: None,
expiration_checked_at_micros: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = RuntimeProfileRechargeCenterRecord {
@@ -3111,6 +3162,12 @@ mod tests {
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
expired_at: None,
expired_at_micros: None,
expiration_checked_at: None,
expiration_checked_at_micros: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = RuntimeProfileRechargeCenterRecord {
@@ -3189,6 +3246,58 @@ mod tests {
);
}
fn build_test_recharge_order(
status: RuntimeProfileRechargeOrderStatus,
expiration_checked_at_micros: Option<i64>,
) -> RuntimeProfileRechargeOrderRecord {
RuntimeProfileRechargeOrderRecord {
order_id: "confirm-terminal-order".to_string(),
user_id: "user-terminal".to_string(),
product_id: "points_60".to_string(),
product_title: "60娉ョ偣".to_string(),
kind: RuntimeProfileRechargeProductKind::Points,
amount_cents: 600,
status,
payment_channel: "wechat_native".to_string(),
paid_at: None,
paid_at_micros: None,
provider_transaction_id: None,
created_at: "2026-04-25T10:00:00Z".to_string(),
created_at_micros: 1_777_111_200_000_000,
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
expired_at: None,
expired_at_micros: None,
expiration_checked_at: expiration_checked_at_micros
.map(|_| "2026-04-25T10:05:02Z".to_string()),
expiration_checked_at_micros,
expiration_provider_state: None,
expiration_last_error: None,
}
}
#[test]
fn expired_recharge_order_waits_for_expiration_compensation_check() {
let order = build_test_recharge_order(RuntimeProfileRechargeOrderStatus::Expired, None);
assert!(!is_wechat_profile_recharge_order_terminal_for_confirmation(
&order
));
}
#[test]
fn checked_expired_recharge_order_is_terminal_for_confirmation() {
let order = build_test_recharge_order(
RuntimeProfileRechargeOrderStatus::Expired,
Some(1_777_111_502_000_000),
);
assert!(is_wechat_profile_recharge_order_terminal_for_confirmation(
&order
));
}
#[tokio::test]
async fn wechat_virtual_payment_sandbox_requires_sandbox_app_key() {
let state = seed_authenticated_state_with_config(AppConfig {
@@ -3231,6 +3340,12 @@ mod tests {
points_delta: 0,
membership_expires_at: None,
membership_expires_at_micros: None,
expired_at: None,
expired_at_micros: None,
expiration_checked_at: None,
expiration_checked_at_micros: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = RuntimeProfileRechargeCenterRecord {
@@ -1159,6 +1159,12 @@ pub fn build_runtime_profile_recharge_order_record(
points_delta: snapshot.points_delta,
membership_expires_at: snapshot.membership_expires_at_micros.map(format_utc_micros),
membership_expires_at_micros: snapshot.membership_expires_at_micros,
expired_at: snapshot.expired_at_micros.map(format_utc_micros),
expired_at_micros: snapshot.expired_at_micros,
expiration_checked_at: snapshot.expiration_checked_at_micros.map(format_utc_micros),
expiration_checked_at_micros: snapshot.expiration_checked_at_micros,
expiration_provider_state: snapshot.expiration_provider_state,
expiration_last_error: snapshot.expiration_last_error,
}
}
@@ -449,6 +449,28 @@ pub fn build_runtime_profile_recharge_order_expiration_complete_input(
Ok(RuntimeProfileRechargeOrderExpirationCompleteInput { order_id })
}
pub fn build_runtime_profile_recharge_order_expiration_check_list_input(
limit: u32,
) -> Result<RuntimeProfileRechargeOrderExpirationCheckListInput, RuntimeProfileFieldError> {
Ok(RuntimeProfileRechargeOrderExpirationCheckListInput { limit })
}
pub fn build_runtime_profile_recharge_order_expiration_check_input(
order_id: String,
checked_at_micros: Option<i64>,
provider_state: Option<String>,
last_error: Option<String>,
) -> Result<RuntimeProfileRechargeOrderExpirationCheckInput, RuntimeProfileFieldError> {
let order_id =
normalize_required_string(order_id).ok_or(RuntimeProfileFieldError::MissingOrderId)?;
Ok(RuntimeProfileRechargeOrderExpirationCheckInput {
order_id,
checked_at_micros,
provider_state: provider_state.and_then(normalize_required_string),
last_error: last_error.and_then(normalize_required_string),
})
}
pub fn build_runtime_profile_feedback_submission_input(
user_id: String,
description: String,
@@ -1184,6 +1184,7 @@ pub enum RuntimeProfileRechargeOrderStatus {
Failed,
Closed,
Refunded,
Expired,
}
impl RuntimeProfileRechargeOrderStatus {
@@ -1194,6 +1195,7 @@ impl RuntimeProfileRechargeOrderStatus {
Self::Failed => "failed",
Self::Closed => "closed",
Self::Refunded => "refunded",
Self::Expired => "expired",
}
}
}
@@ -1288,6 +1290,10 @@ pub struct RuntimeProfileRechargeOrderSnapshot {
pub created_at_micros: i64,
pub points_delta: i64,
pub membership_expires_at_micros: Option<i64>,
pub expired_at_micros: Option<i64>,
pub expiration_checked_at_micros: Option<i64>,
pub expiration_provider_state: Option<String>,
pub expiration_last_error: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
@@ -1415,6 +1421,21 @@ pub struct RuntimeProfileRechargeOrderExpirationCompleteInput {
pub order_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCheckListInput {
pub limit: u32,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCheckInput {
pub order_id: String,
pub checked_at_micros: Option<i64>,
pub provider_state: Option<String>,
pub last_error: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationScheduleSnapshot {
@@ -1442,6 +1463,22 @@ pub struct RuntimeProfileRechargeOrderExpirationCompleteProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCheckListProcedureResult {
pub ok: bool,
pub entries: Vec<RuntimeProfileRechargeOrderSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileRechargeOrderExpirationCheckProcedureResult {
pub ok: bool,
pub record: Option<RuntimeProfileRechargeOrderSnapshot>,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuntimeProfileWalletLedgerEntrySnapshot {
@@ -1931,6 +1968,12 @@ pub struct RuntimeProfileRechargeOrderRecord {
pub points_delta: i64,
pub membership_expires_at: Option<String>,
pub membership_expires_at_micros: Option<i64>,
pub expired_at: Option<String>,
pub expired_at_micros: Option<i64>,
pub expiration_checked_at: Option<String>,
pub expiration_checked_at_micros: Option<i64>,
pub expiration_provider_state: Option<String>,
pub expiration_last_error: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
+58 -6
View File
@@ -47,7 +47,7 @@ const WECHAT_PAY_CLIENT_IP_MAX_CHARS: usize = 45;
const WECHAT_PAY_JSAPI_PATH: &str = "/v3/pay/transactions/jsapi";
const WECHAT_PAY_H5_PATH: &str = "/v3/pay/transactions/h5";
const WECHAT_PAY_NATIVE_PATH: &str = "/v3/pay/transactions/native";
const WECHAT_NATIVE_PAY_EXPIRE_SECONDS: i64 = 5 * 60;
const WECHAT_PAY_ORDER_EXPIRE_SECONDS: i64 = 5 * 60;
const WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY_BYTES: usize = 43;
const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BYTES: usize = 32;
const WECHAT_MINIPROGRAM_MESSAGE_RANDOM_BYTES: usize = 16;
@@ -149,6 +149,7 @@ struct WechatJsapiOrderRequest<'a> {
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
payer: WechatJsapiPayer<'a>,
@@ -171,6 +172,7 @@ struct WechatH5OrderRequest<'a> {
mchid: &'a str,
description: &'a str,
out_trade_no: &'a str,
time_expire: &'a str,
notify_url: &'a str,
amount: WechatJsapiAmount,
scene_info: WechatH5SceneInfo<'a>,
@@ -205,6 +207,11 @@ struct WechatNativeSceneInfo<'a> {
payer_client_ip: &'a str,
}
#[derive(Serialize)]
struct WechatCloseOrderRequest<'a> {
mchid: &'a str,
}
#[derive(Deserialize)]
struct WechatJsapiOrderResponse {
prepay_id: Option<String>,
@@ -452,6 +459,17 @@ impl WechatPayClient {
Self::Real(client) => client.query_order_by_out_trade_no(order_id).await,
}
}
pub async fn close_order_by_out_trade_no(&self, order_id: &str) -> Result<(), WechatPayError> {
match self {
Self::Disabled => Err(WechatPayError::Disabled),
Self::Mock => {
normalize_out_trade_no(order_id)?;
Ok(())
}
Self::Real(client) => client.close_order_by_out_trade_no(order_id).await,
}
}
}
impl RealWechatPayClient {
@@ -462,11 +480,13 @@ impl RealWechatPayClient {
validate_jsapi_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay JSAPI time_expire")?;
let body = serde_json::to_string(&WechatJsapiOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
@@ -527,11 +547,13 @@ impl RealWechatPayClient {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay H5 time_expire")?;
let body = serde_json::to_string(&WechatH5OrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
description: &request.description,
out_trade_no: &request.order_id,
time_expire: &expires_at_text,
notify_url: &self.notify_url,
amount: WechatJsapiAmount {
total: amount_total,
@@ -580,10 +602,7 @@ impl RealWechatPayClient {
validate_web_order_request(self, &request)?;
let amount_total = i64::try_from(request.amount_cents)
.map_err(|_| WechatPayError::InvalidRequest("微信支付金额超出 i64 范围".to_string()))?;
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_NATIVE_PAY_EXPIRE_SECONDS);
let expires_at_text =
format_wechat_pay_rfc3339_seconds(expires_at, "微信支付 Native 过期时间")?;
let (_, expires_at_text) = build_wechat_pay_expire_time("wechat pay Native time_expire")?;
let body = serde_json::to_string(&WechatNativeOrderRequest {
appid: &self.app_id,
mchid: &self.mch_id,
@@ -805,6 +824,26 @@ impl RealWechatPayClient {
})
}
async fn close_order_by_out_trade_no(&self, order_id: &str) -> Result<(), WechatPayError> {
let order_id = normalize_out_trade_no(order_id)?;
let encoded_order_id = urlencoding::encode(&order_id);
let path = format!("/v3/pay/transactions/out-trade-no/{encoded_order_id}/close");
let request_url = format!(
"{}/{encoded_order_id}/close",
self.query_order_endpoint_base.trim_end_matches('/')
);
let body = serde_json::to_string(&WechatCloseOrderRequest {
mchid: &self.mch_id,
})
.map_err(|error| {
WechatPayError::Deserialize(format!("wechat pay close request serialize failed: {error}"))
})?;
self.post_wechat_json(&request_url, &path, body, "wechat pay close request failed")
.await
.map(|_| ())
}
fn verify_notify_signature(
&self,
headers: &HeaderMap,
@@ -905,6 +944,15 @@ fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse {
}
}
fn build_wechat_pay_expire_time(
context: &str,
) -> Result<(OffsetDateTime, String), WechatPayError> {
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_PAY_ORDER_EXPIRE_SECONDS);
let expires_at_text = format_wechat_pay_rfc3339_seconds(expires_at, context)?;
Ok((expires_at, expires_at_text))
}
fn format_wechat_pay_rfc3339_seconds(
value: OffsetDateTime,
context: &str,
@@ -919,7 +967,7 @@ fn format_wechat_pay_rfc3339_seconds(
fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse {
let expires_at =
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_NATIVE_PAY_EXPIRE_SECONDS);
OffsetDateTime::now_utc() + TimeDuration::seconds(WECHAT_PAY_ORDER_EXPIRE_SECONDS);
WechatNativePaymentResponse {
code_url: format!(
"weixin://pay.weixin.qq.com/bizpayurl/up?pr=mock-{}",
@@ -1635,6 +1683,7 @@ mod tests {
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
@@ -1647,6 +1696,7 @@ mod tests {
.expect("JSAPI order request should serialize");
assert_eq!(body["out_trade_no"], "rcgtest001");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(
body["notify_url"],
"https://api.example.com/api/profile/recharge/wechat/notify"
@@ -1662,6 +1712,7 @@ mod tests {
mchid: "1900000001",
description: "陶泥儿 - 60泥点",
out_trade_no: "rcgtest001",
time_expire: "2026-05-15T10:05:00Z",
notify_url: "https://api.example.com/api/profile/recharge/wechat/notify",
amount: WechatJsapiAmount {
total: 600,
@@ -1676,6 +1727,7 @@ mod tests {
assert_eq!(body["scene_info"]["payer_client_ip"], "203.0.113.10");
assert_eq!(body["scene_info"]["h5_info"]["type"], "Wap");
assert_eq!(body["time_expire"], "2026-05-15T10:05:00Z");
assert_eq!(body["amount"]["currency"], "CNY");
assert!(body.get("sceneInfo").is_none());
assert!(body["scene_info"].get("payerClientIp").is_none());
@@ -254,6 +254,10 @@ pub struct ProfileRechargeOrderResponse {
pub created_at: String,
pub points_delta: i64,
pub membership_expires_at: Option<String>,
pub expired_at: Option<String>,
pub expiration_checked_at: Option<String>,
pub expiration_provider_state: Option<String>,
pub expiration_last_error: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -1517,6 +1521,10 @@ mod tests {
created_at: "2026-05-15T10:00:00Z".to_string(),
points_delta: 0,
membership_expires_at: None,
expired_at: None,
expiration_checked_at: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = ProfileRechargeCenterResponse {
wallet_balance: 0,
@@ -1581,6 +1589,10 @@ mod tests {
created_at: "2026-05-15T10:00:00Z".to_string(),
points_delta: 0,
membership_expires_at: Some("2026-06-15T10:00:00Z".to_string()),
expired_at: None,
expiration_checked_at: None,
expiration_provider_state: None,
expiration_last_error: None,
};
let center = ProfileRechargeCenterResponse {
wallet_balance: 0,
@@ -144,6 +144,8 @@ pub mod editor_project;
pub mod external_api_key;
pub mod external_generation;
pub use external_generation::ExternalGenerationQueueWakeSubscription;
pub mod profile_recharge_expiration;
pub use profile_recharge_expiration::ProfileRechargeExpirationSubscription;
pub mod inventory;
pub mod jump_hop;
@@ -291,9 +291,12 @@ pub(crate) use self::runtime_profile::{
map_runtime_profile_invite_code_admin_procedure_result,
map_runtime_profile_play_stats_procedure_result,
map_runtime_profile_recharge_center_procedure_result,
map_runtime_profile_recharge_order_expiration_check_list_procedure_result,
map_runtime_profile_recharge_order_expiration_check_procedure_result,
map_runtime_profile_recharge_order_expiration_claim_procedure_result,
map_runtime_profile_recharge_order_expiration_complete_procedure_result,
map_runtime_profile_recharge_order_procedure_result,
map_runtime_profile_recharge_order_table_row,
map_runtime_profile_recharge_product_admin_list_procedure_result,
map_runtime_profile_recharge_product_admin_procedure_result,
map_runtime_profile_redeem_code_admin_list_procedure_result,
@@ -244,6 +244,27 @@ impl From<module_runtime::RuntimeProfileRechargeOrderExpirationCompleteInput>
}
}
impl From<module_runtime::RuntimeProfileRechargeOrderExpirationCheckListInput>
for RuntimeProfileRechargeOrderExpirationCheckListInput
{
fn from(input: module_runtime::RuntimeProfileRechargeOrderExpirationCheckListInput) -> Self {
Self { limit: input.limit }
}
}
impl From<module_runtime::RuntimeProfileRechargeOrderExpirationCheckInput>
for RuntimeProfileRechargeOrderExpirationCheckInput
{
fn from(input: module_runtime::RuntimeProfileRechargeOrderExpirationCheckInput) -> Self {
Self {
order_id: input.order_id,
checked_at_micros: input.checked_at_micros,
provider_state: input.provider_state,
last_error: input.last_error,
}
}
}
impl From<module_runtime::RuntimeProfileRechargeProductAdminListInput>
for RuntimeProfileRechargeProductAdminListInput
{
@@ -518,6 +539,36 @@ pub(crate) fn map_runtime_profile_recharge_order_expiration_complete_procedure_r
Ok(())
}
pub(crate) fn map_runtime_profile_recharge_order_expiration_check_list_procedure_result(
result: RuntimeProfileRechargeOrderExpirationCheckListProcedureResult,
) -> Result<Vec<RuntimeProfileRechargeOrderRecord>, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
Ok(result
.entries
.into_iter()
.map(map_runtime_profile_recharge_order_snapshot)
.map(module_runtime::build_runtime_profile_recharge_order_record)
.collect())
}
pub(crate) fn map_runtime_profile_recharge_order_expiration_check_procedure_result(
result: RuntimeProfileRechargeOrderExpirationCheckProcedureResult,
) -> Result<RuntimeProfileRechargeOrderRecord, SpacetimeClientError> {
if !result.ok {
return Err(SpacetimeClientError::procedure_failed(result.error_message));
}
let order = result
.record
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge order snapshot"))?;
Ok(module_runtime::build_runtime_profile_recharge_order_record(
map_runtime_profile_recharge_order_snapshot(order),
))
}
pub(crate) fn map_runtime_profile_feedback_submission_procedure_result(
result: RuntimeProfileFeedbackSubmissionProcedureResult,
) -> Result<RuntimeProfileFeedbackSubmissionRecord, SpacetimeClientError> {
@@ -1046,9 +1097,47 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot(
created_at_micros: snapshot.created_at_micros,
points_delta: snapshot.points_delta,
membership_expires_at_micros: snapshot.membership_expires_at_micros,
expired_at_micros: snapshot.expired_at_micros,
expiration_checked_at_micros: snapshot.expiration_checked_at_micros,
expiration_provider_state: snapshot.expiration_provider_state,
expiration_last_error: snapshot.expiration_last_error,
}
}
pub(crate) fn map_runtime_profile_recharge_order_table_row(
row: ProfileRechargeOrder,
) -> RuntimeProfileRechargeOrderRecord {
module_runtime::build_runtime_profile_recharge_order_record(
module_runtime::RuntimeProfileRechargeOrderSnapshot {
order_id: row.order_id,
user_id: row.user_id,
product_id: row.product_id,
product_title: row.product_title,
kind: map_runtime_profile_recharge_product_kind_back(row.kind),
amount_cents: row.amount_cents,
status: map_runtime_profile_recharge_order_status_back(row.status),
payment_channel: row.payment_channel,
paid_at_micros: row
.paid_at
.map(|value| value.to_micros_since_unix_epoch()),
provider_transaction_id: row.provider_transaction_id,
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
points_delta: row.points_delta,
membership_expires_at_micros: row
.membership_expires_at
.map(|value| value.to_micros_since_unix_epoch()),
expired_at_micros: row
.expired_at
.map(|value| value.to_micros_since_unix_epoch()),
expiration_checked_at_micros: row
.expiration_checked_at
.map(|value| value.to_micros_since_unix_epoch()),
expiration_provider_state: row.expiration_provider_state,
expiration_last_error: row.expiration_last_error,
},
)
}
pub(crate) fn map_runtime_profile_recharge_order_expiration_schedule_snapshot(
snapshot: RuntimeProfileRechargeOrderExpirationScheduleSnapshot,
) -> module_runtime::RuntimeProfileRechargeOrderExpirationScheduleSnapshot {
@@ -1479,6 +1568,9 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back(
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded => {
module_runtime::RuntimeProfileRechargeOrderStatus::Refunded
}
crate::module_bindings::RuntimeProfileRechargeOrderStatus::Expired => {
module_runtime::RuntimeProfileRechargeOrderStatus::Expired
}
}
}
@@ -448,6 +448,7 @@ pub mod enqueue_external_generation_job_and_return_procedure;
pub mod ensure_analytics_date_dimension_for_date_reducer;
pub mod equip_inventory_item_input_type;
pub mod execute_custom_world_agent_action_procedure;
pub mod expire_profile_recharge_order_timer_reducer;
pub mod export_auth_store_projection_from_tables_procedure;
pub mod export_database_migration_to_file_procedure;
pub mod external_api_key_authenticate_input_type;
@@ -636,10 +637,12 @@ pub mod list_puzzle_clear_works_procedure;
pub mod list_puzzle_gallery_procedure;
pub mod list_puzzle_works_procedure;
pub mod list_square_hole_works_procedure;
pub mod list_unchecked_expired_profile_recharge_orders_procedure;
pub mod list_visual_novel_runtime_history_procedure;
pub mod list_visual_novel_works_procedure;
pub mod list_wooden_fish_works_procedure;
pub mod mark_editor_showcase_asset_refunded_and_return_procedure;
pub mod mark_profile_recharge_order_expiration_checked_procedure;
pub mod mark_profile_recharge_order_paid_and_return_procedure;
pub mod mark_puzzle_clear_level_time_up_procedure;
pub mod mark_puzzle_draft_generation_failed_procedure;
@@ -719,6 +722,8 @@ pub mod profile_played_world_table;
pub mod profile_played_world_type;
pub mod profile_recharge_order_expiration_schedule_table;
pub mod profile_recharge_order_expiration_schedule_type;
pub mod profile_recharge_order_expiration_timer_table;
pub mod profile_recharge_order_expiration_timer_type;
pub mod profile_recharge_order_table;
pub mod profile_recharge_order_type;
pub mod profile_recharge_product_config_table;
@@ -1004,6 +1009,10 @@ pub mod runtime_profile_recharge_center_procedure_result_type;
pub mod runtime_profile_recharge_center_snapshot_type;
pub mod runtime_profile_recharge_order_close_input_type;
pub mod runtime_profile_recharge_order_create_input_type;
pub mod runtime_profile_recharge_order_expiration_check_input_type;
pub mod runtime_profile_recharge_order_expiration_check_list_input_type;
pub mod runtime_profile_recharge_order_expiration_check_list_procedure_result_type;
pub mod runtime_profile_recharge_order_expiration_check_procedure_result_type;
pub mod runtime_profile_recharge_order_expiration_claim_input_type;
pub mod runtime_profile_recharge_order_expiration_claim_procedure_result_type;
pub mod runtime_profile_recharge_order_expiration_complete_input_type;
@@ -1745,6 +1754,7 @@ pub use enqueue_external_generation_job_and_return_procedure::enqueue_external_g
pub use ensure_analytics_date_dimension_for_date_reducer::ensure_analytics_date_dimension_for_date;
pub use equip_inventory_item_input_type::EquipInventoryItemInput;
pub use execute_custom_world_agent_action_procedure::execute_custom_world_agent_action;
pub use expire_profile_recharge_order_timer_reducer::expire_profile_recharge_order_timer;
pub use export_auth_store_projection_from_tables_procedure::export_auth_store_projection_from_tables;
pub use export_database_migration_to_file_procedure::export_database_migration_to_file;
pub use external_api_key_authenticate_input_type::ExternalApiKeyAuthenticateInput;
@@ -1933,10 +1943,12 @@ pub use list_puzzle_clear_works_procedure::list_puzzle_clear_works;
pub use list_puzzle_gallery_procedure::list_puzzle_gallery;
pub use list_puzzle_works_procedure::list_puzzle_works;
pub use list_square_hole_works_procedure::list_square_hole_works;
pub use list_unchecked_expired_profile_recharge_orders_procedure::list_unchecked_expired_profile_recharge_orders;
pub use list_visual_novel_runtime_history_procedure::list_visual_novel_runtime_history;
pub use list_visual_novel_works_procedure::list_visual_novel_works;
pub use list_wooden_fish_works_procedure::list_wooden_fish_works;
pub use mark_editor_showcase_asset_refunded_and_return_procedure::mark_editor_showcase_asset_refunded_and_return;
pub use mark_profile_recharge_order_expiration_checked_procedure::mark_profile_recharge_order_expiration_checked;
pub use mark_profile_recharge_order_paid_and_return_procedure::mark_profile_recharge_order_paid_and_return;
pub use mark_puzzle_clear_level_time_up_procedure::mark_puzzle_clear_level_time_up;
pub use mark_puzzle_draft_generation_failed_procedure::mark_puzzle_draft_generation_failed;
@@ -2016,6 +2028,8 @@ pub use profile_played_world_table::*;
pub use profile_played_world_type::ProfilePlayedWorld;
pub use profile_recharge_order_expiration_schedule_table::*;
pub use profile_recharge_order_expiration_schedule_type::ProfileRechargeOrderExpirationSchedule;
pub use profile_recharge_order_expiration_timer_table::*;
pub use profile_recharge_order_expiration_timer_type::ProfileRechargeOrderExpirationTimer;
pub use profile_recharge_order_table::*;
pub use profile_recharge_order_type::ProfileRechargeOrder;
pub use profile_recharge_product_config_table::*;
@@ -2301,6 +2315,10 @@ pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRec
pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot;
pub use runtime_profile_recharge_order_close_input_type::RuntimeProfileRechargeOrderCloseInput;
pub use runtime_profile_recharge_order_create_input_type::RuntimeProfileRechargeOrderCreateInput;
pub use runtime_profile_recharge_order_expiration_check_input_type::RuntimeProfileRechargeOrderExpirationCheckInput;
pub use runtime_profile_recharge_order_expiration_check_list_input_type::RuntimeProfileRechargeOrderExpirationCheckListInput;
pub use runtime_profile_recharge_order_expiration_check_list_procedure_result_type::RuntimeProfileRechargeOrderExpirationCheckListProcedureResult;
pub use runtime_profile_recharge_order_expiration_check_procedure_result_type::RuntimeProfileRechargeOrderExpirationCheckProcedureResult;
pub use runtime_profile_recharge_order_expiration_claim_input_type::RuntimeProfileRechargeOrderExpirationClaimInput;
pub use runtime_profile_recharge_order_expiration_claim_procedure_result_type::RuntimeProfileRechargeOrderExpirationClaimProcedureResult;
pub use runtime_profile_recharge_order_expiration_complete_input_type::RuntimeProfileRechargeOrderExpirationCompleteInput;
@@ -2644,6 +2662,9 @@ pub enum Reducer {
EnsureAnalyticsDateDimensionForDate {
input: AnalyticsDateDimensionEnsureInput,
},
ExpireProfileRechargeOrderTimer {
timer: ProfileRechargeOrderExpirationTimer,
},
GrantPlayerProgressionExperience {
input: PlayerProgressionGrantInput,
},
@@ -2711,6 +2732,9 @@ impl __sdk::Reducer for Reducer {
Reducer::EnsureAnalyticsDateDimensionForDate { .. } => {
"ensure_analytics_date_dimension_for_date"
}
Reducer::ExpireProfileRechargeOrderTimer { .. } => {
"expire_profile_recharge_order_timer"
}
Reducer::GrantPlayerProgressionExperience { .. } => {
"grant_player_progression_experience"
}
@@ -2792,6 +2816,11 @@ impl __sdk::Reducer for Reducer {
input,
} => __sats::bsatn::to_vec(&ensure_analytics_date_dimension_for_date_reducer::EnsureAnalyticsDateDimensionForDateArgs {
input: input.clone(),
}),
Reducer::ExpireProfileRechargeOrderTimer{
timer,
} => __sats::bsatn::to_vec(&expire_profile_recharge_order_timer_reducer::ExpireProfileRechargeOrderTimerArgs {
timer: timer.clone(),
}),
Reducer::GrantPlayerProgressionExperience{
input,
@@ -2947,6 +2976,8 @@ pub struct DbUpdate {
profile_recharge_order: __sdk::TableUpdate<ProfileRechargeOrder>,
profile_recharge_order_expiration_schedule:
__sdk::TableUpdate<ProfileRechargeOrderExpirationSchedule>,
profile_recharge_order_expiration_timer:
__sdk::TableUpdate<ProfileRechargeOrderExpirationTimer>,
profile_recharge_product_config: __sdk::TableUpdate<ProfileRechargeProductConfig>,
profile_redeem_code: __sdk::TableUpdate<ProfileRedeemCode>,
profile_redeem_code_usage: __sdk::TableUpdate<ProfileRedeemCodeUsage>,
@@ -3250,6 +3281,13 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate {
)?,
)
}
"profile_recharge_order_expiration_timer" => {
db_update.profile_recharge_order_expiration_timer.append(
profile_recharge_order_expiration_timer_table::parse_table_update(
table_update,
)?,
)
}
"profile_recharge_product_config" => {
db_update.profile_recharge_product_config.append(
profile_recharge_product_config_table::parse_table_update(table_update)?,
@@ -3821,6 +3859,12 @@ impl __sdk::DbUpdate for DbUpdate {
&self.profile_recharge_order_expiration_schedule,
)
.with_updates_by_pk(|row| &row.order_id);
diff.profile_recharge_order_expiration_timer = cache
.apply_diff_to_table::<ProfileRechargeOrderExpirationTimer>(
"profile_recharge_order_expiration_timer",
&self.profile_recharge_order_expiration_timer,
)
.with_updates_by_pk(|row| &row.scheduled_id);
diff.profile_recharge_product_config = cache
.apply_diff_to_table::<ProfileRechargeProductConfig>(
"profile_recharge_product_config",
@@ -4360,6 +4404,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_recharge_order_expiration_schedule" => db_update
.profile_recharge_order_expiration_schedule
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_recharge_order_expiration_timer" => db_update
.profile_recharge_order_expiration_timer
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
"profile_recharge_product_config" => db_update
.profile_recharge_product_config
.append(__sdk::parse_row_list_as_inserts(table_rows.rows)?),
@@ -4772,6 +4819,9 @@ impl __sdk::DbUpdate for DbUpdate {
"profile_recharge_order_expiration_schedule" => db_update
.profile_recharge_order_expiration_schedule
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_recharge_order_expiration_timer" => db_update
.profile_recharge_order_expiration_timer
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
"profile_recharge_product_config" => db_update
.profile_recharge_product_config
.append(__sdk::parse_row_list_as_deletes(table_rows.rows)?),
@@ -5041,6 +5091,8 @@ pub struct AppliedDiff<'r> {
profile_recharge_order: __sdk::TableAppliedDiff<'r, ProfileRechargeOrder>,
profile_recharge_order_expiration_schedule:
__sdk::TableAppliedDiff<'r, ProfileRechargeOrderExpirationSchedule>,
profile_recharge_order_expiration_timer:
__sdk::TableAppliedDiff<'r, ProfileRechargeOrderExpirationTimer>,
profile_recharge_product_config: __sdk::TableAppliedDiff<'r, ProfileRechargeProductConfig>,
profile_redeem_code: __sdk::TableAppliedDiff<'r, ProfileRedeemCode>,
profile_redeem_code_usage: __sdk::TableAppliedDiff<'r, ProfileRedeemCodeUsage>,
@@ -5472,6 +5524,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> {
&self.profile_recharge_order_expiration_schedule,
event,
);
callbacks.invoke_table_row_callbacks::<ProfileRechargeOrderExpirationTimer>(
"profile_recharge_order_expiration_timer",
&self.profile_recharge_order_expiration_timer,
event,
);
callbacks.invoke_table_row_callbacks::<ProfileRechargeProductConfig>(
"profile_recharge_product_config",
&self.profile_recharge_product_config,
@@ -6014,19 +6071,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle {
/// either a [`DbConnection`] or an [`EventContext`] and operate on either.
pub trait RemoteDbContext:
__sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>
{
}
impl<
Ctx: __sdk::DbContext<
Ctx: __sdk::DbContext<
DbView = RemoteTables,
Reducers = RemoteReducers,
SubscriptionBuilder = __sdk::SubscriptionBuilder<RemoteModule>,
>,
> RemoteDbContext for Ctx
> RemoteDbContext for Ctx
{
}
@@ -6493,6 +6550,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
profile_played_world_table::register_table(client_cache);
profile_recharge_order_table::register_table(client_cache);
profile_recharge_order_expiration_schedule_table::register_table(client_cache);
profile_recharge_order_expiration_timer_table::register_table(client_cache);
profile_recharge_product_config_table::register_table(client_cache);
profile_redeem_code_table::register_table(client_cache);
profile_redeem_code_usage_table::register_table(client_cache);
@@ -6628,6 +6686,7 @@ impl __sdk::SpacetimeModule for RemoteModule {
"profile_played_world",
"profile_recharge_order",
"profile_recharge_order_expiration_schedule",
"profile_recharge_order_expiration_timer",
"profile_recharge_product_config",
"profile_redeem_code",
"profile_redeem_code_usage",
@@ -47,9 +47,11 @@ pub trait accept_quest {
&self,
input: QuestRecordInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -58,9 +60,11 @@ impl accept_quest for super::RemoteReducers {
&self,
input: QuestRecordInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(AcceptQuestArgs { input }, callback)
@@ -34,10 +34,10 @@ pub trait acknowledge_external_generation_jobs_and_return {
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -47,10 +47,10 @@ impl acknowledge_external_generation_jobs_and_return for super::RemoteProcedures
input: ExternalGenerationJobAcknowledgeInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
@@ -47,9 +47,11 @@ pub trait acknowledge_quest_completion {
&self,
input: QuestCompletionAckInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()>;
}
@@ -58,9 +60,11 @@ impl acknowledge_quest_completion for super::RemoteReducers {
&self,
input: QuestCompletionAckInput,
callback: impl FnOnce(&super::ReducerEventContext, Result<Result<(), String>, __sdk::InternalError>)
+ Send
+ 'static,
callback: impl FnOnce(
&super::ReducerEventContext,
Result<Result<(), String>, __sdk::InternalError>,
) + Send
+ 'static,
) -> __sdk::Result<()> {
self.imp
.invoke_reducer_with_callback(AcknowledgeQuestCompletionArgs { input }, callback)
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_redeem_code {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_redeem_code for super::RemoteProcedures {
input: RuntimeProfileRedeemCodeAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileRedeemCodeAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileRedeemCodeAdminProcedureResult>(
@@ -31,10 +31,10 @@ pub trait admin_disable_profile_task_config {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
);
}
@@ -44,10 +44,10 @@ impl admin_disable_profile_task_config for super::RemoteProcedures {
input: RuntimeProfileTaskConfigAdminDisableInput,
__callback: impl FnOnce(
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
&super::ProcedureEventContext,
Result<RuntimeProfileTaskConfigAdminProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, RuntimeProfileTaskConfigAdminProcedureResult>(

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