From 8862887baadda7f88c53730d431677a9a6b58011 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 12:14:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=85=85=E5=80=BC=E9=80=80=E6=AC=BE=E7=AE=A1=E7=90=86=E4=B8=8E?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1V3=E9=80=80=E6=AC=BE=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增充值订单查询、通用用户详情、钱包冻结与部分退款操作 接入微信V3退款申请、回调、主动查单、交易账单对账与安全诊断 新增退款占用、权益追回、异常欠账和消费限制事务 补齐Native支付SSE自动收口及退款编号与部分退款校验 同步SpacetimeDB schema、生成绑定、测试与项目文档 --- apps/admin-web/src/api/adminApiClient.test.ts | 91 + apps/admin-web/src/api/adminApiClient.ts | 100 + apps/admin-web/src/api/adminApiTypes.ts | 173 ++ apps/admin-web/src/app/AdminApp.tsx | 7 + apps/admin-web/src/app/AdminShell.tsx | 2 + apps/admin-web/src/app/adminRoutes.test.ts | 10 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../components/AdminUserDetailDialog.test.tsx | 210 ++ .../src/components/AdminUserDetailDialog.tsx | 427 +++ .../components/AdminUserReferenceButton.tsx | 73 + .../src/components/useAdminWriteConfirm.tsx | 6 +- .../pages/AdminDatabaseTablesPage.test.tsx | 56 +- .../src/pages/AdminDatabaseTablesPage.tsx | 64 +- .../pages/AdminEditorAssetQueryPage.test.tsx | 17 + .../src/pages/AdminEditorAssetQueryPage.tsx | 32 +- .../AdminEditorShowcaseReviewPage.test.tsx | 17 + .../pages/AdminEditorShowcaseReviewPage.tsx | 32 +- .../src/pages/AdminRechargeOrderPage.test.tsx | 346 +++ .../src/pages/AdminRechargeOrderPage.tsx | 1136 +++++++ .../src/pages/AdminTrackingEventsPage.tsx | 47 +- .../src/pages/AdminWorkVisibilityPage.tsx | 14 +- apps/admin-web/src/styles/admin.css | 348 ++- apps/admin-web/src/styles/admin.test.ts | 11 + .../shared-memory/decision-log.md | 20 + docs/project-memory/shared-memory/pitfalls.md | 56 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 51 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 45 + ...【技术方案】微信虚拟支付接入-2026-05-26.md | 77 +- server-rs/Cargo.lock | 3 + server-rs/Cargo.toml | 1 + .../crates/api-server/src/admin_recharge.rs | 1291 ++++++++ server-rs/crates/api-server/src/app.rs | 8 +- server-rs/crates/api-server/src/config.rs | 9 + server-rs/crates/api-server/src/main.rs | 4 + .../crates/api-server/src/modules/admin.rs | 48 + .../profile_recharge_refund_reconciliation.rs | 953 ++++++ .../crates/api-server/src/runtime_profile.rs | 4 + server-rs/crates/api-server/src/state.rs | 4 + server-rs/crates/api-server/src/wechat/pay.rs | 967 +++++- .../crates/module-runtime/src/application.rs | 187 ++ .../crates/module-runtime/src/commands.rs | 273 ++ server-rs/crates/module-runtime/src/domain.rs | 399 +++ server-rs/crates/module-runtime/src/lib.rs | 291 ++ server-rs/crates/platform-wechat/Cargo.toml | 3 + server-rs/crates/platform-wechat/src/pay.rs | 2645 ++++++++++++++++- .../crates/shared-contracts/src/admin.rs | 210 ++ .../crates/shared-contracts/src/runtime.rs | 2 + .../crates/spacetime-client/src/mapper.rs | 10 +- .../spacetime-client/src/mapper/puzzle.rs | 3 + .../src/mapper/runtime_profile.rs | 543 ++++ .../spacetime-client/src/module_bindings.rs | 271 ++ ...get_profile_wallet_and_return_procedure.rs | 59 + ...le_recharge_orders_and_return_procedure.rs | 61 + ...manual_restriction_and_return_procedure.rs | 62 + ...nd_bill_checkpoint_and_return_procedure.rs | 67 + ...le_recharge_refund_and_return_procedure.rs | 59 + ...nd_bill_checkpoint_and_return_procedure.rs | 67 + ...fund_holds_for_reconciliation_procedure.rs | 61 + ...ge_refunds_for_reconciliation_procedure.rs | 62 + ...charge_refund_hold_and_return_procedure.rs | 62 + ...charge_refund_hold_and_return_procedure.rs | 62 + ..._recharge_order_refund_settlement_table.rs | 175 ++ ...e_recharge_order_refund_settlement_type.rs | 96 + ...e_recharge_refund_bill_checkpoint_table.rs | 174 ++ ...le_recharge_refund_bill_checkpoint_type.rs | 70 + .../profile_recharge_refund_hold_table.rs | 167 ++ .../profile_recharge_refund_hold_type.rs | 108 + ...ofile_recharge_refund_observation_table.rs | 176 ++ ...rofile_recharge_refund_observation_type.rs | 113 + .../profile_recharge_refund_table.rs | 197 ++ .../profile_recharge_refund_type.rs | 146 + ...profile_wallet_manual_restriction_table.rs | 169 ++ .../profile_wallet_manual_restriction_type.rs | 75 + ...refund_observation_and_return_procedure.rs | 62 + ...charge_refund_hold_and_return_procedure.rs | 62 + ...ime_profile_admin_wallet_get_input_type.rs | 15 + ...file_admin_wallet_procedure_result_type.rs | 19 + ...time_profile_admin_wallet_snapshot_type.rs | 28 + ...echarge_order_admin_entry_snapshot_type.rs | 25 + ...le_recharge_order_admin_list_input_type.rs | 24 + ..._order_admin_list_procedure_result_type.rs | 19 + ...e_order_refund_settlement_snapshot_type.rs | 26 + ...fund_bill_checkpoint_advance_input_type.rs | 19 + ...e_refund_bill_checkpoint_get_input_type.rs | 15 + ...d_bill_checkpoint_procedure_result_type.rs | 19 + ...ge_refund_bill_checkpoint_snapshot_type.rs | 20 + ..._profile_recharge_refund_get_input_type.rs | 15 + ...le_recharge_refund_hold_list_input_type.rs | 15 + ..._refund_hold_list_procedure_result_type.rs | 19 + ...recharge_refund_hold_prepare_input_type.rs | 19 + ...recharge_refund_hold_preview_input_type.rs | 16 + ...harge_refund_hold_procedure_result_type.rs | 25 + ...recharge_refund_hold_release_input_type.rs | 17 + ...file_recharge_refund_hold_snapshot_type.rs | 30 + ...rofile_recharge_refund_hold_status_type.rs | 20 + ...harge_refund_list_procedure_result_type.rs | 19 + ..._recharge_refund_observation_input_type.rs | 32 + ...recharge_refund_observation_source_type.rs | 22 + ...e_recharge_refund_procedure_result_type.rs | 23 + ...e_refund_reconciliation_list_input_type.rs | 15 + ...le_recharge_refund_recovery_status_type.rs | 24 + ...e_profile_recharge_refund_snapshot_type.rs | 40 + ...ime_profile_recharge_refund_status_type.rs | 22 + ..._profile_wallet_ledger_source_type_type.rs | 2 + ...wallet_manual_restriction_snapshot_type.rs | 21 + ...et_manual_restriction_upsert_input_type.rs | 18 + .../crates/spacetime-client/src/runtime.rs | 375 +++ .../crates/spacetime-module/src/migration.rs | 6 + .../spacetime-module/src/runtime/profile.rs | 2317 ++++++++++++++- .../usePlatformProfileCenterController.ts | 79 +- .../RpgEntryHomeView.recharge.test.tsx | 107 +- 111 files changed, 17388 insertions(+), 150 deletions(-) create mode 100644 apps/admin-web/src/api/adminApiClient.test.ts create mode 100644 apps/admin-web/src/components/AdminUserDetailDialog.test.tsx create mode 100644 apps/admin-web/src/components/AdminUserDetailDialog.tsx create mode 100644 apps/admin-web/src/components/AdminUserReferenceButton.tsx create mode 100644 apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminRechargeOrderPage.tsx create mode 100644 server-rs/crates/api-server/src/admin_recharge.rs create mode 100644 server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_refund_settlement_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_advance_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_prepare_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_preview_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_release_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_source_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_reconciliation_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_recovery_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_status_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_upsert_input_type.rs diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts new file mode 100644 index 000000000..9ec75345e --- /dev/null +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -0,0 +1,91 @@ +import {afterEach, expect, test, vi} from 'vitest'; + +import { + executeAdminRechargeRefund, + getAdminUserDetail, + listAdminRechargeOrders, +} from './adminApiClient'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +test('充值订单查询按后台契约序列化筛选参数', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({entries: []}), { + status: 200, + headers: {'content-type': 'application/json'}, + }), + ); + vi.stubGlobal('fetch', fetchMock); + + await listAdminRechargeOrders('token-1', { + orderId: 'order 1', + userId: 'user-1', + providerTransactionId: 'wx-1', + paymentChannel: 'wechat_native', + status: 'paid', + createdAfter: '2026-07-01T00:00:00.000Z', + createdBefore: '2026-07-13T23:59:00.000Z', + limit: 50, + }); + + const requestUrl = String(fetchMock.mock.calls[0]?.[0]); + const parsed = new URL(requestUrl, 'http://admin.local'); + expect(parsed.pathname).toBe('/admin/api/profile/recharge-orders'); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ + orderId: 'order 1', + providerTransactionId: 'wx-1', + userId: 'user-1', + paymentChannel: 'wechat_native', + status: 'paid', + createdAfter: '2026-07-01T00:00:00.000Z', + createdBefore: '2026-07-13T23:59:00.000Z', + limit: '50', + }); +}); + +test('用户详情只发送实际提供的用户定位字段', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({userId: 'user-1'}), {status: 200}), + ); + vi.stubGlobal('fetch', fetchMock); + + await getAdminUserDetail('token-1', {publicUserCode: 'TN1001'}); + + const requestUrl = String(fetchMock.mock.calls[0]?.[0]); + const parsed = new URL(requestUrl, 'http://admin.local'); + expect(parsed.pathname).toBe('/admin/api/profile/users/detail'); + expect(Object.fromEntries(parsed.searchParams)).toEqual({ + publicUserCode: 'TN1001', + }); +}); + +test('退款执行使用独立 execute 管理员路由', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), + ); + vi.stubGlobal('fetch', fetchMock); + + await executeAdminRechargeRefund('token-1', { + orderId: 'order-1', + refundAmountCents: 300, + requestId: 'request-1', + reason: '用户申请', + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + '/admin/api/profile/recharge-refunds/execute', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + orderId: 'order-1', + refundAmountCents: 300, + requestId: 'request-1', + reason: '用户申请', + }), + }), + ); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 5d17b64d6..9f50ff9c0 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -26,6 +26,13 @@ import type { AdminLoginResponse, AdminMeResponse, AdminOverviewResponse, + AdminRechargeOrderListQuery, + AdminRechargeOrderListResponse, + AdminRechargeRefundActionResponse, + AdminRechargeRefundExecuteRequest, + AdminRechargeRefundPreviewRequest, + AdminRechargeRefundPreviewResponse, + AdminRechargeRefundRegisterRequest, AdminTrackingEventListQuery, AdminTrackingEventKeyListResponse, AdminTrackingEventListResponse, @@ -41,6 +48,10 @@ import type { AdminUpsertProfileWalletConfigRequest, AdminUpsertPublicWorkInteractionConfigRequest, AdminWorkVisibilityListResponse, + AdminUserDetailQuery, + AdminUserDetailResponse, + AdminWalletRestrictionRequest, + AdminWalletRestrictionResponse, ApiErrorEnvelope, ApiMeta, ApiSuccessEnvelope, @@ -580,6 +591,66 @@ export function upsertProfileRechargeProduct( ); } +export function listAdminRechargeOrders( + token: string, + query: AdminRechargeOrderListQuery = {}, +) { + return request( + `/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`, + {token}, + ); +} + +export function getAdminUserDetail( + token: string, + query: AdminUserDetailQuery, +) { + return request( + `/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`, + {token}, + ); +} + +export function previewAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundPreviewRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/preview', + {method: 'POST', token, body: payload}, + ); +} + +export function executeAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundExecuteRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/execute', + {method: 'POST', token, body: payload}, + ); +} + +export function registerAdminRechargeRefund( + token: string, + payload: AdminRechargeRefundRegisterRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/register', + {method: 'POST', token, body: payload}, + ); +} + +export function updateAdminWalletRestriction( + token: string, + payload: AdminWalletRestrictionRequest, +) { + return request( + '/admin/api/profile/wallet-restriction', + {method: 'POST', token, body: payload}, + ); +} + function normalizeBaseUrl(value: string) { return value.trim().replace(/\/+$/, ''); } @@ -747,6 +818,35 @@ function buildQueryString(query: AdminTrackingEventListQuery) { return queryString ? `?${queryString}` : ''; } +function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'orderId', query.orderId); + appendQueryParam( + params, + 'providerTransactionId', + query.providerTransactionId, + ); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'publicUserCode', query.publicUserCode); + appendQueryParam(params, 'paymentChannel', query.paymentChannel); + appendQueryParam(params, 'status', query.status); + appendQueryParam(params, 'createdAfter', query.createdAfter); + appendQueryParam(params, 'createdBefore', query.createdBefore); + if (typeof query.limit === 'number' && Number.isFinite(query.limit)) { + params.set('limit', String(query.limit)); + } + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + +function buildAdminUserDetailQuery(query: AdminUserDetailQuery) { + const params = new URLSearchParams(); + appendQueryParam(params, 'userId', query.userId); + appendQueryParam(params, 'publicUserCode', query.publicUserCode); + const queryString = params.toString(); + return queryString ? `?${queryString}` : ''; +} + function buildDashboardQuery(query: AdminDashboardQuery) { const params = new URLSearchParams(); appendQueryParam(params, 'granularity', query.granularity); diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 0cc109130..b26fc064a 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -738,3 +738,176 @@ export interface AdminTrackingEventKeyPayload { export interface AdminTrackingEventKeyListResponse { eventKeys: AdminTrackingEventKeyPayload[]; } + +export interface AdminRechargeOrderListQuery { + orderId?: string; + providerTransactionId?: string; + userId?: string; + publicUserCode?: string; + paymentChannel?: string; + status?: string; + createdAfter?: string; + createdBefore?: string; + limit?: number; +} + +export interface AdminUserSummaryPayload { + userId: string; + publicUserCode: string; + displayName: string; + avatarUrl?: string | null; +} + +export interface AdminWalletManualRestrictionPayload { + frozen: boolean; + reason: string; + createdByAdminUserId: string; + createdAtMicros: number; + updatedByAdminUserId: string; + updatedAtMicros: number; +} + +export interface AdminProfileWalletPayload { + userId: string; + totalBalance: number; + spendableBalance: number; + dailyFreePoints: number; + membershipLimitedPoints: number; + permanentPoints: number; + heldPoints: number; + refundDebtPoints: number; + manualFrozen: boolean; + refundDebtFrozen: boolean; + walletFrozen: boolean; + manualRestriction?: AdminWalletManualRestrictionPayload | null; +} + +export interface AdminRechargeRefundPayload { + outRefundNo: string; + providerRefundId: string; + providerStatus: string; + refundCents: number; + payerRefundCents: number; + successAtMicros?: number | null; + firstObservedAtMicros: number; + updatedAtMicros: number; + observationSource: string; + targetRecoveryPoints: number; + recoveredPoints: number; + unrecoveredPoints: number; + recoveryStatus: string; + lastErrorCode?: string | null; +} + +export interface AdminRechargeRefundHoldPayload { + outRefundNo: string; + refundCents: number; + heldPoints: number; + status: string; + adminUserId: string; + reason: string; + createdAtMicros: number; + updatedAtMicros: number; +} + +export interface AdminRechargeOrderEntryPayload { + orderId: string; + userId: string; + user?: AdminUserSummaryPayload | null; + productId: string; + productTitle: string; + productKind: string; + amountCents: number; + status: string; + paymentChannel: string; + paidAtMicros?: number | null; + providerTransactionId?: string | null; + createdAtMicros: number; + pointsDelta: number; + cumulativeSuccessRefundCents: number; + targetRecoveryPoints: number; + recoveredPoints: number; + unrecoveredPoints: number; + recoveryStatus?: string | null; + wallet: AdminProfileWalletPayload; + refunds: AdminRechargeRefundPayload[]; + activeHold?: AdminRechargeRefundHoldPayload | null; + remainingRefundableCents: number; + refundEligible: boolean; + refundBlockReasonCode?: string | null; +} + +export interface AdminRechargeOrderListResponse { + entries: AdminRechargeOrderEntryPayload[]; +} + +export interface AdminUserDetailQuery { + userId?: string; + publicUserCode?: string; +} + +export interface AdminUserDetailResponse { + userId: string; + publicUserCode: string; + displayName: string; + avatarUrl?: string | null; + phoneNumberMasked?: string | null; + loginMethod: string; + bindingStatus: string; + phoneBound: boolean; + wechatBound: boolean; + wallet: AdminProfileWalletPayload; + rechargeOrders: AdminRechargeOrderEntryPayload[]; +} + +export interface AdminRechargeRefundPreviewRequest { + orderId: string; + refundAmountCents: number; +} + +export interface AdminRechargeRefundExecuteRequest { + orderId: string; + refundAmountCents: number; + requestId: string; + reason?: string | null; +} + +export interface AdminRechargeRefundRegisterRequest { + outRefundNo: string; +} + +export interface AdminWalletRestrictionRequest { + userId: string; + frozen: boolean; + reason: string; +} + +export interface AdminWechatPaymentCheckPayload { + verified: boolean; + tradeState: string; + transactionId?: string | null; + amountTotalCents?: number | null; + knownRefundsRefreshed: number; +} + +export interface AdminRechargeRefundPreviewResponse { + order: AdminRechargeOrderEntryPayload; + paymentCheck: AdminWechatPaymentCheckPayload; + refundAmountCents: number; + incrementalRecoveryPoints: number; + remainingRefundableCents: number; + canSubmit: boolean; + blockReasonCode?: string | null; +} + +export interface AdminRechargeRefundActionResponse { + outRefundNo: string; + providerStatus: string; + resultCode: string; + providerStatusUnknown: boolean; + order: AdminRechargeOrderEntryPayload; +} + +export interface AdminWalletRestrictionResponse { + wallet: AdminProfileWalletPayload; +} diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index 68c9cf799..9a596528c 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -30,6 +30,7 @@ import {AdminGrayReleaseConfigPage} from '../pages/AdminGrayReleaseConfigPage'; import {AdminOverviewPage} from '../pages/AdminOverviewPage'; import {AdminProfileWalletConfigPage} from '../pages/AdminProfileWalletConfigPage'; import {AdminRechargeProductPage} from '../pages/AdminRechargeProductPage'; +import {AdminRechargeOrderPage} from '../pages/AdminRechargeOrderPage'; import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage'; import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage'; import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage'; @@ -248,6 +249,12 @@ export function AdminApp() { onResultChange={setRechargeProductResult} /> ) : null} + {routeId === 'recharge-orders' ? ( + + ) : null} {routeId === 'editor-generation-pricing' ? ( { expect(resolveAdminRoute('#editor-showcase')).toBe('editor-showcase'); expect(routeHash('editor-showcase')).toBe('#editor-showcase'); }); + +test('后台充值管理路由可通过导航和 hash 访问', () => { + expect(adminRoutes).toContainEqual({ + id: 'recharge-orders', + label: '充值管理', + hash: '#recharge-orders', + }); + expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders'); + expect(routeHash('recharge-orders')).toBe('#recharge-orders'); +}); diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 569e5309d..550065503 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -11,6 +11,7 @@ export type AdminRouteId = | 'profile-wallet' | 'tasks' | 'recharge-products' + | 'recharge-orders' | 'editor-generation-pricing' | 'editor-showcase' | 'editor-assets' @@ -37,6 +38,7 @@ export const adminRoutes: AdminRouteDefinition[] = [ {id: 'profile-wallet', label: '账号配置', hash: '#profile-wallet'}, {id: 'tasks', label: '任务配置', hash: '#tasks'}, {id: 'recharge-products', label: '充值商品', hash: '#recharge-products'}, + {id: 'recharge-orders', label: '充值管理', hash: '#recharge-orders'}, {id: 'editor-generation-pricing', label: '模型定价', hash: '#editor-generation-pricing'}, {id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase'}, {id: 'editor-assets', label: '素材查询', hash: '#editor-assets'}, diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx new file mode 100644 index 000000000..719cefca1 --- /dev/null +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -0,0 +1,210 @@ +/* @vitest-environment jsdom */ + +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {beforeEach, expect, test, vi} from 'vitest'; + +import { + getAdminUserDetail, + updateAdminWalletRestriction, +} from '../api/adminApiClient'; +import type { + AdminProfileWalletPayload, + AdminUserDetailResponse, +} from '../api/adminApiTypes'; +import {AdminUserReferenceButton} from './AdminUserReferenceButton'; + +vi.mock('../api/adminApiClient', () => ({ + formatAdminApiError: vi.fn((error: unknown) => + error instanceof Error ? error.message : '请求失败', + ), + getAdminUserDetail: vi.fn(), + isAdminApiError: vi.fn(() => false), + updateAdminWalletRestriction: vi.fn(), +})); + +const wallet: AdminProfileWalletPayload = { + userId: 'user-1', + totalBalance: 96, + spendableBalance: 40, + dailyFreePoints: 6, + membershipLimitedPoints: 20, + permanentPoints: 70, + heldPoints: 5, + refundDebtPoints: 25, + manualFrozen: false, + refundDebtFrozen: true, + walletFrozen: true, + manualRestriction: null, +}; + +const detail: AdminUserDetailResponse = { + userId: 'user-1', + publicUserCode: 'TN1001', + displayName: '陶泥用户', + avatarUrl: 'https://example.com/avatar.png', + phoneNumberMasked: '138****5678', + loginMethod: 'phone', + bindingStatus: 'bound', + phoneBound: true, + wechatBound: true, + wallet, + rechargeOrders: [ + { + orderId: 'order-1', + userId: 'user-1', + user: null, + productId: 'points_60', + productTitle: '60泥点', + productKind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_native', + paidAtMicros: 1_720_000_000_000_000, + providerTransactionId: 'wx-1', + createdAtMicros: 1_720_000_000_000_000, + pointsDelta: 60, + cumulativeSuccessRefundCents: 300, + targetRecoveryPoints: 30, + recoveredPoints: 5, + unrecoveredPoints: 25, + recoveryStatus: 'shortfall', + wallet, + refunds: [], + activeHold: null, + remainingRefundableCents: 300, + refundEligible: false, + refundBlockReasonCode: 'refund_reconciliation_pending', + }, + ], +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getAdminUserDetail).mockResolvedValue(detail); + vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet}); +}); + +test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => { + const user = userEvent.setup(); + const parentClick = vi.fn(); + render( +
+ +
, + ); + + const trigger = screen.getByRole('button', {name: '查看用户信息'}); + await user.click(trigger); + expect(parentClick).not.toHaveBeenCalled(); + expect(await screen.findByRole('dialog', {name: '用户详情'})).toBeTruthy(); + expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', { + userId: 'user-1', + publicUserCode: undefined, + }); + expect(screen.getByText('陶泥用户')).toBeTruthy(); + expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1); + expect(screen.getByText('138****5678')).toBeTruthy(); + expect(screen.getByText('退款欠账限制')).toBeTruthy(); + expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy(); + expect(screen.getByText('order-1')).toBeTruthy(); + + await user.keyboard('{Escape}'); + await waitFor(() => expect(screen.queryByRole('dialog', {name: '用户详情'})).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(trigger)); +}); + +test('只有陶泥号时按 publicUserCode 查询用户', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', {name: '查看用户信息'})); + await screen.findByText('陶泥用户'); + expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', { + userId: undefined, + publicUserCode: 'TN1001', + }); +}); + +test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => { + const user = userEvent.setup(); + const manuallyFrozenWallet: AdminProfileWalletPayload = { + ...wallet, + manualFrozen: true, + walletFrozen: true, + manualRestriction: { + frozen: true, + reason: '风险核查', + createdByAdminUserId: 'admin:root', + createdAtMicros: 1_720_000_000_000_000, + updatedByAdminUserId: 'admin:root', + updatedAtMicros: 1_720_000_000_000_000, + }, + }; + vi.mocked(updateAdminWalletRestriction) + .mockResolvedValueOnce({wallet: manuallyFrozenWallet}) + .mockResolvedValueOnce({wallet: {...wallet, manualFrozen: false}}); + render( + , + ); + await user.click(screen.getByRole('button', {name: '查看用户信息'})); + await screen.findByText('陶泥用户'); + + await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '异常登录'); + await user.click(screen.getByRole('button', {name: '人工冻结钱包'})); + await user.click(screen.getByRole('button', {name: '确认'})); + await waitFor(() => { + expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(1, 'admin-token', { + userId: 'user-1', + frozen: true, + reason: '异常登录', + }); + }); + + expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy(); + await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成'); + await user.click(screen.getByRole('button', {name: '解除人工冻结'})); + await user.click(screen.getByRole('button', {name: '确认'})); + await waitFor(() => { + expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(2, 'admin-token', { + userId: 'user-1', + frozen: false, + reason: '核查完成', + }); + }); + expect(screen.getByText('退款欠账限制')).toBeTruthy(); +}); + +test('用户详情读取失败后可以重试', async () => { + const user = userEvent.setup(); + vi.mocked(getAdminUserDetail) + .mockRejectedValueOnce(new Error('读取失败')) + .mockResolvedValueOnce(detail); + render( + , + ); + + await user.click(screen.getByRole('button', {name: '查看用户信息'})); + expect(await screen.findByText('读取失败')).toBeTruthy(); + await user.click(screen.getByRole('button', {name: '重试'})); + expect(await screen.findByText('陶泥用户')).toBeTruthy(); + expect(getAdminUserDetail).toHaveBeenCalledTimes(2); +}); diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx new file mode 100644 index 000000000..80e513349 --- /dev/null +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -0,0 +1,427 @@ +import {RefreshCcw, ShieldAlert, UserRound, X} from 'lucide-react'; +import {useEffect, useRef, useState} from 'react'; +import {createPortal} from 'react-dom'; + +import { + formatAdminApiError, + getAdminUserDetail, + isAdminApiError, + updateAdminWalletRestriction, +} from '../api/adminApiClient'; +import type { + AdminProfileWalletPayload, + AdminUserDetailResponse, +} from '../api/adminApiTypes'; +import {useAdminWriteConfirm} from './useAdminWriteConfirm'; + +interface AdminUserDetailDialogProps { + token: string; + userId?: string | null; + publicUserCode?: string | null; + onClose: () => void; + onUnauthorized: (message?: string) => void; +} + +export function AdminUserDetailDialog({ + token, + userId, + publicUserCode, + onClose, + onUnauthorized, +}: AdminUserDetailDialogProps) { + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(''); + const [restrictionReason, setRestrictionReason] = useState(''); + const [isSavingRestriction, setIsSavingRestriction] = useState(false); + const closeButtonRef = useRef(null); + const requestVersionRef = useRef(0); + const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm(); + + useEffect(() => { + void loadDetail(); + return () => { + requestVersionRef.current += 1; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [token, userId, publicUserCode]); + + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) { + event.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [isConfirming, isSavingRestriction, onClose]); + + async function loadDetail() { + const requestVersion = requestVersionRef.current + 1; + requestVersionRef.current = requestVersion; + setIsLoading(true); + setErrorMessage(''); + try { + const response = await getAdminUserDetail(token, { + userId: userId?.trim() || undefined, + publicUserCode: userId?.trim() + ? undefined + : publicUserCode?.trim() || undefined, + }); + if (requestVersionRef.current === requestVersion) { + setDetail(response); + } + } catch (error: unknown) { + if (requestVersionRef.current !== requestVersion) { + return; + } + if (isAdminApiError(error) && error.status === 401) { + onUnauthorized('登录状态已失效'); + return; + } + setErrorMessage(formatAdminApiError(error)); + } finally { + if (requestVersionRef.current === requestVersion) { + setIsLoading(false); + } + } + } + + async function handleRestrictionChange() { + if (!detail || isSavingRestriction) { + return; + } + const reason = restrictionReason.trim(); + if (!reason) { + setErrorMessage('请填写人工冻结操作原因'); + return; + } + const nextFrozen = !detail.wallet.manualFrozen; + const action = nextFrozen ? '人工冻结钱包' : '解除人工冻结'; + const confirmed = await confirmWrite({ + action, + target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`, + }); + if (!confirmed) { + return; + } + + setIsSavingRestriction(true); + setErrorMessage(''); + try { + const response = await updateAdminWalletRestriction(token, { + userId: detail.userId, + frozen: nextFrozen, + reason, + }); + setDetail((current) => + current ? {...current, wallet: response.wallet} : current, + ); + setRestrictionReason(''); + } catch (error: unknown) { + if (isAdminApiError(error) && error.status === 401) { + onUnauthorized('登录状态已失效'); + } else { + setErrorMessage(formatAdminApiError(error)); + } + } finally { + setIsSavingRestriction(false); + } + } + + if (typeof document === 'undefined') { + return null; + } + + return createPortal( +
{ + if ( + event.target === event.currentTarget && + !isSavingRestriction && + !isConfirming + ) { + onClose(); + } + }} + > +
+
+
+

用户详情

+ {detail?.publicUserCode || publicUserCode || userId || '-'} +
+
+ + +
+
+ + {isLoading ? ( +
+
+ 正在读取用户信息 +
+ ) : errorMessage && !detail ? ( +
+
+ {errorMessage} +
+ +
+ ) : detail ? ( + <> + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + +
+
+

人工冻结

+ + {detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'} + +
+ {detail.wallet.manualRestriction ? ( +
+ {detail.wallet.manualRestriction.reason || '未填写原因'} + + {formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '} + {detail.wallet.manualRestriction.updatedByAdminUserId} + +
+ ) : null} + {detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? ( +
+
+ ) : null} +
+ + +
+
+ +
+
+

充值订单

+ {detail.rechargeOrders.length} 笔 +
+ {detail.rechargeOrders.length ? ( +
+ + + + + + + + + + + + {detail.rechargeOrders.map((order) => ( + + + + + + + + ))} + +
订单商品实付退款状态
+ {order.orderId} + {formatMicros(order.createdAtMicros)} + + {order.productTitle || order.productId} + 发放 {order.pointsDelta} 泥点 + {formatMoney(order.amountCents)} + {formatMoney(order.cumulativeSuccessRefundCents)} + 欠账 {order.unrecoveredPoints} 泥点 + {formatOrderStatus(order.status)}
+
+ ) : ( +
暂无充值订单
+ )} +
+ + ) : null} +
+ {confirmDialog} +
, + document.body, + ); +} + +function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) { + return ( +
+
+ {detail.avatarUrl ? ( + {`${detail.displayName + ) : ( +
+
+ {detail.displayName || '未设置昵称'} + {detail.publicUserCode || '未分配陶泥号'} +
+
+
+
内部 ID
+
{detail.userId}
+
+
+
脱敏手机号
+
{detail.phoneNumberMasked || '未绑定'}
+
+
+
登录方式
+
{detail.loginMethod || '-'}
+
+
+
绑定状态
+
+ {detail.bindingStatus || '-'} / 手机{detail.phoneBound ? '已绑定' : '未绑定'} / 微信 + {detail.wechatBound ? '已绑定' : '未绑定'} +
+
+
+
+ ); +} + +function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) { + const metrics = [ + ['总余额', wallet.totalBalance], + ['可消费', wallet.spendableBalance], + ['永久泥点', wallet.permanentPoints], + ['每日免费', wallet.dailyFreePoints], + ['会员限时', wallet.membershipLimitedPoints], + ['退款占用', wallet.heldPoints], + ['退款欠账', wallet.refundDebtPoints], + ] as const; + return ( +
+
+

钱包

+
+ {wallet.manualFrozen ? 人工冻结 : null} + {wallet.refundDebtFrozen ? ( + 退款欠账限制 + ) : null} + {!wallet.walletFrozen ? 正常 : null} +
+
+
+ {metrics.map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+ ); +} + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +function formatMicros(value: number) { + if (!Number.isFinite(value) || value <= 0) { + return '-'; + } + return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', { + hour12: false, + }); +} + +function formatOrderStatus(status: string) { + const labels: Record = { + pending: '待支付', + paid: '已支付', + refunded: '已退款', + closed: '已关闭', + }; + return labels[status.toLowerCase()] ?? status; +} diff --git a/apps/admin-web/src/components/AdminUserReferenceButton.tsx b/apps/admin-web/src/components/AdminUserReferenceButton.tsx new file mode 100644 index 000000000..5a93fdfbe --- /dev/null +++ b/apps/admin-web/src/components/AdminUserReferenceButton.tsx @@ -0,0 +1,73 @@ +import {UserRoundSearch} from 'lucide-react'; +import {MouseEvent, useRef, useState} from 'react'; + +import {AdminUserDetailDialog} from './AdminUserDetailDialog'; + +interface AdminUserReferenceButtonProps { + token: string; + userId?: string | null; + publicUserCode?: string | null; + onUnauthorized: (message?: string) => void; +} + +export function AdminUserReferenceButton({ + token, + userId, + publicUserCode, + onUnauthorized, +}: AdminUserReferenceButtonProps) { + const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const normalizedUserId = normalizeUserReference(userId); + const normalizedPublicUserCode = normalizeUserReference(publicUserCode); + const lookup = normalizedUserId + ? {userId: normalizedUserId} + : normalizedPublicUserCode + ? {publicUserCode: normalizedPublicUserCode} + : null; + + if (!lookup) { + return null; + } + + function openDialog(event: MouseEvent) { + event.stopPropagation(); + setOpen(true); + } + + function closeDialog() { + setOpen(false); + window.requestAnimationFrame(() => triggerRef.current?.focus()); + } + + return ( + <> + + {open ? ( + + ) : null} + + ); +} + +function normalizeUserReference(value?: string | null) { + const normalized = value?.trim() ?? ''; + if (!normalized || normalized.toLowerCase().startsWith('admin:')) { + return ''; + } + return normalized; +} diff --git a/apps/admin-web/src/components/useAdminWriteConfirm.tsx b/apps/admin-web/src/components/useAdminWriteConfirm.tsx index 6f62abfe4..3aa01b6f0 100644 --- a/apps/admin-web/src/components/useAdminWriteConfirm.tsx +++ b/apps/admin-web/src/components/useAdminWriteConfirm.tsx @@ -101,5 +101,9 @@ export function useAdminWriteConfirm() { ) : null; - return {confirmWrite, confirmDialog}; + return { + confirmWrite, + confirmDialog, + isConfirming: pendingConfirm !== null, + }; } diff --git a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx index f6ef68b2e..d6bf2e71f 100644 --- a/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDatabaseTablesPage.test.tsx @@ -8,7 +8,10 @@ import { getAdminDatabaseTableRows, getAdminDatabaseTables, } from '../api/adminApiClient'; -import { AdminDatabaseTablesPage } from './AdminDatabaseTablesPage'; +import { + AdminDatabaseTablesPage, + resolveAdminDatabaseUserReference, +} from './AdminDatabaseTablesPage'; vi.mock('../api/adminApiClient', () => ({ formatAdminApiError: vi.fn((error: unknown) => @@ -19,6 +22,21 @@ vi.mock('../api/adminApiClient', () => ({ isAdminApiError: vi.fn(() => false), })); +vi.mock('../components/AdminUserReferenceButton', () => ({ + AdminUserReferenceButton: ({ userId, publicUserCode }: { + userId?: string; + publicUserCode?: string; + }) => ( +
- {authorDisplayName(entry)} - {entry.authorPublicUserCode?.trim() || '-'} +
+
+ {authorDisplayName(entry)} + {entry.authorPublicUserCode?.trim() || '-'} +
+ +
{entry.ownerUserId} diff --git a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx index 67bf65873..9ad15e18b 100644 --- a/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorShowcaseReviewPage.test.tsx @@ -31,6 +31,20 @@ vi.mock('../api/adminApiClient', () => ({ upsertAdminEditorShowcaseCampaign: vi.fn(), })); +vi.mock('../components/AdminUserReferenceButton', () => ({ + AdminUserReferenceButton: ({ userId, publicUserCode }: { + userId?: string; + publicUserCode?: string | null; + }) => ( + + + + + + {visibleDebtOrder ? ( + + ) : lastAction ? ( + + ) : null} + {listError ? ( +
+ {listError} +
+ ) : null} + +
+
+ setFilters({ ...filters, orderId: value })} + /> + + setFilters({ ...filters, providerTransactionId: value }) + } + /> + + setFilters({ + ...filters, + userId: value, + publicUserCode: value.trim() ? '' : filters.publicUserCode, + }) + } + /> + + setFilters({ + ...filters, + publicUserCode: value, + userId: value.trim() ? '' : filters.userId, + }) + } + /> + + + + + +
+ + +
+
+
+ +
+
+

充值订单

+ {orders.length} 条 +
+ {isLoading && orders.length === 0 ? ( +
正在读取充值订单
+ ) : orders.length === 0 ? ( +
暂无匹配订单
+ ) : ( +
+ + + + + + + + + + + + + + + {orders.map((order) => ( + + ))} + +
用户订单支付金额 / 泥点退款与追回钱包状态操作
+
+ )} +
+ + {refundOrder ? ( +
{ + if (event.target === event.currentTarget) { + closeRefund(); + } + }} + > +
+
+
+

退款处理

+ {refundOrder.orderId} +
+ +
+ +
+ + + + + + +
+ +
+ + +
+ +
+ + +
+ + {providerStatusUnknown ? ( +
+
+ ) : null} + {refundError && !providerStatusUnknown ? ( +
+ {refundError} +
+ ) : null} + + {preview ? ( +
+
+

退款预检

+ {preview.canSubmit ? '可以提交' : '已阻止'} +
+
+
+
微信账单核验
+
+ {preview.paymentCheck.verified ? '已核验' : '未核验'} /{' '} + {preview.paymentCheck.tradeState || '-'} +
+
+
+
本次应追回
+
{preview.incrementalRecoveryPoints} 泥点
+
+
+
刷新退款单
+
{preview.paymentCheck.knownRefundsRefreshed} 笔
+
+
+
预检后剩余可退
+
{formatMoney(preview.remainingRefundableCents)}
+
+
+
+ ) : null} + +
+ + +
+
+
+ ) : null} + + {registerOpen ? ( +
{ + if (event.target === event.currentTarget && !isRegistering) { + setRegisterOpen(false); + } + }} + > +
+
+
+

登记商户平台退款

+ 填写退款详情中的 out_refund_no +
+ +
+ + {registerError ? ( +
+ {registerError} +
+ ) : null} +
+ + +
+
+
+ ) : null} + + {confirmDialog} + + ); +} + +function FilterInput({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + + ); +} + +function RechargeOrderRow({ + order, + token, + onRefund, + onUnauthorized, +}: { + order: AdminRechargeOrderEntryPayload; + token: string; + onRefund: (order: AdminRechargeOrderEntryPayload) => void; + onUnauthorized: (message?: string) => void; +}) { + const userName = order.user?.displayName || '未读取用户资料'; + return ( + + +
+
+ {userName} + {order.user?.publicUserCode || order.userId} +
+ +
+ + + {order.orderId} + {formatMicros(order.createdAtMicros)} + {order.productTitle || order.productId} + + + {formatPaymentChannel(order.paymentChannel)} + {order.providerTransactionId || '暂无微信交易单号'} + + {order.paidAtMicros ? formatMicros(order.paidAtMicros) : '-'} + + + + {formatMoney(order.amountCents)} + 发放 {order.pointsDelta} 泥点 + + + 累计 {formatMoney(order.cumulativeSuccessRefundCents)} + + 应追回 {order.targetRecoveryPoints} / 已追回 {order.recoveredPoints} + + 欠账 {order.unrecoveredPoints} 泥点 + {order.activeHold ? ( + 占用 {order.activeHold.heldPoints} 泥点 + ) : null} + + + 可消费 {order.wallet.spendableBalance} + + 永久 {order.wallet.permanentPoints} / 占用 {order.wallet.heldPoints} + +
+ {order.wallet.manualFrozen ? ( + 人工冻结 + ) : null} + {order.wallet.refundDebtFrozen ? ( + 退款欠账限制 + ) : null} +
+ + + {formatOrderStatus(order.status)} + {formatRecoveryStatus(order.recoveryStatus)} + + + + + + ); +} + +function RefundActionBanner({ + action, +}: { + action: AdminRechargeRefundActionResponse; +}) { + if ( + action.providerStatusUnknown || + ['manual_review', 'abnormal', 'closed'].includes(action.resultCode) + ) { + return ( +
+
+ ); + } + return ( +
+
+ 退款处理已受理 + + {action.outRefundNo} / {action.providerStatus} / {action.resultCode} + +
+
+ ); +} + +function RefundDebtBanner({ + order, +}: { + order: AdminRechargeOrderEntryPayload; +}) { + return ( +
+
+ ); +} + +function Metric({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} + +export function buildRechargeOrderQuery( + filters: RechargeFilters, +): AdminRechargeOrderListQuery { + const limit = Number.parseInt(filters.limit, 10); + return { + orderId: filters.orderId, + providerTransactionId: filters.providerTransactionId, + userId: filters.userId, + publicUserCode: filters.publicUserCode, + paymentChannel: filters.paymentChannel, + status: filters.status, + createdAfter: datetimeLocalToRfc3339(filters.createdAfter), + createdBefore: datetimeLocalToRfc3339(filters.createdBefore), + limit: Number.isFinite(limit) ? Math.min(500, Math.max(1, limit)) : 100, + }; +} + +function parseYuanToCents(value: string) { + const normalized = value.trim(); + const match = /^(\d+)(?:\.(\d{1,2}))?$/u.exec(normalized); + if (!match) { + return null; + } + const yuan = Number.parseInt(match[1] ?? '0', 10); + const decimal = (match[2] ?? '').padEnd(2, '0'); + const cents = yuan * 100 + Number.parseInt(decimal || '0', 10); + return Number.isSafeInteger(cents) && cents > 0 ? cents : null; +} + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +function formatCentsInput(cents: number) { + return (cents / 100).toFixed(2); +} + +function formatMicros(value: number) { + if (!Number.isFinite(value) || value <= 0) { + return '-'; + } + return new Date(Math.floor(value / 1000)).toLocaleString('zh-CN', { + hour12: false, + }); +} + +function formatOrderStatus(status: string) { + const labels: Record = { + pending: '待支付', + paid: '已支付', + refunded: '已退款', + closed: '已关闭', + failed: '支付失败', + expired: '已过期', + }; + return labels[status.toLowerCase()] ?? status; +} + +function formatPaymentChannel(channel: string) { + const labels: Record = { + wechat_native: '微信 Native', + wechat_jsapi: '微信 JSAPI', + wechat_h5: '微信 H5', + wechat_mini_program: '微信小程序', + }; + return labels[channel.toLowerCase()] ?? channel; +} + +function formatRecoveryStatus(status?: string | null) { + if (!status) { + return '无退款追回'; + } + const labels: Record = { + pending: '等待追回', + applied: '已完成追回', + shortfall: '退款异常欠账', + manual_review: '人工处理中', + not_applicable: '无需追回', + }; + return labels[status.toLowerCase()] ?? status; +} + +function formatRefundBlockReason(code?: string | null) { + if (!code) { + return '当前订单不可退款'; + } + const labels: Record = { + order_not_paid: '订单尚未支付', + payment_channel_not_supported: '首期仅支持普通微信 V3 充值退款', + membership_not_supported: '首期不支持会员或虚拟支付退款', + refund_in_progress: '当前订单已有退款泥点占用,请先完成对账', + insufficient_permanent_points: '用户永久泥点不足,本次不会向微信发起退款', + provider_transaction_missing: '充值订单缺少微信支付交易单号', + fully_refunded: '订单已无剩余可退款金额', + wechat_order_not_paid: '微信支付账单尚未确认可退款', + wechat_refund_not_reconciled: '微信侧已有退款,但本地尚未完成登记或对账', + refund_precondition_failed: '退款前置条件未满足,请人工核对', + invalid_refund_amount: '退款金额必须大于 0 且不超过剩余可退金额', + }; + return labels[code.toLowerCase()] ?? `当前不可退款:${code}`; +} + +function datetimeLocalToRfc3339(value: string) { + const normalized = value.trim(); + if (!normalized) { + return ''; + } + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? normalized : parsed.toISOString(); +} + +function findDebtOrder( + actionOrder: AdminRechargeOrderEntryPayload | undefined, + orders: AdminRechargeOrderEntryPayload[], +) { + if ( + actionOrder && + (actionOrder.wallet.refundDebtPoints > 0 || + actionOrder.unrecoveredPoints > 0) + ) { + return actionOrder; + } + return orders.find( + (order) => order.wallet.refundDebtPoints > 0 || order.unrecoveredPoints > 0, + ); +} + +function createRequestId() { + if ( + typeof crypto !== 'undefined' && + typeof crypto.randomUUID === 'function' + ) { + return crypto.randomUUID(); + } + return `admin-refund-${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +const unknownProviderStatusMessage = + '微信侧退款状态未知,请勿直接重复提交;请重新核验支付账单后再使用原请求继续处理。'; diff --git a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx index 3c9f5d2e7..010ce3708 100644 --- a/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx +++ b/apps/admin-web/src/pages/AdminTrackingEventsPage.tsx @@ -16,6 +16,7 @@ import { filterAdminTrackingEventDefinitions, findAdminTrackingEventDefinition, } from '../config/trackingEventDefinitions'; +import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; import { handlePageError } from './pageUtils'; interface AdminTrackingEventsPageProps { @@ -326,8 +327,26 @@ export function AdminTrackingEventsPage({ dayKey: {entry.dayKey} - {entry.userId || '-'} - owner: {entry.ownerUserId || '-'} +
+ {entry.userId || '-'} + {entry.userId ? ( + + ) : null} +
+
+ owner: {entry.ownerUserId || '-'} + {entry.ownerUserId ? ( + + ) : null} +
{entry.profileId || '-'} @@ -364,6 +383,8 @@ export function AdminTrackingEventsPage({ {detailEntry ? ( setDetailEntry(null)} /> ) : null} @@ -373,9 +394,13 @@ export function AdminTrackingEventsPage({ function TrackingEventDetailPanel({ entry, + token, + onUnauthorized, onClose, }: { entry: AdminTrackingEventEntryPayload; + token: string; + onUnauthorized: (message?: string) => void; onClose: () => void; }) { return ( @@ -405,6 +430,24 @@ function TrackingEventDetailPanel({
                     {formatMetadataJson(entry.metadataJson)}
                   
+ ) : column.key === 'userId' && entry.userId ? ( +
+ {entry.userId} + +
+ ) : column.key === 'ownerUserId' && entry.ownerUserId ? ( +
+ {entry.ownerUserId} + +
) : ( formatExportCell(entry[column.key], column.key) || '-' )} diff --git a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx index aea06873e..880641340 100644 --- a/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx +++ b/apps/admin-web/src/pages/AdminWorkVisibilityPage.tsx @@ -6,6 +6,7 @@ import { updateAdminWorkVisibility, } from '../api/adminApiClient'; import type {AdminWorkVisibilityEntryPayload} from '../api/adminApiTypes'; +import {AdminUserReferenceButton} from '../components/AdminUserReferenceButton'; import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm'; import {handlePageError} from './pageUtils'; @@ -177,8 +178,17 @@ export function AdminWorkVisibilityPage({ {entry.subtitle || entry.profileId} - {entry.authorDisplayName || '玩家'} - {entry.ownerUserId} +
+
+ {entry.authorDisplayName || '玩家'} + {entry.ownerUserId} +
+ +
diff --git a/apps/admin-web/src/styles/admin.css b/apps/admin-web/src/styles/admin.css index bbee95ac6..19ff3edd7 100644 --- a/apps/admin-web/src/styles/admin.css +++ b/apps/admin-web/src/styles/admin.css @@ -546,6 +546,56 @@ button:disabled { align-items: end; } +.admin-recharge-filter-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + align-items: end; +} + +.admin-recharge-filter-actions { + justify-content: flex-start; +} + +.admin-inline-identity { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.admin-inline-identity > div { + min-width: 0; +} + +.admin-database-user-cell, +.admin-tracking-user-reference { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 7px; +} + +.admin-database-user-cell .admin-table-cell-ellipsis, +.admin-tracking-user-reference span, +.admin-tracking-user-reference small { + min-width: 0; +} + +.admin-inline-identity strong, +.admin-inline-identity small, +.admin-mono-value { + overflow-wrap: anywhere; +} + +.admin-mono-value { + font-family: + "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; + font-size: 12px; +} + .admin-table tbody tr[data-clickable="true"] { cursor: pointer; } @@ -831,6 +881,133 @@ button:disabled { padding: 18px; } +.admin-user-detail-backdrop { + z-index: 85; +} + +.admin-user-detail-panel { + width: min(100%, 920px); + max-height: min(92dvh, 900px); +} + +.admin-user-reference-button { + flex: 0 0 34px; + min-width: 34px; + padding: 0; +} + +.admin-user-detail-loading, +.admin-user-detail-error { + display: grid; + min-height: 180px; + place-items: center; + gap: 12px; +} + +.admin-user-detail-error { + align-content: center; +} + +.admin-user-identity { + display: grid; + grid-template-columns: 68px minmax(160px, 0.42fr) minmax(280px, 1fr); + gap: 16px; + align-items: center; + border-bottom: 1px solid #eaded2; + padding-bottom: 16px; +} + +.admin-user-avatar { + display: grid; + width: 64px; + height: 64px; + place-items: center; + overflow: hidden; + border: 1px solid #dfc8b7; + border-radius: 50%; + color: #8f3f27; + background: #f4e5d7; +} + +.admin-user-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-user-identity-primary { + display: grid; + min-width: 0; + gap: 5px; +} + +.admin-user-identity-primary strong { + overflow-wrap: anywhere; + color: #3d1f10; + font-size: 18px; +} + +.admin-user-identity-primary span { + overflow-wrap: anywhere; + color: #8f3f27; + font-size: 13px; + font-weight: 750; +} + +.admin-user-identity-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.admin-user-identity-list div { + grid-template-columns: 1fr; + gap: 3px; +} + +.admin-user-wallet-section, +.admin-user-restriction-section, +.admin-user-recharge-section { + display: grid; + gap: 12px; +} + +.admin-user-wallet-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 9px; +} + +.admin-user-restriction-section { + border-top: 1px solid #eaded2; + border-bottom: 1px solid #eaded2; + padding: 16px 0; +} + +.admin-user-restriction-record { + display: grid; + gap: 3px; + color: #6f5848; + font-size: 13px; +} + +.admin-user-restriction-record small { + color: #8f7868; +} + +.admin-user-restriction-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: end; +} + +.admin-user-restriction-actions button { + min-width: 150px; +} + +.admin-user-recharge-table { + min-width: 720px; +} + .admin-detail-list .admin-code-block { max-height: 280px; } @@ -905,6 +1082,35 @@ button:disabled { font-size: 13px; } +.admin-alert-warning, +.admin-alert-success, +.admin-refund-result-banner { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.admin-alert-warning { + border-color: #efc894; + color: #8f4b26; + background: #fffaf3; +} + +.admin-alert-success { + border-color: #a9cfb2; + color: #28633a; + background: #f2faf4; +} + +.admin-refund-result-banner div { + display: grid; + gap: 3px; +} + +.admin-refund-result-banner span { + line-height: 1.45; +} + .admin-info-list { display: grid; gap: 10px; @@ -1010,6 +1216,96 @@ button:disabled { min-width: 1180px; } +.admin-recharge-table { + min-width: 1080px; + table-layout: fixed; +} + +.admin-recharge-table th:nth-child(1) { + width: 13%; +} + +.admin-recharge-table th:nth-child(2) { + width: 17%; +} + +.admin-recharge-table th:nth-child(3) { + width: 14%; +} + +.admin-recharge-table th:nth-child(4) { + width: 10%; +} + +.admin-recharge-table th:nth-child(5) { + width: 16%; +} + +.admin-recharge-table th:nth-child(6) { + width: 14%; +} + +.admin-recharge-table th:nth-child(7), +.admin-recharge-table th:nth-child(8) { + width: 8%; +} + +.admin-recharge-table td { + overflow-wrap: anywhere; +} + +.admin-recharge-tags { + margin-top: 7px; +} + +.admin-recharge-refund-button { + min-width: 82px; +} + +.admin-recharge-dialog { + width: min(100%, 840px); +} + +.admin-refund-summary-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.admin-recharge-metric { + display: grid; + min-width: 0; + gap: 5px; + border: 1px solid #eaded2; + border-radius: 8px; + background: #fffdf9; + padding: 11px 12px; +} + +.admin-recharge-metric span { + color: #8f7868; + font-size: 12px; +} + +.admin-recharge-metric strong { + overflow-wrap: anywhere; + color: #3d1f10; + font-size: 16px; +} + +.admin-refund-mode { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.admin-refund-preview { + display: grid; + gap: 12px; + border: 1px solid #eaded2; + border-radius: 8px; + background: #fffdf9; + padding: 14px; +} + .admin-asset-query-table { table-layout: fixed; } @@ -1557,7 +1853,7 @@ button:disabled { } .admin-content { - padding: 16px 14px 86px; + padding: 16px 14px calc(88px + env(safe-area-inset-bottom)); } .admin-overview-grid, @@ -1569,6 +1865,7 @@ button:disabled { .admin-form-row, .admin-filter-grid, .admin-table-query-grid, + .admin-recharge-filter-grid, .admin-contract-field-editor-grid { grid-template-columns: 1fr; } @@ -1611,12 +1908,17 @@ button:disabled { bottom: 0; left: 0; z-index: 20; - display: grid; - grid-template-columns: repeat(auto-fit, minmax(64px, 1fr)); + display: flex; + min-height: calc(72px + env(safe-area-inset-bottom)); + align-items: flex-start; + gap: 4px; + overflow-x: auto; + overflow-y: hidden; border-top: 1px solid #e1ccbb; background: rgba(255, 255, 255, 0.94); padding: 8px 10px calc(8px + env(safe-area-inset-bottom)); backdrop-filter: blur(10px); + scrollbar-width: thin; } .admin-database-pagination { @@ -1635,6 +1937,7 @@ button:disabled { .admin-bottom-nav-button { display: grid; + flex: 0 0 76px; gap: 4px; min-height: 48px; color: #8f7868; @@ -1675,6 +1978,45 @@ button:disabled { display: grid; } + .admin-recharge-page-heading { + display: grid; + align-items: stretch; + } + + .admin-recharge-page-heading .admin-action-row { + justify-content: stretch; + } + + .admin-recharge-page-heading .admin-action-row button { + flex: 1 1 150px; + } + + .admin-refund-summary-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-user-identity { + grid-template-columns: 56px minmax(0, 1fr); + } + + .admin-user-avatar { + width: 52px; + height: 52px; + } + + .admin-user-identity-list { + grid-column: 1 / -1; + grid-template-columns: 1fr; + } + + .admin-user-wallet-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .admin-user-restriction-actions { + grid-template-columns: 1fr; + } + .admin-login-brand h1, .admin-page-heading h2 { font-size: 22px; diff --git a/apps/admin-web/src/styles/admin.test.ts b/apps/admin-web/src/styles/admin.test.ts index a03443925..50ac07c9d 100644 --- a/apps/admin-web/src/styles/admin.test.ts +++ b/apps/admin-web/src/styles/admin.test.ts @@ -19,6 +19,17 @@ describe('admin shell scrolling contract', () => { expect(ruleFor('.admin-content')).toContain('min-height: 0'); expect(ruleFor('.admin-content')).toContain('overflow: auto'); }); + + test('mobile navigation stays in one horizontally scrollable row', () => { + const mobileStyles = stylesheet.slice(stylesheet.indexOf('@media (max-width: 980px)')); + expect(mobileStyles).toContain('.admin-bottom-nav {'); + expect(mobileStyles).toContain('display: flex'); + expect(mobileStyles).toContain('overflow-x: auto'); + expect(mobileStyles).not.toContain( + 'grid-template-columns: repeat(auto-fit, minmax(64px, 1fr))', + ); + expect(mobileStyles).toContain('flex: 0 0 76px'); + }); }); function ruleFor(selector: string) { diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f439f7856..9badbcad8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4064,3 +4064,23 @@ - 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。 - 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。 - 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。 + +## 2026-07-13 普通微信支付 V3 退款使用统一观察事务闭环 + +- 背景:普通微信支付 V3 的退款申请响应、退款结果回调、主动查单和商户平台手工退款发现可能重复、乱序或只出现其中一种;原充值订单只有单一终态,无法表达多次部分退款、权益回收欠款和会员人工处理。 +- 退款事实:新增 `profile_recharge_refund`、`profile_recharge_refund_observation`、`profile_recharge_order_refund_settlement` 和 `profile_recharge_refund_bill_checkpoint`。所有已验签退款事实统一调用 `record_profile_recharge_refund_observation_and_return`;`out_refund_no` 是商户幂等键,微信退款单号保持唯一,重复 observation 必须核对原订单、微信支付单、金额、状态和事实指纹,不能仅按主键吞掉冲突。 +- 订单与权益:部分退款保持原充值订单 `paid`,累计成功退款等于订单金额时才改为 `refunded`;`paid_at` 永久保留,退款不恢复首充资格。泥点按累计退款比例计算目标回收量,全额时精确收口原 `points_delta`;自动回收只扣普通永久泥点,不动每日免费和会员周期泥点。永久泥点不足时记录 `shortfall` 并冻结正式钱包消费,流水来源为 `recharge_refund_recovery`;会员退款统一 `manual_review`,不自动猜测有效期、档位或周期泥点回滚。 +- 回调与现金事实:退款回调使用独立 `/api/profile/recharge/wechat/refund-notify`,不能复用支付 `WECHAT_PAY_NOTIFY_URL`。验签、解密、契约校验和 SpacetimeDB 持久化成功后返回 `204`;现金退款已成功但本地权益不足、订单冲突或会员待复核时仍先保存事实并 ACK,只有签解密、契约或持久化失败才让微信重试。诊断日志只保存脱敏结构化摘要和稳定引用。 +- 查单与账单:`WECHAT_PAY_REFUND_RECONCILIATION_ENABLED` 默认关闭,只有具备真实商户凭据和 runtime service identity 的 HTTP 角色可开启。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;成功退款早于支付通知时只对 `order_missing / order_not_paid` 继续重试,其他人工复核不自动放行;候选列表按分钟轮转分页,失败日志不回显 provider URL。北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞其他行和日期,但当日不写完成 checkpoint;昨日 `NO_STATEMENT_EXIST` 至少延迟到次日 10 点后再确认。`PLATFORM-ORIGINAL / PLATFORM-BALANCE` 只用于发现商户平台退款,发现后仍必须主动查单取得当前状态;账单申请响应验签,GZIP 内容按 SHA1 验真并使用 CSV parser 和十进制定点金额解析。 +- 历史与入口边界:正式落账前已经 ACK 的旧退款通知不会因升级自动重放。已知商户退款单号通过受控服务端查单后进入统一事务,未知手工退款由 T+1 账单发现;超过微信 API 近 90 天窗口的历史数据需从商户平台导出核对后逐笔受控查单,禁止直接 SQL 写退款表。当前不开放匿名或普通用户退款、补录接口,退款申请只允许受控运维或后续管理员鉴权流程。 +- 影响范围:`module-runtime` 退款领域策略与钱包来源、`spacetime-module` 退款表和事务、`spacetime-client` facade、`platform-wechat` 退款 / 查单 / 交易账单协议、`api-server` 退款回调与 reconciliation worker。 +- 验证方式:退款相关 `module-runtime` / `platform-wechat` / `api-server` 定向测试,`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run check:server-rs-ddd`、`npm run check:encoding`、`git diff --check`;真实联调后只读核对退款单、observation、订单级 settlement 和账单 checkpoint。 + +## 2026-07-13 后台充值退款使用钱包占用与统一用户详情 + +- 背景:普通 V3 退款已经能从回调、查单和账单收口现金事实,但后台主动退款若先调微信再扣泥点,会在用户余额不足时产生本可避免的欠账;后台各页面也没有统一查询用户余额、绑定状态和充值订单的入口。 +- 决策:新增 `profile_recharge_refund_hold`,后台执行退款前按累计部分退款公式原子占用本次应追回的永久泥点,再使用客户端稳定 `requestId` 派生 `out_refund_no` 调微信。部分退款额外占用 1 泥点并发舍入缓冲,防止占用创建后到达的外部退款跨越累计 `floor` 边界;全额退款不加缓冲。`SUCCESS` 扣款并结算匹配占用,`CLOSED` 释放,`PROCESSING / ABNORMAL` 保持;结果未知时不擅自释放,至少等待 10 分钟并连续 3 次退款查单收到官方 `RESOURCE_NOT_EXISTS` 才由 worker 释放。普通消费必须排除全部活动占用。 +- 欠账与冻结:支付侧已经成功退款时不能回滚现金事实;永久泥点不足部分继续只写订单 settlement 的 `unrecovered_points`,限制普通消费,后续永久泥点优先自动偿还。每日免费和会员周期泥点不参与。人工冻结单独使用 `profile_wallet_manual_restriction`,解除人工冻结不解除退款欠账限制。 +- 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。 +- 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。 +- 影响范围:`module-runtime`、`spacetime-module`、`spacetime-client`、`api-server` 管理员 BFF / refund worker、`shared-contracts` 与 `apps/admin-web`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7b76e0806..37a59b4aa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2120,6 +2120,30 @@ - 处理:不要改成订阅完整 `profile_recharge_order` 历史表。后端订阅只保留活跃五分钟定时器的 `profile_recharge_order_expiration_timer`,监听 timer 删除后按 `order_id` 通过 procedure 读取订单,只处理当前状态为 `expired` 的记录;支付 / 关闭信号会被忽略,断线窗口继续由未检查过期订单 catch-up 补齐。这样既不依赖不受支持的枚举 SQL,也不会把充值历史常驻 API 客户端缓存。 - 验证:运行 `cargo test -p spacetime-client profile_recharge_expiration --manifest-path server-rs/Cargo.toml`,发布后确认 API 日志不再出现订阅解析错误,并用真实 pending 订单验证 scheduled reducer 过期后写入 `expiration_checked_at`。 +## 微信 Native 已入账但二维码弹窗不关闭 + +- 现象:微信支付回调已经返回 `204`,本地充值订单为 `paid` 且泥点已到账,但网页仍停留在“微信扫码支付”,必须点击“我已支付”才刷新。 +- 原因:通用页面恢复确认逻辑在存在 `nativeWechatPayment` 时直接跳过,Native 分支创建二维码后也没有订阅订单 SSE,因此服务端回调发布的订单更新没有前端消费者。 +- 处理:Native 二维码出现后立即调用 `watchWechatRpgProfileRechargeOrder` 订阅当前订单;收到终态后更新充值中心、关闭二维码、清理 pending ref、刷新全局余额并只展示一次结果。SSE 超时或暂时失败时在二维码过期前重连,手动确认与 SSE 并发时以 pending order ref 保证只有首个终态生效。 +- 验证:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx` 覆盖不点击“我已支付”也会在 SSE 返回 `paid` 后自动关闭;再运行根级 `npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。 +- 关联:`src/components/platform-entry/usePlatformProfileCenterController.ts`、`src/services/rpg-entry/rpgProfileClient.ts`、`server-rs/crates/api-server/src/runtime_profile.rs`。 + +## 商户平台退款登记不要混淆 refund_id 与 out_refund_no + +- 现象:在“登记商户平台退款”里填写 `50000000000000000000000000000` 一类微信退款单号后提示找不到退款。 +- 原因:该编号是微信侧 `refund_id`;V3 单笔退款查询路径只接受商户退款单号 `out_refund_no`。把 `refund_id` 放进路径不会自动转换,退款查单会返回 `RESOURCE_NOT_EXISTS`。不过微信没有承诺 `refund_id` 固定为 `50` 开头的 29 位数字,合法 `out_refund_no` 也可能是纯数字,因此形状判断不能代替真实查单。 +- 处理:登记表单明确标注 `out_refund_no`,所有满足官方字符和长度约束的输入都交给服务端真实查询;只有查询确认不存在后,才把 `50` 开头的 29 位纯数字作为“疑似 refund_id”给出定向提示。只有 `refund_id` 时等待退款回调或 T+1 退款账单建立映射;不要调用异常退款申请接口冒充查询。`out_refund_no` 字符校验须覆盖官方允许的数字、大小写字母和 `_ - | * @`。 +- 验证:后台页面测试断言疑似编号仍交给服务端;平台适配器测试锁定 `RESOURCE_NOT_EXISTS` 映射和 `@` 字符,并确认真正的 `out_refund_no` 仍调用 `GET /v3/refund/domestic/refunds/{out_refund_no}`。 +- 关联:`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx`、`server-rs/crates/api-server/src/admin_recharge.rs`、`server-rs/crates/platform-wechat/src/pay.rs`。 + +## 微信支付查单的 REFUND 不等于已经全额退款 + +- 现象:一笔 6 元充值在商户平台成功退 3 元并登记 `out_refund_no` 后,本地显示累计已退 3 元、剩余可退 3 元,但后台再次预检仍显示“未核验 / REFUND”并禁止退款。 +- 原因:微信支付订单查单的 `trade_state=REFUND` 只说明该支付订单发生过退款,不携带累计退款明细,也不表示已经全额退款。若后台把 `verified` 硬编码为 `trade_state == SUCCESS`,任何已成功部分退款的订单都会永久失去继续退款能力;反过来,仅看到 `REFUND` 就直接放行又可能漏掉未登记的商户平台退款。 +- 处理:预检先查支付订单并校验商户订单号、支付单号和总金额,再主动刷新全部已知 `out_refund_no` 并重读本地退款 settlement。`SUCCESS` 可继续预检;`REFUND` 仅在本地累计成功退款大于 0 且小于订单总额,并且没有 `PROCESSING / ABNORMAL` 退款、活动 hold、退款欠账或人工冻结时,允许继续退本地剩余额度。没有本地成功退款能解释 `REFUND` 时使用独立原因码阻止并要求登记或对账,不能冒充“订单未支付”。 +- 验证:后端策略测试覆盖 `SUCCESS + 0/600`、`REFUND + 0/600`、`REFUND + 300/600`、`REFUND + 600/600`;后台页面测试覆盖 `已核验 / REFUND` 时剩余额度可提交。真实联调核对累计退款、已追回泥点、活动占用和欠账均与退款明细一致。 +- 关联:`server-rs/crates/api-server/src/admin_recharge.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`apps/admin-web/src/pages/AdminRechargeOrderPage.tsx`。 + ## 抓大鹅历史草稿外部 Rodin GLB 链接必须转存后再试玩或发布 - 现象:草稿页预览模型失败并报 `GL_INVALID_ENUM: Invalid cap.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。 @@ -3016,3 +3040,35 @@ - Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。 - 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。 - 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +## iOS 退款问询的 result_code 不是 debug 状态 + +- 现象:为了先观察真实 iOS 退款通知,回调返回 `ErrCode=0 + IosRefundQueryResponse.result_code=1`,并把 evidence 写成“调试阶段不执行自动退款决策”,看起来像安全 ACK,实际已经向微信建议拒绝退款。 +- 原因:`xpay_subscribe_ios_refund_query_notify` 只有 `result_code=0`(建议退款)和 `1`(建议拒绝)两种正式决策;`evidence` 必须是可审计的履约或消耗事实,不存在中立调试值。与此同时,解密后的完整 payload 含 OpenID、Apple 交易号、退款原因和票据,不能为了排障直接落日志。 +- 处理:未接入真实履约决策时返回非零 `ErrCode` 让微信重试,不携带 `IosRefundQueryResponse`;所有事件只写脱敏结构化摘要,payload、未知事件/字段、标识符和字符串值使用消息 Token 加用途域派生的稳定 HMAC 引用,自由文本只写长度和 HMAC 引用。Android 订阅成功和普通 goods 通知可能同形,payload marker 只能快速分流;只有存在同号 `wechat_mp_virtual` 本地充值订单,并在 2.5 秒内通过 `/xpay/query_order` 校验订单号、金额、`order_type=0/7`、支付状态和权威 `paid_time`,才允许入账。Apple 通知缺少 `WeChatPayInfo.PaidTime` 时走查单,绝不能用本机时间补齐。 +- 验证:`cargo test -p platform-wechat virtual_payment_debug_summary --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server virtual_payment_debug_routing --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server virtual_payment_ios_refund_query_has_no_fake_decision_response --manifest-path server-rs/Cargo.toml`。 +- 关联:`server-rs/crates/platform-wechat/src/pay.rs`、`server-rs/crates/api-server/src/wechat/pay.rs`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 + +## 微信支付 V3 的支付 notify_url 不会自动接收退款结果或发现全部手工退款 + +- 现象:普通微信支付成功回调已经配置并可达,但在商户平台或代码里发起退款后,`/api/profile/recharge/wechat/notify` 收不到退款单状态变化;商户平台手工退款也可能没有请求本系统的退款回调入口。 +- 原因:V3 支付成功通知与退款结果通知是不同契约;代码发起退款时,退款通知地址来自每次 `POST /v3/refund/domestic/refunds` 请求里的 `notify_url`,支付下单使用的 `WECHAT_PAY_NOTIFY_URL` 不会自动复用。商户平台手工退款不能假设会携带本系统按 API 请求传入的回调地址;退款接口返回成功也只表示受理,不能当成退款终态。 +- 处理:代码退款显式传入公网 `https:///api/profile/recharge/wechat/refund-notify`,并用稳定 `out_refund_no` 串联申请、重复通知和主动查单。回调先用原始 body 验签、检查正负 5 分钟时间窗,再用 APIv3 密钥解密;校验事件、资源类型、商户号和退款状态后,将 callback observation 写入统一 SpacetimeDB 事务,持久化成功才返回 `204`。正式链路不再是“debug 只记日志”:部分 / 全额退款、泥点回收、欠款冻结和会员人工复核均由事务收口;未知事件、校验或持久化失败返回微信 `FAIL` 响应。另开启 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true`,对 `order_missing / order_not_paid` 继续等待晚到支付通知,候选退款按分钟轮转分页且错误日志不回显 provider URL;次日 10 点后按分片补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,并在落账前再主动查单。单行失败不能阻塞其他行或日期,也不能提前写完成 checkpoint;昨日 `NO_STATEMENT_EXIST` 至少延迟到次日 10 点后再确认;不要为联调开放未鉴权公网退款或补录接口。 +- 验证:`cargo test -p platform-wechat v3_refund_notify --manifest-path server-rs/Cargo.toml`、`cargo test -p platform-wechat v3_transaction_notify --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server v3_refund_notify_failure --manifest-path server-rs/Cargo.toml`;真实联调后只读核对 `profile_recharge_refund`、`profile_recharge_refund_observation`、`profile_recharge_order_refund_settlement` 和 `profile_recharge_refund_bill_checkpoint`。 +- 关联:`server-rs/crates/platform-wechat/src/pay.rs`、`server-rs/crates/api-server/src/wechat/pay.rs`、`server-rs/crates/api-server/src/app.rs`、`docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。 + +## 已 ACK 的历史退款通知不会因正式落账上线而自动重放 + +- 现象:微信侧退款已经是 `SUCCESS`,旧 debug 回调也曾返回 `204`,但部署正式退款表和权益回收事务后,本地充值订单仍为 `paid`,退款表没有记录。 +- 原因:微信收到成功应答后会把该次通知视为已送达;服务升级不会让已经 ACK 的历史通知自动重放。主动 reconciliation 只能继续查询本地已经知道 `out_refund_no` 的非终态退款,不能凭空枚举所有历史退款。 +- 处理:已知 `out_refund_no` 时,由持有真实商户凭据的受控服务端先调用单笔退款查询,验微信响应签名后写入统一 observation 事务;未知的商户平台退款等待 T+1 `REFUND` 交易账单发现,再查单落账。自动账单按分片补扫微信 API 可查询的近 90 天,超过窗口的数据需从商户平台导出候选后逐笔受控查单。禁止用 SQL 直接把订单改为 `refunded`,也禁止直接插入退款表或按账单 CSV 状态扣泥点,这些做法会绕过不可变字段冲突校验、累计部分退款和权益结算。 +- 验证:核对退款 observation 的 `source`、`resolution_code` 与金额,再核对订单级 settlement 的累计退款、`recovery_status`、`unrecovered_points` 和 `wallet_frozen`;全额退款应保留原订单 `paid_at`,防止错误恢复首充资格。 +- 关联:`server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs`、`server-rs/crates/spacetime-module/src/runtime/profile.rs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 退款请求结果未知时不能立即释放钱包占用 + +- 现象:后台调用微信退款超时或连接中断,页面提示状态未知;如果服务端立即释放泥点占用,用户可以继续消费,而微信稍后仍可能完成退款,最终形成可避免的退款欠账。反过来,永久保留占用又会让一次明确未创建的退款长期冻结余额。 +- 原因:HTTP 错误只能说明客户端没有拿到确定响应,不能证明微信没有受理;单次退款查单 `RESOURCE_NOT_EXISTS` 也可能处于短暂传播窗口。只有使用原 `out_refund_no` 主动查单才能继续判定。 +- 处理:网络结果未知时保留活动 hold,页面复用原 `requestId`;worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。 +- 补充:退款查单适配器必须保留微信 `RESOURCE_NOT_EXISTS` 业务码,并兼容同类 `ORDER_NOT_EXIST`,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 `out_refund_no`、订单号、交易号和金额做关联校验。已释放 hold 复用旧 `requestId` 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。 +- 关联:`server-rs/crates/api-server/src/admin_recharge.rs`、`server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs`、`profile_recharge_refund_hold`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 58253180e..dd7b93dce 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -203,6 +203,16 @@ npm run check:server-rs-ddd 13. 所有微信真实渠道都以微信支付通知或服务端查单确认 `SUCCESS` 为到账事实;小程序、H5 跳转和 Native 二维码返回都不能直接发放泥点或会员。 14. 微信 JSAPI / H5 / 小程序 / Native 下单统一显式传 5 分钟 `time_expire`,格式为 RFC3339 秒级时间;Native 额外通过 `wechatNativePayment.expiresAt` 下发给前端二维码弹窗展示。 15. 真实微信渠道的新建 pending 充值订单会写入 SpacetimeDB 原生 scheduled 表 `profile_recharge_order_expiration_timer`。到期 reducer 只做数据库内状态转换:订单仍为 `pending` 时更新为 `expired` 并写 `expired_at`,同时删除 timer。HTTP `api-server` 只订阅这张活跃 timer 表的删除事件,收到 `order_id` 后通过 procedure 重新读取订单,只有状态确认为 `expired` 才执行微信查单补偿;支付或主动关闭同样会删除 timer,但会被状态判断忽略。监听断线期间遗漏的删除事件由未检查过期订单 catch-up 补齐,不订阅完整 `profile_recharge_order` 历史表。普通微信支付查单中 `SUCCESS` 可把 `expired` 补确认成 `paid` 入账;`NOTPAY` 会调用微信关单并把本地订单保持为 `expired`;`CLOSED` / `REVOKED` / `PAYERROR` / `ORDER_NOT_EXIST` 只记录检查结果。`wechat_mp_virtual` 使用小程序 `access_token` 和虚拟支付 AppKey 调用官方 `/xpay/query_order`,只在返回单号、支付类型 `order_type=0/7`、金额、合法 `paid_time` 与本地契约一致且状态为 `2/3/4` 时补入账;退款类型 `1/8` 不得触发充值,其余已知状态只记录检查结果。`short_series_goods` 从 `status=2` 恢复时先幂等入账,再调用 `/xpay/notify_provide_goods`,失败后允许基于本地 `paid` 状态只重试发货;`short_series_coin` 不调用现金单发货接口。`external-generation-worker` / controller 不处理充值过期。 +16. 普通微信支付 V3 退款事实由 `profile_recharge_refund` 保存,回调、退款 API 响应、主动查单和交易账单发现统一调用 `record_profile_recharge_refund_observation_and_return`。`out_refund_no` 是商户幂等键,`provider_refund_id` 唯一;重复 observation 必须校验订单、交易、金额、状态和指纹,不允许仅按主键直接吞掉冲突。 +17. `profile_recharge_order_refund_settlement` 按原订单聚合累计成功退款金额和权益回收。部分退款不改充值订单 `paid`;累计金额等于订单金额时才改为 `refunded`。历史支付和首充资格以 `paid_at` 是否存在判断,退款不把用户重新变成首充。 +18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点。 +19. 外部现金退款 `SUCCESS` 必须先持久化并 ACK,即使本地订单缺失、金额冲突、权益不足或会员需要人工处理,也不能回滚已经发生的现金事实。冲突 observation 记录 resolution code 并进入告警;只有验签、解密、契约解析或 SpacetimeDB 持久化失败才让微信重试。 +20. 主动查询与退款交易账单 worker 只由 HTTP 角色运行并由 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true` 显式开启。非终态退款按 1 / 5 / 10 / 20 / 30 分钟衰减查单;北京时间次日 10 点后按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口。单行失败不阻塞同日其他退款或其他日期,但该日不写完成 checkpoint 并继续重试;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。账单申请响应验签,GZIP 解压后按 SHA1 验真,CSV 用结构化 parser 和十进制定点金额解析;发现手工退款后必须再查单取得当前状态。 +21. 普通 V3 支付通知同时校验 AppID、商户号、本地订单渠道、金额和微信支付单号;`success_time` 缺失或非法时拒绝,不能用本机时间补齐。晚到通知遇到 `refunded` 订单只做交易号一致性幂等校验,不再次发放权益。 +22. 后台主动退款只支持普通 V3 泥点订单。`api-server` 先做微信支付订单查单预检,再调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。虚拟支付、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须 fail-closed。 +23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。 +24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。 +25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记和钱包冻结接口只留在 `api-server` 管理员鉴权路由。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。 ## 创作入口泥点扣费契约 @@ -748,6 +758,47 @@ npm run check:server-rs-ddd - 源码:`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_refund` + +- Rust 结构体:`ProfileRechargeRefund` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。 +- 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_user_id`、`by_profile_recharge_refund_provider_status`。 + +### `profile_recharge_refund_observation` + +- Rust 结构体:`ProfileRechargeRefundObservation` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:退款事实的追加观察记录。保存 callback / api_request / query / trade_bill 来源、稳定 observation ID、金额、状态、脱敏通知引用、事实指纹和 resolution code;不保存回调密文、签名、密钥、原始 CSV 或短时下载 URL。 +- 索引:`by_profile_recharge_refund_observation_out_refund_no`、`by_profile_recharge_refund_observation_order_id`。 + +### `profile_recharge_order_refund_settlement` + +- Rust 结构体:`ProfileRechargeOrderRefundSettlement` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:原充值订单维度的退款与权益结算摘要,保存累计成功退款金额、目标/已回收/未回收泥点、权益状态和钱包冻结事实。部分退款不改订单终态;累计全额才把订单改为 `refunded`。 +- 索引:主键 `order_id`,`by_profile_recharge_order_refund_settlement_user_id` 用于钱包消费前检查退款欠款。 + +### `profile_recharge_refund_hold` + +- Rust 结构体:`ProfileRechargeRefundHold` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:后台主动退款调用微信前的永久泥点占用。活动占用保护退款所需泥点但不改钱包总额;匹配退款成功后结算,关闭或确认未创建的退款释放。相同 `out_refund_no` 只允许内容完全一致的幂等重放。 +- 索引:主键 `out_refund_no`,`by_profile_recharge_refund_hold_order_id`、`by_profile_recharge_refund_hold_user_id`、`by_profile_recharge_refund_hold_status`。 + +### `profile_wallet_manual_restriction` + +- Rust 结构体:`ProfileWalletManualRestriction` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:后台人工钱包冻结当前态,保存冻结原因、创建/更新管理员和时间。它不承载退款欠账;退款欠账仍来自订单退款 settlement,两个来源任一有效都阻断普通消费。 +- 索引:主键 `user_id`。 + +### `profile_recharge_refund_bill_checkpoint` + +- Rust 结构体:`ProfileRechargeRefundBillCheckpoint` +- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` +- 作用:按交易账单日期记录已完成的退款 reconciliation,保存账单 SHA1(确认稳定无账单时保存 `NO_STATEMENT_EXIST`)、处理退款行数和完成时间。任一退款行失败时不写完成 checkpoint,成功前缀依赖 observation 幂等重放;重复下载、多实例执行或进程重启不会重复回收权益。 + ### `profile_recharge_order_expiration_schedule` - Rust 结构体:`ProfileRechargeOrderExpirationSchedule` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index cf5084de7..88ff570fb 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -81,6 +81,51 @@ Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发 普通微信充值订单本地有效期为 5 分钟。SpacetimeDB 原生 `profile_recharge_order_expiration_timer` 到点后只把仍为 `pending` 的订单改为 `expired`;HTTP `api-server` 只订阅活跃 timer 表的删除事件,按事件中的 `order_id` 重新读取订单并仅对 `expired` 执行微信查单补偿,不订阅完整充值订单历史表。支付或主动关闭也会删除 timer,但读取到非 `expired` 后直接忽略;监听断线窗口由未检查过期订单 catch-up 补齐。`external-generation-worker` 和 `external-generation-controller` 不运行充值过期逻辑,也不应因为扩容外部生成 worker 放大微信查单或关单流量。查账时本地未支付终态保持 `expired`,不再改写为 `closed`;`expiration_checked_at`、`expiration_provider_state`、`expiration_last_error` 用于判断 HTTP 监听器是否已经完成补偿。 +### 普通微信支付 V3 退款联调与对账 + +普通微信支付 V3 的退款结果入口固定为 `POST /api/profile/recharge/wechat/refund-notify`。支付下单的 `WECHAT_PAY_NOTIFY_URL` 只接收支付结果,不能替代退款入口;通过 `POST /v3/refund/domestic/refunds` 发起退款时,必须在该次请求的 `notify_url` 中显式传入公网退款入口。回调不做用户登录态校验,但必须保留原始 body 和 `Wechatpay-*` 请求头供验签、解密;验签、契约校验和统一退款 observation 持久化成功后才返回 `204`。重复回调、主动查单、已验签退款申请响应和交易账单发现都进入 `record_profile_recharge_refund_observation_and_return`,依赖稳定 `out_refund_no`、微信退款单号和 observation 指纹幂等,禁止直接改充值订单或手写退款表。 + +主动查单和退款交易账单 worker 默认关闭,只由 `api` / `all` 这类 HTTP 角色运行。真实部署具备商户私钥、商户证书序列号、平台公钥及序列号、APIv3 密钥和 SpacetimeDB runtime service identity 后,才在服务私密环境中开启: + +```bash +WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true +``` + +开启后,`processing / abnormal` 退款按 1、5、10、20、30 分钟衰减主动查单;`success` 且权益尚未收口时只重试本地回收,不重复请求微信。成功退款早于支付通知时,`order_missing / order_not_paid` 会继续等待晚到支付事实;会员退款及金额、渠道、交易号冲突保持人工复核。候选列表按分钟轮转分页,超过单批 100 条也不会长期饿死,失败日志只输出哈希引用与静态分类。北京时间次日 10 点后,worker 按 30 个稳定分片轮转补扫微信 API 可查询的近 90 天 `bill_type=REFUND` 交易账单,每 30 分钟覆盖完整窗口;`PLATFORM-ORIGINAL / PLATFORM-BALANCE` 用于发现商户平台手工退款,但账单行不能直接决定本地终态,必须再按 `out_refund_no` 查单并验响应签名。单个坏行只让该日保持可重试,不阻塞其余行或日期,也不写完成 checkpoint;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。`profile_recharge_refund_bill_checkpoint` 已存在的日期不会重复处理;多实例或重启造成的重复 observation 仍由统一事务幂等收口。超过近 90 天 API 窗口的历史账单需从商户平台下载后受控核对。 + +公网联调可使用临时 HTTPS tunnel,但必须确认 tunnel 正在转发当前 `api-server` 实际监听端口,且公网 `/healthz` 与本地 `/healthz` 指向同一进程。`notify_url` 不能带查询参数;临时域名变化后,只影响之后新提交的退款请求,已经提交给微信的旧退款单仍绑定旧地址。无签名探测只能验证路由可达,不能伪造成功回调: + +```bash +curl -i https://<公网域名>/healthz +curl -i -X POST 'https://<公网域名>/api/profile/recharge/wechat/refund-notify' \ + -H 'Content-Type: application/json' \ + --data '{}' +``` + +第二个请求在真实 provider 配置下应因缺少微信签名返回 `4xx` 与微信 `FAIL` envelope;若返回 `404`,优先检查 tunnel 目标端口和运行中的二进制是否已包含退款路由。不要把 ngrok inspect 页面、商户私钥、APIv3 密钥、平台公钥原文、签名、密文或解密 payload 写入文档、工单和日志。 + +真实联调时可开启支付 handler 与退款 reconciliation 的 debug 日志。日志应出现“收到微信支付 V3 退款结果通知,开始验签解密”“退款结果通知已持久化”或 `wechat pay refund observation persisted`,并通过稳定 HMAC / SHA256 引用关联重试;不得期待日志输出商户订单号、微信订单号、退款号或原始 payload: + +```bash +npm run dev -- --log 'info,api_server::wechat::pay=debug,api_server::profile_recharge_refund_reconciliation=debug,tower_http=info' +``` + +联调后使用有权读取目标库私有表的 SpacetimeDB 身份做只读核对。以下查询中的占位符必须替换为目标环境值;不要用 SQL `INSERT / UPDATE / DELETE` 补退款,否则会绕过 observation 冲突校验、订单级累计退款和权益回收事务: + +```bash +spacetime sql "SELECT * FROM profile_recharge_order WHERE order_id = ''" --server +spacetime sql "SELECT * FROM profile_recharge_refund WHERE order_id = ''" --server +spacetime sql "SELECT * FROM profile_recharge_refund_observation WHERE out_refund_no = ''" --server +spacetime sql "SELECT * FROM profile_recharge_order_refund_settlement WHERE order_id = ''" --server +spacetime sql "SELECT * FROM profile_recharge_refund_bill_checkpoint" --server +``` + +核对规则:部分退款时原订单保持 `paid`;累计退款等于订单金额后才为 `refunded`,但 `paid_at` 继续保留,因此不会恢复首充资格。泥点退款只回收普通永久泥点;每日免费泥点和会员周期泥点不动。永久泥点不足时 `recovery_status=shortfall`、`unrecovered_points>0`、`wallet_frozen=true`,正式钱包消费在欠款清零前 fail-closed;会员订单统一为 `manual_review`,不得自动缩短有效期或扣周期泥点。 + +后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`、预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。 + +正式落账上线前已经被旧 debug handler 返回成功的退款回调不会因部署新版本自动重放。已知 `out_refund_no` 的历史退款应由具备真实商户凭据的受控服务端操作先调用单笔退款查询,验签后写入同一 observation 事务;未知的商户平台退款等待次日交易账单发现。自动账单按分片补扫微信 API 可查询的近 90 天,超出窗口的历史退款需从商户平台导出核对后逐笔受控查单补录,不能直接把商户平台截图或 CSV 行当作退款终态,也不能开放匿名或普通用户补录 / 退款入口。 + 微信小程序订阅消息生成结果通知使用 `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` 指向可发布的本地库。 diff --git a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md index acf9cc4d8..c389d8114 100644 --- a/docs/【技术方案】微信虚拟支付接入-2026-05-26.md +++ b/docs/【技术方案】微信虚拟支付接入-2026-05-26.md @@ -1,6 +1,6 @@ # 微信虚拟支付接入 -更新时间:`2026-05-26` +更新时间:`2026-07-13` ## 接入口径 @@ -60,10 +60,84 @@ WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV=0 - `WECHAT_MINIPROGRAM_MESSAGE_TOKEN` 和 `WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY` 由环境变量注入;GET URL 验证按官方规则用 `Token/timestamp/nonce` 校验 `signature` 并原样返回 `echostr`,POST 安全模式推送再校验 `msg_signature`、用 `EncodingAESKey` 解密 `Encrypt`,然后按虚拟支付事件入账。 - 安全模式下,POST 推送会先解密再解析 `xpay_goods_deliver_notify` / `xpay_coin_pay_notify`;不要把 GET URL 验证里的 `echostr` 当密文解密。 +## 通知诊断与退款保护 + +后端识别微信虚拟支付当前 9 类官方事件:`xpay_goods_deliver_notify`、`xpay_coin_pay_notify`、`xpay_refund_notify`、`xpay_complaint_notify`、`xpay_wxpay_callback_notify`、`xpay_subscribe_signing_result_notify`、`xpay_subscribe_pay_fail_notify`、`xpay_apple_subscribe_signing_result_notify`、`xpay_subscribe_ios_refund_query_notify`。 + +| 通知 | 当前处理 | 微信应答 | +| --- | --- | --- | +| goods / coin 且存在同号 `wechat_mp_virtual` 本地充值订单 | 在 2.5 秒预算内调用 `/xpay/query_order`,校验订单号、金额、支付类型、状态和权威 `paid_time` 后幂等入账 | 成功时 `ErrCode=0`,查单或校验失败时 `ErrCode=1` | +| goods / coin 但没有同号本地充值订单,或带明确订阅标记 | 只记录诊断,不按普通充值入账;这是 Android 订阅与普通 goods 通知同形时的硬边界 | `ErrCode=0` | +| 退款结果、投诉、风控回调、签解约、订阅扣款失败、Apple 签约结果 | 只记录诊断,不改订单、泥点或会员权益 | `ErrCode=0` | +| iOS 退款问询 | 未配置真实履约决策前不返回同意或拒绝建议 | `ErrCode=1`,触发微信重试 | +| 未识别事件 | 不静默吞掉 | `ErrCode=1`,触发微信重试 | + +`xpay_subscribe_ios_refund_query_notify` 的 `result_code=0` 表示建议退款,`result_code=1` 表示建议拒绝退款,两者都是正式业务决策,不存在“仅调试”的中立值。只有拿到可审计的发货、消耗或会员使用事实后,才能返回 `ErrCode=0 + IosRefundQueryResponse`;当前诊断阶段固定返回非零且不携带该对象。最终退款结果以 `xpay_refund_notify` 为准;正式退款落账与权益回收流程尚未接入,诊断处理不得提前回收权益。 + +Android 订阅成功通知可能与普通 `xpay_goods_deliver_notify` 字段完全相同,因此 `AppleSubscriptionInfo`、`OutContractCode` 等 payload 标记只能用于快速分流,不能作为最终安全边界。入账前必须先确认通知订单号对应本地 `wechat_mp_virtual` 充值订单,再以官方查单结果校验。Apple 等非微信支付渠道的通知可能没有 `WeChatPayInfo.PaidTime`;不得用本机时间补齐,统一从 `/xpay/query_order` 获取权威 `paid_time`。 + +诊断日志在验签和解密成功后记录 `event`、响应格式、payload 字节数、字段路径、payload 指纹、重试次数、环境、状态、金额、数量、产品和履约字段。Payload 指纹、未知事件名、未知字段名、OpenID、本地/微信/Apple 订单号、退款号、合同号等只记录使用消息 Token 和用途域生成的稳定 HMAC 引用;状态等非数值字符串、投诉详情、退款原因、`Attach`、`Remark` 等只记录长度和 HMAC 引用。禁止把解密明文、签名、nonce、密文或密钥写入日志。 + +微信后台固定配置“安全模式 + JSON”。内层 XML 仅保留兼容解析和同格式应答,不代表外层加密 envelope 支持 XML;真实联调不得把后台数据格式切换为 XML。 + +### 微信支付 V3 退款结果通知 + +普通微信支付 V3 与虚拟支付是两套退款通知协议。V3 退款结果使用独立入口 `POST /api/profile/recharge/wechat/refund-notify`;`WECHAT_PAY_NOTIFY_URL` 仍只用于支付成功通知,不会自动让退款结果发到该入口。调用 `POST /v3/refund/domestic/refunds` 发起退款时,必须把 `https://<公网 API 域名>/api/profile/recharge/wechat/refund-notify` 作为本次退款请求的 `notify_url`。退款接口受理成功只表示生成退款单,最终状态以该通知和 `GET /v3/refund/domestic/refunds/{out_refund_no}` 查单为准。 + +V3 退款入口在正式落账的同时保留可用于真实联调的安全诊断: + +- 复用支付通知的原始 body 验签与 APIv3 密钥 AES-256-GCM 解密层;校验 `Wechatpay-Timestamp` 在正负 5 分钟窗口内,并拒绝签名探测、错误平台公钥序列号和篡改请求。 +- 只接受 `resource_type=encrypt-resource`、`resource.algorithm=AEAD_AES_256_GCM`、`resource.original_type=refund`,并要求解密后的 `mchid` 与当前商户一致。 +- 只接受 `REFUND.SUCCESS`、`REFUND.ABNORMAL`、`REFUND.CLOSED`,且分别要求解密后的 `refund_status` 为 `SUCCESS`、`ABNORMAL`、`CLOSED`;未知事件或状态不一致返回非 2xx,避免静默吞掉新契约。 +- 验签、解密和契约校验成功后,先把退款观察写入 SpacetimeDB 统一事务,再返回 HTTP `204`;同一通知重复到达时仍稳定返回 `204`。外部现金退款已经成功但本地权益需要人工处理时也先保存事实再 ACK;只有验签、契约或持久化失败才返回 HTTP `400/502/503` 与 `{"code":"FAIL","message":"失败"}`,让微信继续重试。 +- 日志只明文记录事件、退款状态、资源类型、创建/成功时间和四个金额字段。成功与失败通知都记录按 APIv3 密钥和用途域生成的稳定 `request_ref`,失败额外记录不含原值的阶段与原因码,便于关联微信重试。通知 ID、商户号、支付单号、商户订单号、微信退款单号、商户退款单号只记录 HMAC 引用;`user_received_account` 只记录字节数和 HMAC 引用。禁止记录外层 body、解密明文、签名、nonce、密文、平台序列号或密钥。 +- 成功退款会进入正式退款事务:部分退款保留原订单 `paid`,累计全额退款改为 `refunded`;泥点商品只回收可用永久泥点,会员退款转人工。系统不在公网暴露匿名或普通用户“发起退款”接口;退款申请只能通过受控运维或后续管理员鉴权流程,以稳定 `out_refund_no` 串联申请、回调、主动查单和账单数据。 + +### 微信支付 V3 退款正式落账与对账契约 + +正式链路使用“退款单事实 + 订单级权益结算摘要 + 观察记录 + 账单检查点”,不把部分退款、对账游标或权益异常塞进原充值订单。四个事实入口统一调用同一个 SpacetimeDB 事务:受控退款 API 的已验签响应、退款回调、主动查询和交易账单发现。`out_refund_no` 是商户幂等键,重试必须复用;`provider_refund_id` 是微信退款单号。任何入口重复、乱序或并发到达都必须校验订单号、微信支付单号、微信退款单号、订单总额和本次退款金额等不可变字段,不能只因主键已存在就直接成功。 + +1. `profile_recharge_refund` 每行保存一张普通 V3 退款单,状态只使用 `processing / success / abnormal / closed`。`ABNORMAL` 后可由查询推进为 `SUCCESS` 或 `CLOSED`;`SUCCESS` 终态不得被旧观察回退。最新来源和 observation ID 只用于定位最近观察,完整来源历史以 observation 表为准。 +2. `profile_recharge_refund_observation` 追加保存来源、观察指纹、微信状态和观察时间,不保存回调密文、签名、APIv3 Key、私钥、完整下载地址或原始 CSV。回调观察用微信通知 ID 幂等;API、查询和账单观察用稳定事实指纹幂等。 +3. `profile_recharge_order_refund_settlement` 按原充值订单聚合累计成功退款金额、应回收泥点、已回收泥点、未回收泥点和权益处理状态。累计成功退款小于订单金额时充值订单仍为 `paid`;等于订单金额时才改为 `refunded`;累计金额大于订单金额必须转人工并告警。退款不能恢复首充资格,历史成功支付事实以 `paid_at` 是否存在判定。 +4. 退款金额按分保存。泥点商品的累计应回收量按 `floor(points_delta * 累计成功退款分 / 订单金额分)` 计算并用 `u128` 防溢出;累计全额退款时强制等于完整 `points_delta`,避免部分退款逐单舍入造成遗漏。自动回收只能扣普通永久泥点,绝不消耗每日免费泥点或会员周期限时泥点;余额不足时回收当前可用永久泥点,余量写入 `unrecovered_points`,状态为 `shortfall`,且正式钱包消费入口在欠款清零前 fail-closed。回收流水使用独立来源 `recharge_refund_recovery` 和确定性流水号。 +5. 会员购买、续费和升级目前聚合写入单行 `profile_membership`,没有按订单保存可逆 grant 或升级前快照。会员退款无论全额还是部分都只记录现金退款事实并标记 `manual_review`,不得猜测缩短有效期、降档或扣周期泥点。现金退款已经成功时,即使权益回收不足或需要人工处理,也必须持久化退款事实并正常 ACK,不能依赖微信重复通知解决本地权益问题。 +6. 主动查询 worker 只由 HTTP 角色运行。对 `processing / abnormal` 退款按 1、5、10、20、30 分钟衰减查询 `GET /v3/refund/domestic/refunds/{out_refund_no}`;查询响应先验微信签名,再进入统一观察事务。`success / closed` 停止外部查询;本地仍有泥点欠款的订单只重试本地权益结算,不重复请求微信。成功退款先于支付通知到达而产生的 `order_missing / order_not_paid` 会继续重试,会员退款和金额、渠道、交易号冲突不自动解除人工复核。候选退款按分钟轮转分页,单批超过 100 条时也必须最终覆盖,失败日志只记录哈希引用和静态分类,不回显可能含商户退款单号的请求 URL。 +7. 商户平台手工退款不假设会发送本系统 `notify_url`。北京时间次日 10 点后按 30 个稳定分片轮转请求 `GET /v3/bill/tradebill?bill_date=YYYY-MM-DD&bill_type=REFUND&tar_type=GZIP`,每 30 分钟覆盖微信 API 可查询的近 90 天窗口;申请账单响应必须验微信签名。下载地址仅短时有效,下载请求需要按 V3 规则签名,下载响应无签名头,解压后按响应中的 `SHA1` 校验。CSV 必须用 CSV parser 读取并移除字段前导反引号,金额以十进制定点从元转分,禁止浮点换算。交易状态 `REFUND` 且退款类型 `PLATFORM-ORIGINAL / PLATFORM-BALANCE` 是商户平台退款发现依据;账单状态 `SUCCESS / PROCESSING / FAIL / CHANGE` 不能直接当成查询状态,发现后使用商户退款单号主动查单再落当前终态。单行失败时继续处理其他行和日期,但当日不写完成 checkpoint;昨日返回 `NO_STATEMENT_EXIST` 时至少延迟到次日 10 点后再确认空账单。`profile_recharge_refund_bill_checkpoint` 只保存已完成日期、账单哈希、处理行数和完成时间;多实例可能重复下载,但 observation 与 checkpoint 写入均幂等,重启后不会重复结算权益。超过近 90 天 API 窗口的历史账单只能从商户平台下载并受控核对。 +8. 退款申请只允许管理员鉴权或受控运维入口。`platform-wechat` 提供已签名且验响应签名的 `POST /v3/refund/domestic/refunds` 能力,但当前不新增普通用户或匿名公网退款路由;受控调用必须先核对本地 `paid / refunded` 订单和剩余可退金额,复用稳定 `out_refund_no`,并携带本退款回调 URL。 +9. 普通 V3 支付通知和退款通知都必须校验本地订单的商户号、应用 ID、订单号、微信支付单号和金额。晚到的支付通知遇到已全额退款订单只作为已支付事实幂等确认,不得重新发放权益;同一订单出现不同微信支付单号必须拒绝。 + +主动查询与账单 reconciliation 默认关闭。只有部署环境具备真实商户私钥、平台公钥和运行时服务身份时,才设置 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true`;回调落账不依赖该开关,始终在退款通知路由验签成功后执行。 + +### 后台充值订单与退款编排契约 + +首期后台退款只支持普通微信 V3 的泥点充值订单;`wechat_mp_virtual`、会员商品、未支付订单、支付单号缺失、存在未完成退款或未完成退款占用、已经全额退款的订单必须拒绝。后台是唯一常规退款入口,商户平台只用于应急退款;已知应急退款必须登记商户退款单号 `out_refund_no` 后主动查单,未知退款继续由回调或 T+1 账单发现。微信 V3 单笔退款查询不支持按微信退款单号 `refund_id` 查询,后台必须明确区分这两个字段。编号前缀和长度只能作为 `refund_id` 的疑似提示,不能在查单前硬拒绝一个满足 `out_refund_no` 契约的输入;只有按 `out_refund_no` 查询确认不存在后,才提示改填商户退款单号或等待回调、退款账单建立映射。 + +1. 后台退款分为预检和执行。预检必须验管理员会话,读取本地订单、累计成功退款、活动退款占用和钱包分桶,并实时调用微信支付订单查询。微信支付查单的 `trade_state=REFUND` 只表示发生过退款,不表示已经全额退款:`SUCCESS` 可以进入退款预检;`REFUND` 只有在已登记的成功退款经主动查单刷新、本地累计成功退款大于 0 且小于订单总额、并且不存在未完成退款、活动占用或欠账时,才允许继续退本地计算出的剩余额度。`REFUND` 没有对应本地成功退款事实时必须阻止,提示登记 `out_refund_no` 或等待账单对账。 +2. 执行请求必须携带客户端生成并在重试时复用的 `request_id`。服务端由订单号和 `request_id` 派生稳定 `out_refund_no`,先在 SpacetimeDB 事务中创建 `profile_recharge_refund_hold`,再调用微信退款。占用金额使用累计退款公式计算本次增量应追回泥点;部分退款额外预留 1 泥点作为并发外部退款跨越累计 `floor` 边界的安全缓冲,预检仍展示真实应追回量,结算后自动释放未使用缓冲;全额退款精确占用剩余全部订单泥点。永久泥点不足时事务直接拒绝,不能调用微信。 +3. 活动 hold 不直接改钱包总额,但所有普通负向流水都必须把活动占用从可消费余额中扣除。退款 `SUCCESS` observation 在同一事务里扣除对应占用的永久泥点并把 hold 改为 `settled`;退款 `CLOSED` 释放为 `released`;`PROCESSING / ABNORMAL` 保持占用。没有 provider 结果的活动 hold 由 reconciliation 按稳定 `out_refund_no` 查单;占用创建至少 10 分钟且连续 3 次退款查单收到微信官方 `RESOURCE_NOT_EXISTS` 后才自动释放,进程重启会清空连续次数并重新观察。适配层兼容同类 `ORDER_NOT_EXIST` 错误,但不能把超时、签名、配置、解析和其他上游错误当成退款不存在。 +4. 已经发生的外部退款没有 hold 时沿用现有结算:回收当前未被其他 hold 占用的永久泥点,余额不足部分继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 作为唯一退款欠账真相,状态为 `shortfall` 并限制消费。后续永久泥点到账后在同一钱包事务内按最早退款单自动继续追回;每日免费和会员周期泥点不参与。不得再建一张平行 debt 表重复累计欠账。 +5. 人工钱包冻结单独使用 `profile_wallet_manual_restriction`,保存当前是否冻结、原因、操作管理员和操作时间。普通消费同时检查人工冻结、退款欠账和活动 hold;解除人工冻结不能解除仍存在的退款欠账。 +6. 后台 API 统一位于管理员鉴权下:充值订单列表与详情、用户详情、退款预检、退款执行、应急 `out_refund_no` 登记、钱包人工冻结/解冻。任何接口都不得返回原始手机号、商户私钥、APIv3 Key、微信签名、回调密文或账单下载 URL。 +7. 通用用户详情通过内部 `user_id` 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、冻结原因和最近充值订单。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。 + +真实联调时显式开启该模块的 debug 日志: + +```bash +npm run dev -- --log 'info,api_server::wechat::pay=debug,tower_http=info' +``` + ## 验收命令 ```bash npm exec vitest run miniprogram/pages/wechat-pay/index.test.js src/services/payment/paymentPlatform.test.ts src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +cargo test -p platform-wechat virtual_payment_debug_summary --manifest-path server-rs/Cargo.toml +cargo test -p platform-wechat parse_virtual_payment_notify --manifest-path server-rs/Cargo.toml +cargo test -p api-server virtual_payment_debug_routing --manifest-path server-rs/Cargo.toml +cargo test -p api-server virtual_payment_ios_refund_query_has_no_fake_decision_response --manifest-path server-rs/Cargo.toml +cargo test -p platform-wechat v3_refund_notify --manifest-path server-rs/Cargo.toml +cargo test -p platform-wechat v3_transaction_notify --manifest-path server-rs/Cargo.toml +cargo test -p api-server v3_refund_notify_failure --manifest-path server-rs/Cargo.toml cargo check -p api-server --manifest-path server-rs/Cargo.toml cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml create_profile_recharge_order_response_serializes_virtual_wechat_payloads npm run typecheck @@ -112,5 +186,6 @@ npm run spacetime:wechat-virtual-payment:reconcile -- \ - 沙箱或基础库失败会把微信返回的 `errCode` / `errMsg` 透传到前端失败弹窗,便于区分微信后台道具、沙箱 AppKey、签名和基础库能力问题。 - Web 侧在拉起虚拟支付后会短时轮询 `wx_pay_result`,即使小程序 `web-view` 回写 hash 没触发浏览器 `hashchange`,也必须展示回写的微信错误内容。 - WebView 返回但没有拿到 `wx_pay_result` 时,前端必须主动调用订单确认接口,并接入 `/api/profile/recharge/orders/{orderId}/wechat/events` 的 SSE 事件流作为服务端推送兜底;虚拟支付确认接口会使用当前用户后端保存的小程序 `openid` 调用官方 `/xpay/query_order`,查到已支付且契约校验通过后写入订单。后端通过消息推送或查单入账后都会发布订单更新,SSE 先推当前订单快照,再在订单结束时推 `done`。 +- Web Native 二维码弹窗也必须在展示后立即订阅同一订单 SSE,收到支付回调入账后的 `paid` 快照时自动关闭二维码、刷新充值中心与全局余额并展示一次成功结果;“我已支付”只作为主动查单兜底,不能是扫码付款后的唯一状态推进入口。SSE 在等待窗口结束或短暂断线时按订单过期时间重连,关闭弹窗时必须取消订阅。 - 小程序订阅消息用于 AI 创作生成结果通知:H5 在生成动作发起前先把页面切到生成进度态并立即调用生成 action,同时非阻塞跳转到小程序原生订阅授权页尝试请求授权;授权接受、拒绝或页面返回都不得阻塞或取消生成。原生页不得改写上一页 `webViewUrl`,避免返回后丢失 H5 当前进度页状态。通知发送只允许发生在玩法草稿生成成功或失败终态之后,api-server 使用当前用户微信登录保存的 openid 调用微信 `subscribeMessage.send`。发送失败只记录 warning,不阻断作品生成。模板 `thing1` 发送玩法模板名,`number6` 发送本次生成结算后的实际泥点扣除,失败退款后固定为 `0`;模板 `time4` 字段必须是北京时间 `YYYY-MM-DD HH:mm`。`WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE` 支持 `formal` / `trial` / `developer`,应与当前发布环境一致。 - WebView 返回后,在订单状态拉取或 SSE 等待期间展示不可关闭遮罩“正在确认支付”,阻止用户离开或继续操作;只有确认到最终订单状态后才展示一次最终结果弹窗,不能先弹“正在支付/支付已提交”再二次弹成功。 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 96361942e..a010f230e 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4538,7 +4538,10 @@ dependencies = [ "aes", "base64 0.22.1", "cbc", + "csv", + "flate2", "hex", + "openssl", "reqwest 0.12.28", "ring", "serde", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index fd22210ca..3d3bc1151 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -108,6 +108,7 @@ base64 = "0.22" cbc = { version = "0.1", features = ["alloc"] } bytes = "1" curl = "0.4" +csv = "1" dotenvy = "0.15" flate2 = "1" futures-util = "0.3" diff --git a/server-rs/crates/api-server/src/admin_recharge.rs b/server-rs/crates/api-server/src/admin_recharge.rs new file mode 100644 index 000000000..aac351d69 --- /dev/null +++ b/server-rs/crates/api-server/src/admin_recharge.rs @@ -0,0 +1,1291 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use axum::{ + Json, + extract::{Extension, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use module_auth::AuthUser; +use module_runtime::{ + RuntimeProfileAdminWalletSnapshot, RuntimeProfileRechargeOrderAdminEntrySnapshot, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeRefundHoldSnapshot, + RuntimeProfileRechargeRefundHoldStatus, RuntimeProfileRechargeRefundObservationSource, + RuntimeProfileRechargeRefundRecoveryStatus, RuntimeProfileRechargeRefundSnapshot, + RuntimeProfileRechargeRefundStatus, build_runtime_profile_admin_wallet_get_input, + build_runtime_profile_recharge_order_admin_list_input, + build_runtime_profile_recharge_refund_hold_prepare_input, + build_runtime_profile_recharge_refund_hold_preview_input, + build_runtime_profile_recharge_refund_settlement_plan, + build_runtime_profile_wallet_manual_restriction_upsert_input, +}; +use platform_wechat::pay::{ + WechatPayError, WechatPayNotifyOrder, WechatPayRefund, WechatPayRefundRequest, +}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use shared_contracts::admin::{ + AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, + AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, + AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, AdminRechargeRefundPayload, + AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, + AdminRechargeRefundRegisterRequest, AdminUserDetailQuery, AdminUserDetailResponse, + AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest, + AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload, +}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + admin::AuthenticatedAdmin, + api_response::json_success_body, + http_error::AppError, + request_context::RequestContext, + state::AppState, + wechat::pay::{build_wechat_pay_refund_observation, current_unix_micros, map_wechat_pay_error}, +}; + +const DEFAULT_ORDER_LIMIT: u32 = 100; +const USER_DETAIL_ORDER_LIMIT: u32 = 20; +const WECHAT_PAYMENT_NOTIFY_PATH: &str = "/api/profile/recharge/wechat/notify"; +const WECHAT_REFUND_NOTIFY_PATH: &str = "/api/profile/recharge/wechat/refund-notify"; + +pub async fn admin_list_recharge_orders( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, Response> { + let user_id = resolve_optional_user_id(&state, query.user_id, query.public_user_code) + .map_err(|error| error_response(&request_context, error))?; + let input = build_runtime_profile_recharge_order_admin_list_input( + query.order_id, + user_id, + query.provider_transaction_id, + query.payment_channel, + parse_order_status(query.status.as_deref()) + .map_err(|error| error_response(&request_context, error))?, + parse_optional_time_micros(query.created_after.as_deref(), "createdAfter") + .map_err(|error| error_response(&request_context, error))?, + parse_optional_time_micros(query.created_before.as_deref(), "createdBefore") + .map_err(|error| error_response(&request_context, error))?, + query.limit.unwrap_or(DEFAULT_ORDER_LIMIT), + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let entries = state + .spacetime_client() + .admin_list_profile_recharge_orders(input) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + let user_summaries = load_user_summaries(&state, &entries); + + Ok(json_success_body( + Some(&request_context), + AdminRechargeOrderListResponse { + entries: entries + .into_iter() + .map(|entry| { + let user = user_summaries.get(&entry.order.user_id).cloned(); + map_order_entry(entry, user) + }) + .collect(), + }, + )) +} + +pub async fn admin_get_user_detail( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, Response> { + let user = resolve_user(&state, query.user_id, query.public_user_code) + .map_err(|error| error_response(&request_context, error))?; + let wallet = state + .spacetime_client() + .admin_get_profile_wallet( + build_runtime_profile_admin_wallet_get_input(user.id.clone()).map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?, + ) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + let list_input = build_runtime_profile_recharge_order_admin_list_input( + None, + Some(user.id.clone()), + None, + None, + None, + None, + None, + USER_DETAIL_ORDER_LIMIT, + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let orders = state + .spacetime_client() + .admin_list_profile_recharge_orders(list_input) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + let summary = map_user_summary(&user); + + Ok(json_success_body( + Some(&request_context), + AdminUserDetailResponse { + user_id: user.id, + public_user_code: user.public_user_code, + display_name: user.display_name, + avatar_url: user.avatar_url, + phone_number_masked: user.phone_number_masked.clone(), + login_method: user.login_method.as_str().to_string(), + binding_status: user.binding_status.as_str().to_string(), + phone_bound: user.phone_number_masked.is_some(), + wechat_bound: user.wechat_bound, + wallet: map_wallet(wallet), + recharge_orders: orders + .into_iter() + .map(|entry| map_order_entry(entry, Some(summary.clone()))) + .collect(), + }, + )) +} + +pub async fn admin_preview_recharge_refund( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let (entry, payment_check) = reconcile_and_validate_order( + &state, + &request_context, + payload.order_id, + payload.refund_amount_cents, + ) + .await?; + let user = load_user_summary(&state, &entry.order.user_id); + let remaining_refundable_cents = remaining_refundable_cents(&entry); + + let preview_input = build_runtime_profile_recharge_refund_hold_preview_input( + entry.order.order_id.clone(), + payload.refund_amount_cents, + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let preview = state + .spacetime_client() + .preview_profile_recharge_refund_hold(preview_input) + .await; + let settlement = entry.settlement.as_ref(); + let recovery_plan = build_runtime_profile_recharge_refund_settlement_plan( + settlement + .map(|value| value.successful_refund_count) + .unwrap_or(0), + settlement + .map(|value| value.cumulative_success_refund_cents) + .unwrap_or(0), + settlement + .map(|value| value.target_recovery_points) + .unwrap_or(0), + payload.refund_amount_cents, + entry.order.amount_cents, + entry.order.points_delta, + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::CONFLICT).with_message(message), + ) + })?; + let (incremental_recovery_points, hold_ready, hold_block_reason_code) = match preview { + Ok(_) => (recovery_plan.incremental_target_recovery_points, true, None), + Err(error) => ( + 0, + false, + Some(classify_refund_block_reason(&error.to_string()).to_string()), + ), + }; + let can_submit = payment_check.verified && hold_ready; + let block_reason_code = if payment_check.verified { + hold_block_reason_code + } else { + Some(payment_check_block_reason_code(&payment_check.trade_state).to_string()) + }; + let mapped_entry = map_order_entry(entry, user); + + Ok(json_success_body( + Some(&request_context), + AdminRechargeRefundPreviewResponse { + order: mapped_entry, + payment_check, + refund_amount_cents: payload.refund_amount_cents, + incremental_recovery_points, + remaining_refundable_cents, + can_submit, + block_reason_code, + }, + )) +} + +pub async fn admin_execute_recharge_refund( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result { + let request_id = normalize_request_id(&payload.request_id) + .map_err(|error| error_response(&request_context, error))?; + let out_refund_no = build_out_refund_no(&payload.order_id, request_id); + let refund_reason = normalize_refund_reason(payload.reason.as_deref()); + let mut entry = load_order(&state, &request_context, &payload.order_id).await?; + let has_matching_hold = entry.active_hold.as_ref().is_some_and(|hold| { + hold.out_refund_no == out_refund_no && hold.refund_cents == payload.refund_amount_cents + }); + + if entry.active_hold.is_some() && !has_matching_hold { + return Err(error_response( + &request_context, + AppError::from_status(StatusCode::CONFLICT) + .with_message("该订单已有另一笔退款处理中") + .with_details(json!({"reasonCode": "refund_in_progress"})), + )); + } + + let mut payment_check = query_and_validate_payment(&state, &entry) + .await + .map_err(|error| error_response(&request_context, error))?; + + if !has_matching_hold { + refresh_known_refunds(&state, &entry) + .await + .map_err(|error| error_response(&request_context, error))?; + entry = load_order(&state, &request_context, &payload.order_id).await?; + payment_check.verified = payment_trade_state_allows_additional_refund( + &payment_check.trade_state, + cumulative_success_refund_cents(&entry), + entry.order.amount_cents, + ); + validate_refund_amount(&entry, payload.refund_amount_cents) + .map_err(|error| error_response(&request_context, error))?; + } + + let stable_hold_retry = has_matching_hold + && payment_check + .trade_state + .trim() + .eq_ignore_ascii_case("REFUND"); + if !payment_check.verified && !stable_hold_retry { + let reason_code = payment_check_block_reason_code(&payment_check.trade_state); + return Err(error_response( + &request_context, + AppError::from_status(StatusCode::CONFLICT) + .with_message(payment_check_block_message(reason_code)) + .with_details(json!({"reasonCode": reason_code})), + )); + } + + if !has_matching_hold { + let prepare_input = build_runtime_profile_recharge_refund_hold_prepare_input( + entry.order.order_id.clone(), + out_refund_no.clone(), + payload.refund_amount_cents, + admin.session().subject.clone(), + refund_reason.clone(), + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let prepared_hold = state + .spacetime_client() + .prepare_profile_recharge_refund_hold(prepare_input) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + if let Err(reason_code) = validate_prepared_refund_hold_status(prepared_hold.status) { + return Err(error_response( + &request_context, + AppError::from_status(StatusCode::CONFLICT) + .with_message(refund_block_message(reason_code)) + .with_details(json!({"reasonCode": reason_code})), + )); + } + } + + let notify_url = + build_refund_notify_url(&state).map_err(|error| error_response(&request_context, error))?; + let transaction_id = entry.order.provider_transaction_id.clone().ok_or_else(|| { + error_response( + &request_context, + AppError::from_status(StatusCode::CONFLICT).with_message("充值订单缺少微信支付单号"), + ) + })?; + let provider_result = state + .wechat_pay_client() + .create_refund(WechatPayRefundRequest { + transaction_id, + out_trade_no: entry.order.order_id.clone(), + out_refund_no: out_refund_no.clone(), + reason: Some(refund_reason), + notify_url, + refund_amount_cents: payload.refund_amount_cents, + total_amount_cents: entry.order.amount_cents, + }) + .await; + + let provider_refund = match provider_result { + Ok(refund) => Some(( + refund, + RuntimeProfileRechargeRefundObservationSource::ApiRequest, + )), + Err(create_error) => match state + .wechat_pay_client() + .query_refund_by_out_refund_no(&out_refund_no) + .await + { + Ok(refund) => Some((refund, RuntimeProfileRechargeRefundObservationSource::Query)), + Err(_) => { + let entry = load_order(&state, &request_context, &entry.order.order_id).await?; + let user = load_user_summary(&state, &entry.order.user_id); + let response = AdminRechargeRefundActionResponse { + out_refund_no, + provider_status: "unknown".to_string(), + result_code: "provider_status_unknown".to_string(), + provider_status_unknown: true, + order: map_order_entry(entry, user), + }; + tracing::warn!( + error_code = create_error.diagnostic_code(), + "后台退款请求结果未知,保留钱包占用等待对账" + ); + return Ok(( + StatusCode::ACCEPTED, + json_success_body(Some(&request_context), response), + ) + .into_response()); + } + }, + }; + let (refund, observation_source) = + provider_refund.expect("provider refund must exist after matched branches"); + persist_refund_fact(&state, &refund, observation_source) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + let entry = load_order(&state, &request_context, &refund.out_trade_no).await?; + let user = load_user_summary(&state, &entry.order.user_id); + let response = build_refund_action_response(refund, entry, user); + + Ok(( + StatusCode::OK, + json_success_body(Some(&request_context), response), + ) + .into_response()) +} + +pub async fn admin_register_recharge_refund( + State(state): State, + Extension(request_context): Extension, + Extension(_admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let out_refund_no = payload.out_refund_no.trim(); + if out_refund_no.is_empty() || out_refund_no.len() > 64 { + return Err(error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("商户退款单号 out_refund_no 格式无效"), + )); + } + let refund = state + .wechat_pay_client() + .query_refund_by_out_refund_no(out_refund_no) + .await + .map_err(|error| { + let error = match error { + WechatPayError::OrderNotExist(_) if is_likely_wechat_refund_id(out_refund_no) => { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message( + "该编号疑似微信退款单号 refund_id,请在商户平台退款详情复制商户退款单号 out_refund_no", + ) + .with_details(json!({"reasonCode": "provider_refund_id_not_supported"})) + } + WechatPayError::OrderNotExist(_) => AppError::from_status(StatusCode::NOT_FOUND) + .with_message("未找到该商户退款单号 out_refund_no") + .with_details(json!({"reasonCode": "out_refund_no_not_found"})), + other => map_wechat_pay_error(other), + }; + error_response(&request_context, error) + })?; + persist_refund_fact( + &state, + &refund, + RuntimeProfileRechargeRefundObservationSource::Query, + ) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + let entry = load_order(&state, &request_context, &refund.out_trade_no).await?; + let user = load_user_summary(&state, &entry.order.user_id); + + Ok(json_success_body( + Some(&request_context), + build_refund_action_response(refund, entry, user), + )) +} + +pub async fn admin_update_wallet_restriction( + State(state): State, + Extension(request_context): Extension, + Extension(admin): Extension, + Json(payload): Json, +) -> Result, Response> { + let user = resolve_user(&state, Some(payload.user_id), None) + .map_err(|error| error_response(&request_context, error))?; + let input = build_runtime_profile_wallet_manual_restriction_upsert_input( + user.id, + payload.frozen, + payload.reason, + admin.session().subject.clone(), + ) + .map_err(|message| { + error_response( + &request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let wallet = state + .spacetime_client() + .admin_upsert_profile_wallet_manual_restriction(input) + .await + .map_err(|error| spacetime_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + AdminWalletRestrictionResponse { + wallet: map_wallet(wallet), + }, + )) +} + +async fn reconcile_and_validate_order( + state: &AppState, + request_context: &RequestContext, + order_id: String, + refund_amount_cents: u64, +) -> Result< + ( + RuntimeProfileRechargeOrderAdminEntrySnapshot, + AdminWechatPaymentCheckPayload, + ), + Response, +> { + let mut entry = load_order(state, request_context, &order_id).await?; + let mut payment_check = query_and_validate_payment(state, &entry) + .await + .map_err(|error| error_response(request_context, error))?; + let known_refunds_refreshed = refresh_known_refunds(state, &entry) + .await + .map_err(|error| error_response(request_context, error))?; + entry = load_order(state, request_context, &order_id).await?; + payment_check.verified = payment_trade_state_allows_additional_refund( + &payment_check.trade_state, + cumulative_success_refund_cents(&entry), + entry.order.amount_cents, + ); + validate_refund_amount(&entry, refund_amount_cents) + .map_err(|error| error_response(request_context, error))?; + payment_check.known_refunds_refreshed = known_refunds_refreshed; + Ok((entry, payment_check)) +} + +async fn load_order( + state: &AppState, + request_context: &RequestContext, + order_id: &str, +) -> Result { + let input = build_runtime_profile_recharge_order_admin_list_input( + Some(order_id.to_string()), + None, + None, + None, + None, + None, + None, + 2, + ) + .map_err(|message| { + error_response( + request_context, + AppError::from_status(StatusCode::BAD_REQUEST).with_message(message), + ) + })?; + let mut entries = state + .spacetime_client() + .admin_list_profile_recharge_orders(input) + .await + .map_err(|error| spacetime_error_response(request_context, error))?; + if entries.len() != 1 { + return Err(error_response( + request_context, + AppError::from_status(StatusCode::NOT_FOUND).with_message("充值订单不存在"), + )); + } + Ok(entries.remove(0)) +} + +async fn query_and_validate_payment( + state: &AppState, + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> Result { + let queried = state + .wechat_pay_client() + .query_order_by_out_trade_no(&entry.order.order_id) + .await + .map_err(map_wechat_pay_error)?; + validate_queried_payment(&entry.order, &queried)?; + let cumulative_refund_cents = cumulative_success_refund_cents(entry); + let verified = payment_trade_state_allows_additional_refund( + &queried.trade_state, + cumulative_refund_cents, + entry.order.amount_cents, + ); + tracing::debug!( + trade_state = queried.trade_state.as_str(), + cumulative_refund_cents, + order_total_cents = entry.order.amount_cents, + verified, + "后台退款微信支付订单核验完成" + ); + Ok(AdminWechatPaymentCheckPayload { + verified, + trade_state: queried.trade_state, + transaction_id: queried.transaction_id, + amount_total_cents: queried.amount_total_cents, + known_refunds_refreshed: 0, + }) +} + +fn validate_queried_payment( + order: &module_runtime::RuntimeProfileRechargeOrderSnapshot, + queried: &WechatPayNotifyOrder, +) -> Result<(), AppError> { + if queried.out_trade_no != order.order_id { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信支付查单返回了不匹配的商户订单号")); + } + if queried.amount_total_cents != Some(order.amount_cents) { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信支付查单金额与本地充值订单不一致")); + } + if let Some(expected) = order.provider_transaction_id.as_deref() + && queried.transaction_id.as_deref() != Some(expected) + { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("微信支付查单交易号与本地充值订单不一致")); + } + Ok(()) +} + +async fn refresh_known_refunds( + state: &AppState, + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> Result { + let mut out_refund_nos = entry + .refunds + .iter() + .map(|refund| refund.out_refund_no.clone()) + .collect::>(); + if let Some(hold) = entry.active_hold.as_ref() { + out_refund_nos.insert(hold.out_refund_no.clone()); + } + let mut refreshed = 0_u32; + for out_refund_no in out_refund_nos { + let refund = state + .wechat_pay_client() + .query_refund_by_out_refund_no(&out_refund_no) + .await + .map_err(map_wechat_pay_error)?; + persist_refund_fact( + state, + &refund, + RuntimeProfileRechargeRefundObservationSource::Query, + ) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message("刷新已知微信退款单失败") + .with_details(json!({"provider": "spacetimedb", "message": error.to_string()})) + })?; + refreshed = refreshed.saturating_add(1); + } + Ok(refreshed) +} + +async fn persist_refund_fact( + state: &AppState, + refund: &WechatPayRefund, + source: RuntimeProfileRechargeRefundObservationSource, +) -> Result<(), SpacetimeClientError> { + let fingerprint = refund_fact_fingerprint(refund); + let observation = build_wechat_pay_refund_observation( + format!( + "admin-refund-observation-{}", + short_hash(fingerprint.as_bytes()) + ), + source, + None, + fingerprint, + refund, + current_unix_micros(), + ) + .map_err(|error| SpacetimeClientError::Runtime(error.to_string()))?; + state + .spacetime_client() + .record_profile_recharge_refund_observation(observation) + .await?; + Ok(()) +} + +fn validate_refund_amount( + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, + refund_amount_cents: u64, +) -> Result<(), AppError> { + let reason_code = order_refund_block_reason(entry); + if let Some(reason_code) = reason_code { + return Err(AppError::from_status(StatusCode::CONFLICT) + .with_message(refund_block_message(reason_code)) + .with_details(json!({"reasonCode": reason_code}))); + } + let remaining = remaining_refundable_cents(entry); + if refund_amount_cents == 0 || refund_amount_cents > remaining { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("退款金额必须大于 0 且不超过订单剩余可退金额") + .with_details(json!({"reasonCode": "invalid_refund_amount", "remainingRefundableCents": remaining}))); + } + Ok(()) +} + +fn order_refund_block_reason( + entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> Option<&'static str> { + if entry.order.kind.as_str() != "points" { + return Some("membership_not_supported"); + } + if !is_ordinary_wechat_v3_channel(&entry.order.payment_channel) { + return Some("payment_channel_not_supported"); + } + if !matches!( + entry.order.status, + RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded + ) { + return Some("order_not_paid"); + } + if entry.order.provider_transaction_id.is_none() { + return Some("provider_transaction_missing"); + } + if entry.refunds.iter().any(|refund| { + matches!( + refund.provider_status, + RuntimeProfileRechargeRefundStatus::Processing + | RuntimeProfileRechargeRefundStatus::Abnormal + ) + }) { + return Some("refund_in_progress"); + } + if remaining_refundable_cents(entry) == 0 { + return Some("fully_refunded"); + } + if entry.active_hold.is_some() { + return Some("refund_in_progress"); + } + None +} + +fn remaining_refundable_cents(entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot) -> u64 { + entry + .order + .amount_cents + .saturating_sub(cumulative_success_refund_cents(entry)) +} + +fn cumulative_success_refund_cents(entry: &RuntimeProfileRechargeOrderAdminEntrySnapshot) -> u64 { + entry + .settlement + .as_ref() + .map(|value| value.cumulative_success_refund_cents) + .unwrap_or(0) +} + +fn payment_trade_state_allows_additional_refund( + trade_state: &str, + cumulative_success_refund_cents: u64, + order_total_cents: u64, +) -> bool { + match trade_state.trim().to_ascii_uppercase().as_str() { + "SUCCESS" => true, + "REFUND" => { + cumulative_success_refund_cents > 0 + && cumulative_success_refund_cents < order_total_cents + } + _ => false, + } +} + +fn payment_check_block_reason_code(trade_state: &str) -> &'static str { + if trade_state.trim().eq_ignore_ascii_case("REFUND") { + "wechat_refund_not_reconciled" + } else { + "wechat_order_not_paid" + } +} + +fn payment_check_block_message(reason_code: &str) -> &'static str { + match reason_code { + "wechat_refund_not_reconciled" => "微信支付订单存在尚未登记或未完成对账的退款", + _ => "微信支付订单尚未确认可退款", + } +} + +fn build_refund_action_response( + refund: WechatPayRefund, + entry: RuntimeProfileRechargeOrderAdminEntrySnapshot, + user: Option, +) -> AdminRechargeRefundActionResponse { + let result_code = if refund.status.eq_ignore_ascii_case("SUCCESS") { + match entry.settlement.as_ref().map(|value| value.recovery_status) { + Some(RuntimeProfileRechargeRefundRecoveryStatus::Shortfall) => "refund_debt", + Some(RuntimeProfileRechargeRefundRecoveryStatus::ManualReview) => "manual_review", + Some(RuntimeProfileRechargeRefundRecoveryStatus::Applied) => "reconciled", + _ => "pending_reconciliation", + } + } else if refund.status.eq_ignore_ascii_case("PROCESSING") { + "processing" + } else if refund.status.eq_ignore_ascii_case("CLOSED") { + "closed" + } else { + "abnormal" + }; + AdminRechargeRefundActionResponse { + out_refund_no: refund.out_refund_no, + provider_status: refund.status.to_ascii_lowercase(), + result_code: result_code.to_string(), + provider_status_unknown: false, + order: map_order_entry(entry, user), + } +} + +fn map_order_entry( + entry: RuntimeProfileRechargeOrderAdminEntrySnapshot, + user: Option, +) -> AdminRechargeOrderEntryPayload { + let settlement = entry.settlement.as_ref(); + let remaining_refundable_cents = remaining_refundable_cents(&entry); + let block_reason = order_refund_block_reason(&entry).map(str::to_string); + AdminRechargeOrderEntryPayload { + order_id: entry.order.order_id, + user_id: entry.order.user_id, + user, + product_id: entry.order.product_id, + product_title: entry.order.product_title, + product_kind: entry.order.kind.as_str().to_string(), + amount_cents: entry.order.amount_cents, + status: entry.order.status.as_str().to_string(), + payment_channel: entry.order.payment_channel, + paid_at_micros: entry.order.paid_at_micros, + provider_transaction_id: entry.order.provider_transaction_id, + created_at_micros: entry.order.created_at_micros, + points_delta: entry.order.points_delta, + cumulative_success_refund_cents: settlement + .map(|value| value.cumulative_success_refund_cents) + .unwrap_or(0), + target_recovery_points: settlement + .map(|value| value.target_recovery_points) + .unwrap_or(0), + recovered_points: settlement.map(|value| value.recovered_points).unwrap_or(0), + unrecovered_points: settlement + .map(|value| value.unrecovered_points) + .unwrap_or(0), + recovery_status: settlement.map(|value| value.recovery_status.as_str().to_string()), + wallet: map_wallet(entry.wallet), + refunds: entry.refunds.into_iter().map(map_refund).collect(), + active_hold: entry.active_hold.map(map_hold), + remaining_refundable_cents, + refund_eligible: block_reason.is_none(), + refund_block_reason_code: block_reason, + } +} + +fn map_wallet(wallet: RuntimeProfileAdminWalletSnapshot) -> AdminProfileWalletPayload { + AdminProfileWalletPayload { + user_id: wallet.user_id, + total_balance: wallet.total_balance, + spendable_balance: wallet.spendable_balance, + daily_free_points: wallet.daily_free_points, + membership_limited_points: wallet.membership_limited_points, + permanent_points: wallet.permanent_points, + held_points: wallet.held_points, + refund_debt_points: wallet.refund_debt_points, + manual_frozen: wallet.manual_frozen, + refund_debt_frozen: wallet.refund_debt_frozen, + wallet_frozen: wallet.wallet_frozen, + manual_restriction: wallet.manual_restriction.map(|value| { + AdminWalletManualRestrictionPayload { + frozen: value.frozen, + reason: value.reason, + created_by_admin_user_id: value.created_by_admin_user_id, + created_at_micros: value.created_at_micros, + updated_by_admin_user_id: value.updated_by_admin_user_id, + updated_at_micros: value.updated_at_micros, + } + }), + } +} + +fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefundPayload { + AdminRechargeRefundPayload { + out_refund_no: refund.out_refund_no, + provider_refund_id: refund.provider_refund_id, + provider_status: refund.provider_status.as_str().to_string(), + refund_cents: refund.refund_cents, + payer_refund_cents: refund.payer_refund_cents, + success_at_micros: refund.success_at_micros, + first_observed_at_micros: refund.first_observed_at_micros, + updated_at_micros: refund.updated_at_micros, + observation_source: refund.last_observation_source.as_str().to_string(), + target_recovery_points: refund.target_recovery_points, + recovered_points: refund.recovered_points, + unrecovered_points: refund.unrecovered_points, + recovery_status: refund.recovery_status.as_str().to_string(), + last_error_code: refund.last_error_code, + } +} + +fn map_hold(hold: RuntimeProfileRechargeRefundHoldSnapshot) -> AdminRechargeRefundHoldPayload { + AdminRechargeRefundHoldPayload { + out_refund_no: hold.out_refund_no, + refund_cents: hold.refund_cents, + held_points: hold.held_points, + status: hold.status.as_str().to_string(), + admin_user_id: hold.admin_user_id, + reason: hold.reason, + created_at_micros: hold.created_at_micros, + updated_at_micros: hold.updated_at_micros, + } +} + +fn resolve_optional_user_id( + state: &AppState, + user_id: Option, + public_user_code: Option, +) -> Result, AppError> { + if user_id.as_deref().is_none_or(str::is_empty) + && public_user_code.as_deref().is_none_or(str::is_empty) + { + return Ok(None); + } + resolve_user(state, user_id, public_user_code).map(|user| Some(user.id)) +} + +fn resolve_user( + state: &AppState, + user_id: Option, + public_user_code: Option, +) -> Result { + let user_id = user_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + let public_user_code = public_user_code + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + if user_id.is_some() == public_user_code.is_some() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("userId 与 publicUserCode 必须且只能提供一个")); + } + let user = if let Some(user_id) = user_id { + state.auth_user_service().get_user_by_id(user_id) + } else { + state + .auth_user_service() + .get_user_by_public_user_code(public_user_code.expect("checked above")) + } + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取用户认证信息失败:{error}")) + })?; + user.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND).with_message("用户不存在")) +} + +fn load_user_summaries( + state: &AppState, + entries: &[RuntimeProfileRechargeOrderAdminEntrySnapshot], +) -> BTreeMap { + entries + .iter() + .map(|entry| entry.order.user_id.clone()) + .collect::>() + .into_iter() + .filter_map(|user_id| load_user_summary(state, &user_id).map(|user| (user_id, user))) + .collect() +} + +fn load_user_summary(state: &AppState, user_id: &str) -> Option { + state + .auth_user_service() + .get_user_by_id(user_id) + .ok() + .flatten() + .as_ref() + .map(map_user_summary) +} + +fn map_user_summary(user: &AuthUser) -> AdminUserSummaryPayload { + AdminUserSummaryPayload { + user_id: user.id.clone(), + public_user_code: user.public_user_code.clone(), + display_name: user.display_name.clone(), + avatar_url: user.avatar_url.clone(), + } +} + +fn parse_order_status( + value: Option<&str>, +) -> Result, AppError> { + let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let status = match value.to_ascii_lowercase().as_str() { + "pending" => RuntimeProfileRechargeOrderStatus::Pending, + "paid" => RuntimeProfileRechargeOrderStatus::Paid, + "failed" => RuntimeProfileRechargeOrderStatus::Failed, + "closed" => RuntimeProfileRechargeOrderStatus::Closed, + "refunded" => RuntimeProfileRechargeOrderStatus::Refunded, + "expired" => RuntimeProfileRechargeOrderStatus::Expired, + _ => { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("充值订单状态筛选值无效")); + } + }; + Ok(Some(status)) +} + +fn parse_optional_time_micros(value: Option<&str>, field: &str) -> Result, AppError> { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| { + shared_kernel::parse_rfc3339(value) + .map(|time| i64::try_from(time.unix_timestamp_nanos() / 1_000).unwrap_or(i64::MAX)) + .map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("{field} 必须是 RFC3339 时间")) + }) + }) + .transpose() +} + +fn normalize_request_id(value: &str) -> Result<&str, AppError> { + let value = value.trim(); + if value.len() < 8 || value.len() > 128 || !value.is_ascii() { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("requestId 必须是 8 至 128 字节的 ASCII 字符串")); + } + Ok(value) +} + +fn normalize_refund_reason(value: Option<&str>) -> String { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("管理员发起充值退款") + .chars() + .take(80) + .collect() +} + +fn is_likely_wechat_refund_id(value: &str) -> bool { + value.len() == 29 && value.starts_with("50") && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn build_out_refund_no(order_id: &str, request_id: &str) -> String { + let digest = Sha256::digest(format!("admin-refund\n{order_id}\n{request_id}").as_bytes()); + format!("gar{}", hex::encode(&digest[..16])) +} + +fn validate_prepared_refund_hold_status( + status: RuntimeProfileRechargeRefundHoldStatus, +) -> Result<(), &'static str> { + if status == RuntimeProfileRechargeRefundHoldStatus::Released { + return Err("refund_hold_released"); + } + Ok(()) +} + +fn build_refund_notify_url(state: &AppState) -> Result { + let configured = state + .config + .wechat_pay_notify_url + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("微信支付通知地址未配置") + })?; + let base = configured + .strip_suffix(WECHAT_PAYMENT_NOTIFY_PATH) + .or_else(|| configured.strip_suffix('/')) + .unwrap_or(configured); + Ok(format!("{base}{WECHAT_REFUND_NOTIFY_PATH}")) +} + +fn refund_fact_fingerprint(refund: &WechatPayRefund) -> String { + let payload = format!( + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{:?}\n{:?}", + refund.out_refund_no, + refund.refund_id, + refund.out_trade_no, + refund.transaction_id, + refund.status, + refund.amount_total_cents, + refund.amount_refund_cents, + refund.amount_payer_total_cents, + refund.amount_payer_refund_cents, + refund.success_time, + refund.create_time, + ); + format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) +} + +fn short_hash(value: &[u8]) -> String { + hex::encode(&Sha256::digest(value)[..8]) +} + +fn is_ordinary_wechat_v3_channel(value: &str) -> bool { + matches!( + value, + "wechat_mini_program" | "wechat_jsapi" | "wechat_h5" | "wechat_native" + ) +} + +fn classify_refund_block_reason(message: &str) -> &'static str { + if message.contains("永久泥点不足") || message.contains("可用") { + "insufficient_permanent_points" + } else if message.contains("处理中") + || message.contains("占用") + || message.contains("未完成退款") + || message.contains("对账") + { + "refund_in_progress" + } else if message.contains("会员") { + "membership_not_supported" + } else if message.contains("渠道") { + "payment_channel_not_supported" + } else if message.contains("已全额退款") || message.contains("剩余") { + "fully_refunded" + } else { + "refund_precondition_failed" + } +} + +fn refund_block_message(reason_code: &str) -> &'static str { + match reason_code { + "membership_not_supported" => "首期不支持会员订单退款", + "payment_channel_not_supported" => "首期只支持普通微信支付 V3 泥点充值退款", + "order_not_paid" => "只有已支付的充值订单可以退款", + "provider_transaction_missing" => "充值订单缺少微信支付单号", + "fully_refunded" => "该充值订单已无剩余可退金额", + "refund_in_progress" => "该充值订单已有退款处理中", + "refund_hold_released" => "该退款占用已释放,请使用新的 requestId 重新预检后发起退款", + _ => "充值订单当前不可退款", + } +} + +fn spacetime_error_response( + request_context: &RequestContext, + error: SpacetimeClientError, +) -> Response { + let message = error.to_string(); + let status = if matches!(error, SpacetimeClientError::Runtime(_)) { + StatusCode::BAD_REQUEST + } else if matches!(error, SpacetimeClientError::Procedure(_)) { + StatusCode::CONFLICT + } else { + StatusCode::BAD_GATEWAY + }; + error_response( + request_context, + AppError::from_status(status) + .with_message(message.clone()) + .with_details(json!({"provider": "spacetimedb", "message": message})), + ) +} + +fn error_response(request_context: &RequestContext, error: AppError) -> Response { + error.into_response_with_context(Some(request_context)) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{Request, StatusCode}, + }; + use module_runtime::RuntimeProfileRechargeRefundHoldStatus; + use tower::ServiceExt; + + use super::{ + build_out_refund_no, classify_refund_block_reason, is_likely_wechat_refund_id, + normalize_request_id, payment_check_block_reason_code, + payment_trade_state_allows_additional_refund, refund_block_message, + validate_prepared_refund_hold_status, + }; + use crate::{app::build_router, config::AppConfig, state::AppState}; + + #[test] + fn stable_request_id_builds_stable_wechat_refund_number() { + let first = build_out_refund_no("order-1", "request-12345678"); + let second = build_out_refund_no("order-1", "request-12345678"); + assert_eq!(first, second); + assert!(first.len() <= 64); + assert_ne!(first, build_out_refund_no("order-1", "request-87654321")); + } + + #[test] + fn request_id_requires_a_bounded_ascii_value() { + assert!(normalize_request_id("request-123").is_ok()); + assert!(normalize_request_id("短请求编号").is_err()); + assert!(normalize_request_id("short").is_err()); + } + + #[test] + fn provider_refund_id_shape_is_only_a_post_query_hint() { + assert!(is_likely_wechat_refund_id("50000000000000000000000000000")); + assert!(!is_likely_wechat_refund_id("merchant-refund-test-001")); + assert!(!is_likely_wechat_refund_id("refund-1001")); + } + + #[test] + fn reconciled_partial_refund_keeps_the_remaining_amount_refundable() { + assert!(payment_trade_state_allows_additional_refund( + "SUCCESS", 0, 600 + )); + assert!(!payment_trade_state_allows_additional_refund( + "REFUND", 0, 600 + )); + assert!(payment_trade_state_allows_additional_refund( + "REFUND", 300, 600 + )); + assert!(!payment_trade_state_allows_additional_refund( + "REFUND", 600, 600 + )); + assert_eq!( + payment_check_block_reason_code("REFUND"), + "wechat_refund_not_reconciled" + ); + } + + #[test] + fn preview_failure_exposes_stable_reason_code() { + assert_eq!( + classify_refund_block_reason("永久泥点不足,无法占用"), + "insufficient_permanent_points" + ); + assert_eq!( + classify_refund_block_reason("充值订单存在未完成退款,需先完成对账"), + "refund_in_progress" + ); + } + + #[test] + fn released_refund_hold_cannot_reenter_provider_call() { + assert_eq!( + validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Released), + Err("refund_hold_released") + ); + assert_eq!( + refund_block_message("refund_hold_released"), + "该退款占用已释放,请使用新的 requestId 重新预检后发起退款" + ); + assert!( + validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Active) + .is_ok() + ); + assert!( + validate_prepared_refund_hold_status(RuntimeProfileRechargeRefundHoldStatus::Settled) + .is_ok(), + "settled hold replay must keep using the original provider refund number" + ); + } + + #[tokio::test] + async fn recharge_management_routes_require_admin_authentication() { + let app = build_router( + AppState::new(AppConfig { + admin_username: Some("root".to_string()), + admin_password: Some("secret123".to_string()), + ..AppConfig::default() + }) + .expect("state should build"), + ); + let cases = [ + ("GET", "/admin/api/profile/recharge-orders", None), + ("GET", "/admin/api/profile/users/detail?userId=user-1", None), + ( + "POST", + "/admin/api/profile/recharge-refunds/preview", + Some(r#"{"orderId":"order-1","refundAmountCents":100}"#), + ), + ( + "POST", + "/admin/api/profile/recharge-refunds/execute", + Some( + r#"{"orderId":"order-1","refundAmountCents":100,"requestId":"request-12345678"}"#, + ), + ), + ( + "POST", + "/admin/api/profile/recharge-refunds/register", + Some(r#"{"outRefundNo":"refund-1"}"#), + ), + ( + "POST", + "/admin/api/profile/wallet-restriction", + Some(r#"{"userId":"user-1","frozen":true,"reason":"人工复核"}"#), + ), + ]; + + for (method, uri, body) in cases { + let mut request = Request::builder().method(method).uri(uri); + if body.is_some() { + request = request.header("content-type", "application/json"); + } + let response = app + .clone() + .oneshot( + request + .body(body.map(Body::from).unwrap_or_else(Body::empty)) + .expect("request should build"), + ) + .await + .expect("request should succeed"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{uri}"); + } + } +} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 68b9ced6e..976c67481 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -29,8 +29,8 @@ use crate::{ telemetry::record_http_observability, tracking::record_route_tracking_event_after_success, wechat::pay::{ - handle_wechat_pay_notify, handle_wechat_virtual_payment_message_push_verify, - handle_wechat_virtual_payment_notify, + handle_wechat_pay_notify, handle_wechat_pay_refund_notify, + handle_wechat_virtual_payment_message_push_verify, handle_wechat_virtual_payment_notify, }, }; @@ -53,6 +53,10 @@ pub fn build_router(state: AppState) -> Router { "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), ) + .route( + "/api/profile/recharge/wechat/refund-notify", + post(handle_wechat_pay_refund_notify), + ) .route( "/api/profile/recharge/wechat/virtual-notify", get(handle_wechat_virtual_payment_message_push_verify) diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 62421d40a..0631b8e5f 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -141,6 +141,7 @@ pub struct AppConfig { pub wechat_pay_platform_serial_no: Option, pub wechat_pay_api_v3_key: Option, pub wechat_pay_notify_url: Option, + pub wechat_pay_refund_reconciliation_enabled: bool, pub wechat_pay_jsapi_endpoint: String, pub wechat_mini_program_virtual_payment_offer_id: Option, pub wechat_mini_program_virtual_payment_app_key: Option, @@ -397,6 +398,7 @@ impl Default for AppConfig { wechat_pay_platform_serial_no: None, wechat_pay_api_v3_key: None, wechat_pay_notify_url: None, + wechat_pay_refund_reconciliation_enabled: false, wechat_pay_jsapi_endpoint: "https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi" .to_string(), wechat_mini_program_virtual_payment_offer_id: None, @@ -936,6 +938,9 @@ impl AppConfig { read_first_non_empty_env(&["WECHAT_PAY_PLATFORM_SERIAL_NO"]); config.wechat_pay_api_v3_key = read_first_non_empty_env(&["WECHAT_PAY_API_V3_KEY"]); config.wechat_pay_notify_url = read_first_non_empty_env(&["WECHAT_PAY_NOTIFY_URL"]); + if let Some(enabled) = read_first_bool_env(&["WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"]) { + config.wechat_pay_refund_reconciliation_enabled = enabled; + } if let Some(wechat_pay_jsapi_endpoint) = read_first_non_empty_env(&["WECHAT_PAY_JSAPI_ENDPOINT"]) { @@ -2096,6 +2101,7 @@ mod tests { std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_API_V3_KEY"); std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); + std::env::remove_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); @@ -2124,6 +2130,7 @@ mod tests { "WECHAT_PAY_NOTIFY_URL", "https://api.example.com/api/profile/recharge/wechat/notify", ); + std::env::set_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED", "true"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID", "offer-001"); std::env::set_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY", "app-key-001"); std::env::set_var( @@ -2164,6 +2171,7 @@ mod tests { config.wechat_pay_notify_url.as_deref(), Some("https://api.example.com/api/profile/recharge/wechat/notify") ); + assert!(config.wechat_pay_refund_reconciliation_enabled); assert_eq!( config.wechat_pay_platform_public_key_path.as_deref(), Some(std::path::Path::new("certs/wechatpay_platform.pem")) @@ -2228,6 +2236,7 @@ mod tests { std::env::remove_var("WECHAT_PAY_PLATFORM_SERIAL_NO"); std::env::remove_var("WECHAT_PAY_API_V3_KEY"); std::env::remove_var("WECHAT_PAY_NOTIFY_URL"); + std::env::remove_var("WECHAT_PAY_REFUND_RECONCILIATION_ENABLED"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY"); std::env::remove_var("WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY"); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 725704d2c..28e32d58b 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -1,6 +1,7 @@ #![recursion_limit = "256"] mod admin; +mod admin_recharge; mod ai_generation_drafts; mod aliyun_matting; mod ai_tasks; @@ -78,6 +79,7 @@ mod platform_errors; mod process_metrics; mod profile_identity; mod profile_recharge_expiration_listener; +mod profile_recharge_refund_reconciliation; mod prompt; mod public_work; mod puzzle; @@ -135,6 +137,7 @@ use crate::{ external_generation_worker::run_external_generation_worker, external_generation_worker_controller::run_external_generation_worker_controller, profile_recharge_expiration_listener::spawn_profile_recharge_expiration_listener, + profile_recharge_refund_reconciliation::spawn_profile_recharge_refund_reconciliation_worker, state::{AppState, AppStateInitError}, tracking_outbox::TrackingOutbox, wallet_refund_outbox::WalletRefundOutbox, @@ -435,6 +438,7 @@ fn spawn_http_app_state_background_workers(state: &AppState, process_role: Proce spawn_common_app_state_background_workers(state); if should_start_profile_recharge_expiration_listener(process_role) { spawn_profile_recharge_expiration_listener(state.clone()); + spawn_profile_recharge_refund_reconciliation_worker(state.clone()); } } diff --git a/server-rs/crates/api-server/src/modules/admin.rs b/server-rs/crates/api-server/src/modules/admin.rs index a22f0c916..771165456 100644 --- a/server-rs/crates/api-server/src/modules/admin.rs +++ b/server-rs/crates/api-server/src/modules/admin.rs @@ -18,6 +18,11 @@ use crate::{ admin_upsert_feature_gate_config, admin_upsert_public_work_interaction_config, require_admin_auth, }, + admin_recharge::{ + admin_execute_recharge_refund, admin_get_user_detail, admin_list_recharge_orders, + admin_preview_recharge_refund, admin_register_recharge_refund, + admin_update_wallet_restriction, + }, runtime_profile::{ admin_disable_profile_redeem_code, admin_disable_profile_task_config, admin_get_profile_wallet_config, admin_list_profile_invite_codes, @@ -237,6 +242,49 @@ pub fn router(state: AppState) -> Router { "/admin/api/profile/recharge-products", get(admin_list_profile_recharge_products) .post(admin_upsert_profile_recharge_product) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/recharge-orders", + get(admin_list_recharge_orders).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/recharge-refunds/preview", + post(admin_preview_recharge_refund).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/recharge-refunds/execute", + post(admin_execute_recharge_refund).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/recharge-refunds/register", + post(admin_register_recharge_refund).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/users/detail", + get(admin_get_user_detail).route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_auth, + )), + ) + .route( + "/admin/api/profile/wallet-restriction", + post(admin_update_wallet_restriction) .route_layer(middleware::from_fn_with_state(state, require_admin_auth)), ) } diff --git a/server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs b/server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs new file mode 100644 index 000000000..e27eabf8b --- /dev/null +++ b/server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs @@ -0,0 +1,953 @@ +use std::{collections::BTreeMap, time::Duration}; + +use module_runtime::{ + RuntimeProfileRechargeOrderRefundSettlementSnapshot, + RuntimeProfileRechargeRefundObservationSource, RuntimeProfileRechargeRefundRecoveryStatus, + RuntimeProfileRechargeRefundSnapshot, RuntimeProfileRechargeRefundStatus, + build_runtime_profile_recharge_refund_hold_list_input, + build_runtime_profile_recharge_refund_hold_release_input, +}; +use platform_wechat::pay::{ + WechatPayError, WechatPayRefund, WechatPayTradeBillDownload, WechatPayTradeBillRefundRow, +}; +use sha2::{Digest, Sha256}; +use shared_kernel::offset_datetime_to_unix_micros; +use time::{Date, OffsetDateTime, UtcOffset}; +use tokio::time::sleep; +use tracing::{debug, info, warn}; + +use crate::{ + state::AppState, + wechat::pay::{build_wechat_pay_refund_observation, current_unix_micros}, +}; + +const REFUND_RECONCILIATION_LOOP_INTERVAL: Duration = Duration::from_secs(60); +const REFUND_RECONCILIATION_BATCH_SIZE: u32 = 100; +const REFUND_HOLD_MIN_RELEASE_AGE: Duration = Duration::from_secs(10 * 60); +const REFUND_HOLD_NOT_FOUND_RELEASE_THRESHOLD: u8 = 3; +const REFUND_HOLD_RECONCILIATION_ACTOR: &str = "system:refund-reconciliation"; +// 微信交易账单 API 最多支持最近三个月;按 90 天轮转覆盖完整可查询窗口。 +const REFUND_TRADE_BILL_LOOKBACK_DAYS: i64 = 90; +const REFUND_TRADE_BILL_AVAILABLE_HOUR: u8 = 10; +const REFUND_TRADE_BILL_SCAN_SLOT_COUNT: u8 = 30; +const REFUND_TRADE_BILL_NO_STATEMENT_STABLE_AGE_DAYS: i64 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefundTradeBillCompletionDecision { + Checkpoint, + Retry, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RefundTradeBillDateOutcome { + Completed, + RetryableNoStatement, +} + +#[derive(Clone, Debug)] +pub(crate) struct PersistedWechatRefundObservation { + pub refund: RuntimeProfileRechargeRefundSnapshot, + pub settlement: Option, + pub duplicate: bool, + pub resolution_code: String, +} + +pub fn spawn_profile_recharge_refund_reconciliation_worker(state: AppState) { + if !state.wechat_pay_refund_reconciliation_enabled() { + debug!("wechat pay refund reconciliation worker is disabled"); + return; + } + if !state.wechat_pay_client().supports_refund_reconciliation() { + warn!("wechat pay refund reconciliation requires the real provider"); + return; + } + + tokio::spawn(async move { + let mut trade_bill_scan_slot = 0; + let mut hold_not_found_counts = BTreeMap::::new(); + loop { + if let Err(error) = reconcile_profile_recharge_refunds_once(&state).await { + warn!( + failure_reason = classify_active_refund_reconciliation_failure(&error), + "wechat pay refund active reconciliation failed" + ); + } + if let Err(error) = + reconcile_profile_recharge_refund_holds_once(&state, &mut hold_not_found_counts) + .await + { + warn!( + failure_reason = classify_active_refund_reconciliation_failure(&error), + "wechat pay refund hold reconciliation failed" + ); + } + if let Err(error) = + reconcile_profile_recharge_refund_trade_bill_once(&state, trade_bill_scan_slot) + .await + { + warn!( + failure_reason = classify_refund_trade_bill_failure(&error), + "wechat pay refund trade-bill reconciliation failed" + ); + } + trade_bill_scan_slot = (trade_bill_scan_slot + 1) % REFUND_TRADE_BILL_SCAN_SLOT_COUNT; + sleep(REFUND_RECONCILIATION_LOOP_INTERVAL).await; + } + }); +} + +async fn reconcile_profile_recharge_refund_holds_once( + state: &AppState, + not_found_counts: &mut BTreeMap, +) -> Result<(), String> { + let holds = state + .spacetime_client() + .list_profile_recharge_refund_holds_for_reconciliation( + build_runtime_profile_recharge_refund_hold_list_input(REFUND_RECONCILIATION_BATCH_SIZE), + ) + .await + .map_err(|error| error.to_string())?; + let now_micros = current_unix_micros(); + + for hold in holds { + if !refund_hold_query_is_due(hold.created_at_micros, now_micros) { + continue; + } + match state + .wechat_pay_client() + .query_refund_by_out_refund_no(&hold.out_refund_no) + .await + { + Ok(queried) => { + not_found_counts.remove(&hold.out_refund_no); + let persisted = persist_wechat_refund_observation( + state, + &queried, + RuntimeProfileRechargeRefundObservationSource::Query, + query_observation_id(&queried, now_micros), + None, + refund_fact_fingerprint(&queried), + now_micros, + ) + .await; + match persisted { + Ok(persisted) => log_persisted_refund("active_hold_query", &persisted), + Err(error) => warn!( + refund_ref = refund_log_ref(&hold.out_refund_no), + failure_reason = classify_active_refund_reconciliation_failure(&error), + "wechat pay refund hold query persistence failed" + ), + } + } + Err(WechatPayError::OrderNotExist(_)) => { + let count = not_found_counts + .entry(hold.out_refund_no.clone()) + .and_modify(|value| *value = value.saturating_add(1)) + .or_insert(1); + if refund_hold_should_release(hold.created_at_micros, now_micros, *count) { + let release_input = build_runtime_profile_recharge_refund_hold_release_input( + hold.out_refund_no.clone(), + REFUND_HOLD_RECONCILIATION_ACTOR.to_string(), + "provider_order_not_found_after_repeated_queries".to_string(), + ) + .map_err(|error| error.to_string())?; + match state + .spacetime_client() + .release_profile_recharge_refund_hold(release_input) + .await + { + Ok(_) => { + not_found_counts.remove(&hold.out_refund_no); + info!( + refund_ref = refund_log_ref(&hold.out_refund_no), + "wechat pay refund hold released after repeated verified absence" + ); + } + Err(error) => warn!( + refund_ref = refund_log_ref(&hold.out_refund_no), + failure_reason = + classify_active_refund_reconciliation_failure(&error.to_string()), + "wechat pay refund hold release failed" + ), + } + } + } + Err(error) => { + not_found_counts.remove(&hold.out_refund_no); + warn!( + refund_ref = refund_log_ref(&hold.out_refund_no), + failure_reason = classify_wechat_refund_query_error(&error), + "wechat pay refund hold query failed" + ); + } + } + } + Ok(()) +} + +fn refund_hold_query_is_due(created_at_micros: i64, now_micros: i64) -> bool { + now_micros.saturating_sub(created_at_micros) + >= i64::try_from(REFUND_HOLD_MIN_RELEASE_AGE.as_micros()).unwrap_or(i64::MAX) +} + +fn refund_hold_should_release( + created_at_micros: i64, + now_micros: i64, + consecutive_not_found_count: u8, +) -> bool { + refund_hold_query_is_due(created_at_micros, now_micros) + && consecutive_not_found_count >= REFUND_HOLD_NOT_FOUND_RELEASE_THRESHOLD +} + +fn classify_wechat_refund_query_error(error: &WechatPayError) -> &'static str { + match error { + WechatPayError::Disabled | WechatPayError::InvalidConfig(_) => "provider_config_invalid", + WechatPayError::InvalidSignature(_) => "provider_verification_failed", + WechatPayError::Deserialize(_) | WechatPayError::InvalidRequest(_) => { + "provider_contract_invalid" + } + WechatPayError::OrderNotExist(_) => "provider_order_not_found", + WechatPayError::RequestFailed(_) + | WechatPayError::Upstream(_) + | WechatPayError::Crypto(_) => "provider_request_failed", + } +} + +pub(crate) async fn persist_wechat_refund_observation( + state: &AppState, + refund: &WechatPayRefund, + source: RuntimeProfileRechargeRefundObservationSource, + observation_id: String, + notification_ref: Option, + payload_fingerprint: String, + observed_at_micros: i64, +) -> Result { + let input = build_wechat_pay_refund_observation( + observation_id, + source, + notification_ref, + payload_fingerprint, + refund, + observed_at_micros, + ) + .map_err(|error| error.to_string())?; + let (refund, settlement, duplicate, resolution_code) = state + .spacetime_client() + .record_profile_recharge_refund_observation(input) + .await + .map_err(|error| error.to_string())?; + Ok(PersistedWechatRefundObservation { + refund, + settlement, + duplicate, + resolution_code, + }) +} + +async fn reconcile_profile_recharge_refunds_once(state: &AppState) -> Result<(), String> { + let refunds = state + .spacetime_client() + .list_profile_recharge_refunds_for_reconciliation(REFUND_RECONCILIATION_BATCH_SIZE) + .await + .map_err(|error| error.to_string())?; + let now_micros = current_unix_micros(); + for refund in refunds { + if let Err(error) = reconcile_profile_recharge_refund_once(state, &refund, now_micros).await + { + warn!( + refund_ref = refund_log_ref(&refund.provider_refund_id), + order_ref = refund_log_ref(&refund.order_id), + failure_reason = classify_active_refund_reconciliation_failure(&error), + "wechat pay refund reconciliation item failed" + ); + } + } + Ok(()) +} + +async fn reconcile_profile_recharge_refund_once( + state: &AppState, + refund: &RuntimeProfileRechargeRefundSnapshot, + now_micros: i64, +) -> Result<(), String> { + if refund.provider_status == RuntimeProfileRechargeRefundStatus::Success { + if matches!( + refund.recovery_status, + RuntimeProfileRechargeRefundRecoveryStatus::Pending + | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall + ) { + retry_local_refund_recovery(state, refund, now_micros).await?; + } else if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview + && refund_manual_review_is_retryable(refund) + { + retry_local_refund_recovery(state, refund, now_micros).await?; + } else if refund.recovery_status == RuntimeProfileRechargeRefundRecoveryStatus::ManualReview + { + warn!( + refund_ref = refund_log_ref(&refund.provider_refund_id), + order_ref = refund_log_ref(&refund.order_id), + error_code = refund.last_error_code.as_deref().unwrap_or("manual_review"), + "wechat pay refund requires manual entitlement review" + ); + } + return Ok(()); + } + if !refund_query_is_due(refund, now_micros) { + return Ok(()); + } + let queried = state + .wechat_pay_client() + .query_refund_by_out_refund_no(&refund.out_refund_no) + .await + .map_err(|error| error.to_string())?; + let observation_id = query_observation_id(&queried, now_micros); + let persisted = persist_wechat_refund_observation( + state, + &queried, + RuntimeProfileRechargeRefundObservationSource::Query, + observation_id, + None, + refund_fact_fingerprint(&queried), + now_micros, + ) + .await?; + log_persisted_refund("active_query", &persisted); + Ok(()) +} + +async fn retry_local_refund_recovery( + state: &AppState, + refund: &RuntimeProfileRechargeRefundSnapshot, + observed_at_micros: i64, +) -> Result<(), String> { + let fact = WechatPayRefund { + mch_id: None, + transaction_id: refund.provider_transaction_id.clone(), + out_trade_no: refund.order_id.clone(), + refund_id: refund.provider_refund_id.clone(), + out_refund_no: refund.out_refund_no.clone(), + status: "SUCCESS".to_string(), + success_time: refund + .success_at_micros + .map(micros_to_rfc3339) + .transpose()?, + create_time: None, + amount_total_cents: refund.total_cents, + amount_refund_cents: refund.refund_cents, + amount_payer_total_cents: refund.payer_total_cents, + amount_payer_refund_cents: refund.payer_refund_cents, + }; + let persisted = persist_wechat_refund_observation( + state, + &fact, + RuntimeProfileRechargeRefundObservationSource::Query, + format!( + "local-recovery:{}:{}", + refund.out_refund_no, refund.recovered_points + ), + None, + refund_fact_fingerprint(&fact), + observed_at_micros, + ) + .await?; + log_persisted_refund("local_recovery", &persisted); + Ok(()) +} + +async fn reconcile_profile_recharge_refund_trade_bill_once( + state: &AppState, + scan_slot: u8, +) -> Result<(), String> { + let now = OffsetDateTime::now_utc(); + let beijing = now.to_offset( + UtcOffset::from_hms(8, 0, 0).map_err(|error| format!("beijing offset: {error}"))?, + ); + let bill_dates = refund_trade_bill_dates_for_scan_slot(beijing, scan_slot); + let mut failed_date_count = 0_u32; + for bill_date in bill_dates { + let bill_date_text = format_date(bill_date); + let checkpoint_id = format!("wechat-refund-trade-bill:{bill_date_text}"); + let checkpoint = match state + .spacetime_client() + .get_profile_recharge_refund_bill_checkpoint(checkpoint_id.clone()) + .await + { + Ok(checkpoint) => checkpoint, + Err(_) => { + failed_date_count = failed_date_count.saturating_add(1); + warn!( + bill_date = bill_date_text, + failure_reason = "checkpoint_read_failed", + "wechat pay refund trade-bill date remains retryable" + ); + continue; + } + }; + if checkpoint.is_some() { + continue; + } + if let Err(error) = + reconcile_refund_trade_bill_date(state, checkpoint_id, bill_date, now, beijing).await + { + failed_date_count = failed_date_count.saturating_add(1); + warn!( + bill_date = bill_date_text, + failure_reason = classify_refund_trade_bill_failure(&error), + "wechat pay refund trade-bill date remains retryable" + ); + } + } + if failed_date_count > 0 { + return Err(format!( + "wechat pay refund trade-bill reconciliation left {failed_date_count} date(s) retryable" + )); + } + Ok(()) +} + +async fn reconcile_refund_trade_bill_date( + state: &AppState, + checkpoint_id: String, + bill_date: Date, + now: OffsetDateTime, + beijing_now: OffsetDateTime, +) -> Result { + let bill_date_text = format_date(bill_date); + let download = match state + .wechat_pay_client() + .request_refund_trade_bill(&bill_date_text) + .await + { + Ok(download) => download, + Err(error) if error.to_string().contains("NO_STATEMENT_EXIST") => { + if !no_statement_is_stable_for_checkpoint(bill_date, beijing_now) { + debug!( + bill_date = bill_date_text, + "wechat pay refund trade bill is not available yet; leaving date retryable" + ); + return Ok(RefundTradeBillDateOutcome::RetryableNoStatement); + } + state + .spacetime_client() + .advance_profile_recharge_refund_bill_checkpoint( + checkpoint_id, + bill_date_text, + "NO_STATEMENT_EXIST".to_string(), + 0, + offset_datetime_to_unix_micros(now), + ) + .await + .map_err(|error| error.to_string())?; + return Ok(RefundTradeBillDateOutcome::Completed); + } + Err(error) => return Err(error.to_string()), + }; + let rows = state + .wechat_pay_client() + .download_refund_trade_bill(&download) + .await + .map_err(|error| error.to_string())?; + let mut failed_row_count = 0_usize; + for row in &rows { + if let Err(error) = + reconcile_trade_bill_refund_row(state, &bill_date_text, &download, row).await + { + failed_row_count = failed_row_count.saturating_add(1); + warn!( + bill_date = bill_date_text, + refund_ref = refund_log_ref(&row.refund_id), + failure_reason = classify_refund_trade_bill_failure(&error), + "wechat pay refund trade-bill row remains retryable" + ); + } + } + if refund_trade_bill_completion_decision(rows.len(), failed_row_count) + == RefundTradeBillCompletionDecision::Retry + { + return Err(format!( + "wechat pay refund trade bill has {failed_row_count} retryable row(s)" + )); + } + let processed_refund_count = u32::try_from(rows.len()).unwrap_or(u32::MAX); + state + .spacetime_client() + .advance_profile_recharge_refund_bill_checkpoint( + checkpoint_id, + bill_date_text.clone(), + download.hash_value, + processed_refund_count, + offset_datetime_to_unix_micros(now), + ) + .await + .map_err(|error| error.to_string())?; + info!( + bill_date = bill_date_text, + processed_refund_count, "wechat pay refund trade bill reconciled" + ); + Ok(RefundTradeBillDateOutcome::Completed) +} + +fn refund_trade_bill_dates_for_scan_slot(beijing_now: OffsetDateTime, scan_slot: u8) -> Vec { + if beijing_now.hour() < REFUND_TRADE_BILL_AVAILABLE_HOUR { + return Vec::new(); + } + let slot_count = i32::from(REFUND_TRADE_BILL_SCAN_SLOT_COUNT); + let slot = i32::from(scan_slot % REFUND_TRADE_BILL_SCAN_SLOT_COUNT); + (1..=REFUND_TRADE_BILL_LOOKBACK_DAYS) + .rev() + .map(|days_ago| beijing_now.date() - time::Duration::days(days_ago)) + .filter(|bill_date| bill_date.to_julian_day().rem_euclid(slot_count) == slot) + .collect() +} + +fn no_statement_is_stable_for_checkpoint(bill_date: Date, beijing_now: OffsetDateTime) -> bool { + bill_date + <= beijing_now.date() - time::Duration::days(REFUND_TRADE_BILL_NO_STATEMENT_STABLE_AGE_DAYS) +} + +fn refund_trade_bill_completion_decision( + _total_row_count: usize, + failed_row_count: usize, +) -> RefundTradeBillCompletionDecision { + if failed_row_count == 0 { + RefundTradeBillCompletionDecision::Checkpoint + } else { + RefundTradeBillCompletionDecision::Retry + } +} + +fn classify_refund_trade_bill_failure(error: &str) -> &'static str { + if error.contains("trade-bill row does not match refund query") { + "row_query_mismatch" + } else if error.contains("签名") || error.contains("SHA1") { + "provider_verification_failed" + } else if error.contains("解析") || error.contains("格式") || error.contains("契约") { + "provider_contract_invalid" + } else if error.contains("持久化") || error.contains("procedure") { + "observation_persistence_failed" + } else { + "provider_or_runtime_request_failed" + } +} + +fn classify_active_refund_reconciliation_failure(error: &str) -> &'static str { + if error.contains("签名") || error.contains("signature") { + "provider_verification_failed" + } else if error.contains("timestamp") || error.contains("时间") { + "refund_fact_time_invalid" + } else if error.contains("procedure") || error.contains("持久化") { + "observation_persistence_failed" + } else if error.contains("配置") || error.contains("config") { + "provider_config_invalid" + } else { + "provider_or_runtime_request_failed" + } +} + +fn refund_manual_review_is_retryable(refund: &RuntimeProfileRechargeRefundSnapshot) -> bool { + matches!( + refund.last_error_code.as_deref(), + Some("order_missing" | "order_not_paid") + ) +} + +async fn reconcile_trade_bill_refund_row( + state: &AppState, + bill_date: &str, + download: &WechatPayTradeBillDownload, + row: &WechatPayTradeBillRefundRow, +) -> Result<(), String> { + let queried = state + .wechat_pay_client() + .query_refund_by_out_refund_no(&row.out_refund_no) + .await + .map_err(|error| error.to_string())?; + validate_trade_bill_row_against_query(row, &queried)?; + let payload_fingerprint = trade_bill_fingerprint(download, row, &queried); + let observation_id = trade_bill_observation_id(bill_date, row, &payload_fingerprint); + let persisted = persist_wechat_refund_observation( + state, + &queried, + RuntimeProfileRechargeRefundObservationSource::TradeBill, + observation_id, + None, + payload_fingerprint, + current_unix_micros(), + ) + .await?; + log_persisted_refund("trade_bill", &persisted); + Ok(()) +} + +fn validate_trade_bill_row_against_query( + row: &WechatPayTradeBillRefundRow, + queried: &WechatPayRefund, +) -> Result<(), String> { + if row.transaction_id != queried.transaction_id + || row.out_trade_no != queried.out_trade_no + || row.refund_id != queried.refund_id + || row.out_refund_no != queried.out_refund_no + || row.requested_refund_cents != queried.amount_refund_cents + { + return Err("wechat refund trade-bill row does not match refund query".to_string()); + } + Ok(()) +} + +fn refund_query_is_due(refund: &RuntimeProfileRechargeRefundSnapshot, now_micros: i64) -> bool { + let age_micros = now_micros.saturating_sub(refund.first_observed_at_micros); + let interval_seconds = if age_micros < 5 * 60 * 1_000_000 { + 60 + } else if age_micros < 15 * 60 * 1_000_000 { + 5 * 60 + } else if age_micros < 35 * 60 * 1_000_000 { + 10 * 60 + } else if age_micros < 75 * 60 * 1_000_000 { + 20 * 60 + } else { + 30 * 60 + }; + now_micros.saturating_sub(refund.updated_at_micros) >= interval_seconds * 1_000_000 +} + +fn query_observation_id(refund: &WechatPayRefund, observed_at_micros: i64) -> String { + let success = refund.success_time.as_deref().unwrap_or("-"); + format!( + "query:{}:{}:{}:{}", + refund.refund_id, + refund.status.to_ascii_uppercase(), + short_hash(success.as_bytes()), + observed_at_micros.div_euclid(1_000_000), + ) +} + +fn refund_fact_fingerprint(refund: &WechatPayRefund) -> String { + let payload = format!( + "{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}\0{}", + refund.transaction_id, + refund.out_trade_no, + refund.refund_id, + refund.out_refund_no, + refund.status, + refund.success_time.as_deref().unwrap_or(""), + refund.amount_total_cents, + refund.amount_refund_cents, + refund.amount_payer_total_cents, + refund.amount_payer_refund_cents, + ); + format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) +} + +fn trade_bill_fingerprint( + download: &WechatPayTradeBillDownload, + row: &WechatPayTradeBillRefundRow, + queried: &WechatPayRefund, +) -> String { + let payload = format!( + "{}\0{}\0{}\0{}\0{}\0{}\0{}", + download.hash_value, + row.refund_type, + row.bill_refund_status, + row.requested_refund_cents, + row.refunded_cents, + row.coupon_refund_cents, + refund_fact_fingerprint(queried), + ); + format!("sha256:{}", hex::encode(Sha256::digest(payload.as_bytes()))) +} + +fn trade_bill_observation_id( + bill_date: &str, + row: &WechatPayTradeBillRefundRow, + payload_fingerprint: &str, +) -> String { + format!( + "trade-bill:{bill_date}:{}:{}", + short_hash(row.refund_id.as_bytes()), + short_hash(payload_fingerprint.as_bytes()), + ) +} + +fn micros_to_rfc3339(micros: i64) -> Result { + OffsetDateTime::from_unix_timestamp_nanos(i128::from(micros) * 1_000) + .map_err(|error| format!("refund timestamp is invalid: {error}"))? + .format(&time::format_description::well_known::Rfc3339) + .map_err(|error| format!("refund timestamp formatting failed: {error}")) +} + +fn format_date(date: time::Date) -> String { + format!( + "{:04}-{:02}-{:02}", + date.year(), + u8::from(date.month()), + date.day() + ) +} + +fn short_hash(value: &[u8]) -> String { + hex::encode(&Sha256::digest(value)[..8]) +} + +fn refund_log_ref(value: &str) -> String { + format!("sha256:{}", short_hash(value.as_bytes())) +} + +fn log_persisted_refund(source: &str, persisted: &PersistedWechatRefundObservation) { + let wallet_frozen = persisted + .settlement + .as_ref() + .map(|value| value.wallet_frozen) + .unwrap_or(false); + info!( + source, + refund_ref = refund_log_ref(&persisted.refund.provider_refund_id), + order_ref = refund_log_ref(&persisted.refund.order_id), + provider_status = persisted.refund.provider_status.as_str(), + recovery_status = persisted.refund.recovery_status.as_str(), + unrecovered_points = persisted.refund.unrecovered_points, + wallet_frozen, + duplicate = persisted.duplicate, + resolution_code = persisted.resolution_code, + "wechat pay refund observation persisted" + ); +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + #[test] + fn refund_query_backoff_uses_durable_observation_timestamps() { + let mut refund = test_refund_snapshot(); + let now = 10_000_000_000; + refund.first_observed_at_micros = now - 2 * 60 * 1_000_000; + refund.updated_at_micros = now - 59 * 1_000_000; + assert!(!refund_query_is_due(&refund, now)); + refund.updated_at_micros = now - 60 * 1_000_000; + assert!(refund_query_is_due(&refund, now)); + + refund.first_observed_at_micros = now - 80 * 60 * 1_000_000; + refund.updated_at_micros = now - 29 * 60 * 1_000_000; + assert!(!refund_query_is_due(&refund, now)); + refund.updated_at_micros = now - 30 * 60 * 1_000_000; + assert!(refund_query_is_due(&refund, now)); + } + + #[test] + fn trade_bill_scan_slots_cover_the_full_provider_window() { + let before_available = test_beijing_now(9); + assert!(refund_trade_bill_dates_for_scan_slot(before_available, 0).is_empty()); + + let beijing_now = test_beijing_now(10); + let mut all_dates = BTreeSet::new(); + for slot in 0..REFUND_TRADE_BILL_SCAN_SLOT_COUNT { + let dates = refund_trade_bill_dates_for_scan_slot(beijing_now, slot); + assert_eq!(dates.len(), 3); + for date in dates { + assert!( + all_dates.insert(date), + "a bill date must belong to one scan slot" + ); + } + } + + assert_eq!(all_dates.len(), REFUND_TRADE_BILL_LOOKBACK_DAYS as usize); + assert_eq!( + all_dates.first().copied(), + Some(beijing_now.date() - time::Duration::days(REFUND_TRADE_BILL_LOOKBACK_DAYS)) + ); + assert_eq!( + all_dates.last().copied(), + Some(beijing_now.date() - time::Duration::days(1)) + ); + } + + #[test] + fn no_statement_only_checkpoints_after_the_stability_window() { + let beijing_now = test_beijing_now(10); + assert!(!no_statement_is_stable_for_checkpoint( + beijing_now.date() - time::Duration::days(1), + beijing_now, + )); + assert!(no_statement_is_stable_for_checkpoint( + beijing_now.date() + - time::Duration::days(REFUND_TRADE_BILL_NO_STATEMENT_STABLE_AGE_DAYS), + beijing_now, + )); + } + + #[test] + fn trade_bill_partial_failure_never_completes_the_date() { + assert_eq!( + refund_trade_bill_completion_decision(3, 0), + RefundTradeBillCompletionDecision::Checkpoint + ); + assert_eq!( + refund_trade_bill_completion_decision(3, 1), + RefundTradeBillCompletionDecision::Retry + ); + assert_eq!( + refund_trade_bill_completion_decision(1, 1), + RefundTradeBillCompletionDecision::Retry + ); + } + + #[test] + fn trade_bill_row_requires_all_provider_identifiers_and_requested_amount() { + let queried = test_wechat_refund(); + let mut row = test_trade_bill_row(); + validate_trade_bill_row_against_query(&row, &queried).expect("matching row should pass"); + row.requested_refund_cents += 1; + assert!(validate_trade_bill_row_against_query(&row, &queried).is_err()); + } + + #[test] + fn trade_bill_observation_id_is_stable_per_query_fact_and_advances_with_status() { + let download = WechatPayTradeBillDownload { + hash_type: "SHA1".to_string(), + hash_value: "bill-hash".to_string(), + download_url: "https://api.example.com/v3/billdownload/file?token=test".to_string(), + }; + let row = test_trade_bill_row(); + let processing = test_wechat_refund(); + let processing_fingerprint = trade_bill_fingerprint(&download, &row, &processing); + let first_id = trade_bill_observation_id("2026-07-13", &row, &processing_fingerprint); + assert_eq!( + first_id, + trade_bill_observation_id("2026-07-13", &row, &processing_fingerprint) + ); + + let mut success = processing; + success.status = "SUCCESS".to_string(); + success.success_time = Some("2026-07-13T18:17:27+08:00".to_string()); + let success_fingerprint = trade_bill_fingerprint(&download, &row, &success); + assert_ne!( + first_id, + trade_bill_observation_id("2026-07-13", &row, &success_fingerprint) + ); + } + + #[test] + fn late_payment_can_reopen_only_retryable_manual_review() { + let mut refund = test_refund_snapshot(); + refund.provider_status = RuntimeProfileRechargeRefundStatus::Success; + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + + refund.last_error_code = Some("order_missing".to_string()); + assert!(refund_manual_review_is_retryable(&refund)); + refund.last_error_code = Some("order_not_paid".to_string()); + assert!(refund_manual_review_is_retryable(&refund)); + + refund.last_error_code = Some("membership_manual_review".to_string()); + assert!(!refund_manual_review_is_retryable(&refund)); + refund.last_error_code = Some("provider_transaction_id_mismatch".to_string()); + assert!(!refund_manual_review_is_retryable(&refund)); + } + + #[test] + fn active_reconciliation_error_classification_never_echoes_provider_input() { + let sensitive = "request failed for /v3/refund/domestic/refunds/merchant-refund-secret"; + assert_eq!( + classify_active_refund_reconciliation_failure(sensitive), + "provider_or_runtime_request_failed" + ); + assert!( + !classify_active_refund_reconciliation_failure(sensitive) + .contains("merchant-refund-secret") + ); + } + + #[test] + fn refund_hold_release_requires_age_and_three_consecutive_verified_absences() { + let now = 20 * 60 * 1_000_000; + let too_new = now - 9 * 60 * 1_000_000; + let old_enough = now - 10 * 60 * 1_000_000; + + assert!(!refund_hold_should_release(too_new, now, 3)); + assert!(!refund_hold_should_release(old_enough, now, 2)); + assert!(refund_hold_should_release(old_enough, now, 3)); + } + + #[test] + fn refund_hold_query_error_classification_is_static() { + let error = + WechatPayError::RequestFailed("request contains merchant-refund-secret".to_string()); + assert_eq!( + classify_wechat_refund_query_error(&error), + "provider_request_failed" + ); + assert!(!classify_wechat_refund_query_error(&error).contains("secret")); + } + + fn test_wechat_refund() -> WechatPayRefund { + WechatPayRefund { + mch_id: None, + transaction_id: "tx-1".to_string(), + out_trade_no: "order-1".to_string(), + refund_id: "refund-1".to_string(), + out_refund_no: "merchant-refund-1".to_string(), + status: "PROCESSING".to_string(), + success_time: None, + create_time: None, + amount_total_cents: 600, + amount_refund_cents: 600, + amount_payer_total_cents: 600, + amount_payer_refund_cents: 600, + } + } + + fn test_trade_bill_row() -> WechatPayTradeBillRefundRow { + WechatPayTradeBillRefundRow { + transaction_id: "tx-1".to_string(), + out_trade_no: "order-1".to_string(), + refund_id: "refund-1".to_string(), + out_refund_no: "merchant-refund-1".to_string(), + accepted_time: None, + success_time: None, + refund_type: "PLATFORM-ORIGINAL".to_string(), + bill_refund_status: "PROCESSING".to_string(), + requested_refund_cents: 600, + refunded_cents: 600, + coupon_refund_cents: 0, + app_id: None, + mch_id: None, + } + } + + fn test_refund_snapshot() -> RuntimeProfileRechargeRefundSnapshot { + RuntimeProfileRechargeRefundSnapshot { + out_refund_no: "merchant-refund-1".to_string(), + provider_refund_id: "refund-1".to_string(), + order_id: "order-1".to_string(), + provider_transaction_id: "tx-1".to_string(), + user_id: Some("user-1".to_string()), + provider_status: RuntimeProfileRechargeRefundStatus::Processing, + total_cents: 600, + refund_cents: 600, + payer_total_cents: 600, + payer_refund_cents: 600, + success_at_micros: None, + first_observed_at_micros: 1, + updated_at_micros: 1, + last_observation_source: RuntimeProfileRechargeRefundObservationSource::Query, + last_observation_id: "query-1".to_string(), + order_settled_at_micros: None, + target_recovery_points: 0, + recovered_points: 0, + unrecovered_points: 0, + recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable, + last_recovery_ledger_id: None, + last_error_code: None, + } + } + + fn test_beijing_now(hour: u8) -> OffsetDateTime { + time::Date::from_calendar_date(2026, time::Month::July, 13) + .expect("test date should be valid") + .with_hms(hour, 0, 0) + .expect("test time should be valid") + .assume_offset(UtcOffset::from_hms(8, 0, 0).expect("offset should be valid")) + } +} diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index 0d936e15e..afae476d5 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -58,6 +58,7 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_NEW_USER_REGISTRATION_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileCodeOperationAdminResponse, ProfileDailyFreePointsResponse, ProfileDashboardSummaryResponse, @@ -205,6 +206,9 @@ fn format_profile_wallet_ledger_source_type( RuntimeProfileWalletLedgerSourceType::DailyFreeReset => { PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET } + RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { + PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY + } } } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d6e8d09e8..17e064ac2 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1215,6 +1215,10 @@ impl AppState { &self.wechat_pay_client } + pub fn wechat_pay_refund_reconciliation_enabled(&self) -> bool { + self.config.wechat_pay_refund_reconciliation_enabled + } + #[cfg_attr(not(test), allow(dead_code))] pub fn ai_task_service(&self) -> &AiTaskService { &self.ai_task_service diff --git a/server-rs/crates/api-server/src/wechat/pay.rs b/server-rs/crates/api-server/src/wechat/pay.rs index 7a9b0846f..556c8f00e 100644 --- a/server-rs/crates/api-server/src/wechat/pay.rs +++ b/server-rs/crates/api-server/src/wechat/pay.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use axum::{ Json, extract::{Query, State}, @@ -5,9 +7,19 @@ use axum::{ response::{IntoResponse, Response}, }; use bytes::Bytes; +use module_runtime::{ + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeRefundObservationInput, RuntimeProfileRechargeRefundObservationSource, + RuntimeProfileRechargeRefundStatus, build_runtime_profile_recharge_refund_observation_input, +}; use platform_wechat::pay::{ WechatMiniProgramMessagePushQuery, WechatMiniProgramOrderRequest, WechatPayConfig, - WechatPayError, WechatWebOrderRequest, decrypt_wechat_message_push_ciphertext, + WechatPayError, WechatPayRefund, WechatPayRefundNotification, + WechatVirtualPaymentNotifyDebugSummary, WechatVirtualPaymentNotifyOrder, WechatWebOrderRequest, + build_virtual_payment_notify_debug_summary, decrypt_wechat_message_push_ciphertext, parse_virtual_payment_notify, parse_wechat_mini_program_message_push_payload, resolve_wechat_message_push_verify_response, verify_wechat_message_push_signature, }; @@ -17,9 +29,11 @@ use platform_wechat::{ }; use serde::Serialize; use serde_json::json; +use sha2::{Digest, Sha256}; use shared_kernel::offset_datetime_to_unix_micros; +use spacetime_client::SpacetimeClientError; use time::OffsetDateTime; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use crate::{config::AppConfig, http_error::AppError, state::AppState}; @@ -29,6 +43,15 @@ enum VirtualPaymentNotifyResponseFormat { Xml, } +impl VirtualPaymentNotifyResponseFormat { + fn as_str(self) -> &'static str { + match self { + Self::Json => "json", + Self::Xml => "xml", + } + } +} + #[derive(Serialize)] struct ApiWechatVirtualPaymentNotifyResponse { #[serde(rename = "ErrCode")] @@ -37,6 +60,244 @@ struct ApiWechatVirtualPaymentNotifyResponse { err_msg: String, } +const WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT: &str = + "xpay_subscribe_ios_refund_query_notify"; +const WECHAT_VIRTUAL_PAYMENT_NOTIFY_CONFIRM_TIMEOUT: Duration = Duration::from_millis(2_500); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum VirtualPaymentNotifyHandling { + CreditOrder, + DebugAcknowledge, + DebugRetry, +} + +enum VirtualPaymentNotifyCreditOutcome { + IgnoredNoRechargeOrder, + AlreadyPaid { order_id: String }, + Credited { order_id: String }, +} + +impl VirtualPaymentNotifyHandling { + fn as_str(self) -> &'static str { + match self { + Self::CreditOrder => "credit_order", + Self::DebugAcknowledge => "debug_acknowledge", + Self::DebugRetry => "debug_retry", + } + } +} + +struct VirtualPaymentNotifyDebugContext { + request_ref: String, + response_format: VirtualPaymentNotifyResponseFormat, + started_at: Instant, +} + +impl VirtualPaymentNotifyDebugContext { + fn new(response_format: VirtualPaymentNotifyResponseFormat, body: &[u8]) -> Self { + let digest = Sha256::digest(body); + Self { + request_ref: format!("sha256:{}", hex::encode(&digest[..16])), + response_format, + started_at: Instant::now(), + } + } + + fn log_received(&self, body_bytes: usize) { + debug!( + request_ref = self.request_ref.as_str(), + response_format = self.response_format.as_str(), + encrypted_request_bytes = body_bytes, + "收到微信虚拟支付通知" + ); + } + + fn log_signature_verified(&self, ciphertext_bytes: usize) { + debug!( + request_ref = self.request_ref.as_str(), + signature_verified = true, + ciphertext_bytes, + "微信虚拟支付通知验签成功" + ); + } + + fn log_summary(&self, summary: &WechatVirtualPaymentNotifyDebugSummary) { + debug!( + request_ref = self.request_ref.as_str(), + notification_fingerprint = summary.payload_fingerprint.as_str(), + event = summary.event.as_str(), + event_ref = summary.event_ref.as_deref().unwrap_or("not_applicable"), + known_event = summary.known_event, + response_format = self.response_format.as_str(), + payload_bytes = summary.payload_bytes, + payload_schema_keys = ?summary.schema_fields, + identifier_refs = ?summary.identifier_refs, + safe_fields = ?summary.safe_fields, + sensitive_text_fingerprints = ?summary.sensitive_text_fields, + apple_subscription_info = summary.apple_subscription_info, + subscription_info = summary.subscription_info, + signature_verified = true, + decrypt_succeeded = true, + "微信虚拟支付通知诊断摘要" + ); + } + + fn success( + &self, + summary: &WechatVirtualPaymentNotifyDebugSummary, + handling: VirtualPaymentNotifyHandling, + ) -> Response { + debug!( + request_ref = self.request_ref.as_str(), + notification_fingerprint = summary.payload_fingerprint.as_str(), + event = summary.event.as_str(), + handling = handling.as_str(), + response_err_code = 0, + handler_latency_ms = self.started_at.elapsed().as_millis() as u64, + "微信虚拟支付通知处理完成" + ); + build_virtual_payment_notify_success_response(self.response_format) + } + + fn error( + &self, + error: WechatPayError, + stage: &'static str, + summary: Option<&WechatVirtualPaymentNotifyDebugSummary>, + ) -> Response { + let event = summary + .map(|value| value.event.as_str()) + .unwrap_or("unparsed"); + let notification_fingerprint = summary + .map(|value| value.payload_fingerprint.as_str()) + .unwrap_or("unavailable"); + warn!( + request_ref = self.request_ref.as_str(), + notification_fingerprint, + event, + stage, + error = %error, + response_err_code = 1, + handler_latency_ms = self.started_at.elapsed().as_millis() as u64, + "微信虚拟支付通知处理失败" + ); + build_virtual_payment_notify_error_response(error, self.response_format) + } +} + +fn classify_virtual_payment_notify( + summary: &WechatVirtualPaymentNotifyDebugSummary, +) -> VirtualPaymentNotifyHandling { + match summary.raw_event() { + WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT => VirtualPaymentNotifyHandling::DebugRetry, + "xpay_goods_deliver_notify" | "xpay_coin_pay_notify" if summary.subscription_info => { + VirtualPaymentNotifyHandling::DebugAcknowledge + } + "xpay_goods_deliver_notify" | "xpay_coin_pay_notify" => { + VirtualPaymentNotifyHandling::CreditOrder + } + _ if summary.known_event => VirtualPaymentNotifyHandling::DebugAcknowledge, + _ => VirtualPaymentNotifyHandling::DebugRetry, + } +} + +async fn confirm_virtual_payment_recharge_order( + state: &AppState, + notify: &WechatVirtualPaymentNotifyOrder, +) -> Result { + let (_, order) = match state + .spacetime_client() + .get_profile_recharge_order(notify.out_trade_no.clone()) + .await + { + Ok(result) => result, + Err(error) if is_profile_recharge_order_not_found(&error) => { + return Ok(VirtualPaymentNotifyCreditOutcome::IgnoredNoRechargeOrder); + } + Err(error) => { + return Err(WechatPayError::Upstream(format!( + "读取本地充值订单失败:{error}" + ))); + } + }; + if order.payment_channel != PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL { + return Err(WechatPayError::InvalidRequest( + "微信虚拟支付通知对应的本地订单渠道不匹配".to_string(), + )); + } + if order.status == RuntimeProfileRechargeOrderStatus::Paid { + return Ok(VirtualPaymentNotifyCreditOutcome::AlreadyPaid { + order_id: order.order_id, + }); + } + if !matches!( + order.status, + RuntimeProfileRechargeOrderStatus::Pending | RuntimeProfileRechargeOrderStatus::Expired + ) { + return Err(WechatPayError::InvalidRequest( + "微信虚拟支付通知对应的本地订单状态不允许确认支付".to_string(), + )); + } + + let identity = state + .wechat_auth_service() + .get_identity_by_user_id(&order.user_id) + .map_err(|error| WechatPayError::Upstream(format!("读取微信身份失败:{error}")))? + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信虚拟支付通知对应的本地订单缺少微信身份".to_string()) + })?; + let query_request = build_wechat_virtual_payment_query_order_request( + &state.config, + identity.provider_uid, + order.order_id.clone(), + ) + .map_err(|error| { + WechatPayError::InvalidConfig(format!("构造微信虚拟支付查单请求失败:{error}")) + })?; + let wechat_order = state + .wechat_client() + .query_virtual_payment_order(query_request) + .await + .map_err(|error| WechatPayError::Upstream(format!("微信虚拟支付查单失败:{error}")))?; + validate_wechat_virtual_payment_order(&order.order_id, order.amount_cents, &wechat_order) + .map_err(|error| { + WechatPayError::Upstream(format!("微信虚拟支付查单契约校验失败:{error}")) + })?; + if !is_wechat_virtual_payment_order_paid(wechat_order.status) { + return Err(WechatPayError::Upstream(format!( + "微信虚拟支付查单尚未确认支付,status={}", + wechat_order.status + ))); + } + let paid_at_micros = + paid_at_micros_from_wechat_virtual_payment_order(&wechat_order).map_err(|error| { + WechatPayError::Upstream(format!("微信虚拟支付查单缺少权威支付时间:{error}")) + })?; + let order_id = order.order_id; + state + .spacetime_client() + .mark_profile_recharge_order_paid( + order_id.clone(), + paid_at_micros, + wechat_order.wxpay_order_id.or(wechat_order.wx_order_id), + ) + .await + .map_err(|error| WechatPayError::Upstream(format!("确认微信虚拟支付订单失败:{error}")))?; + + Ok(VirtualPaymentNotifyCreditOutcome::Credited { order_id }) +} + +fn is_profile_recharge_order_not_found(error: &SpacetimeClientError) -> bool { + matches!( + error, + SpacetimeClientError::Procedure(message) + if matches!( + message.trim(), + "profile_recharge_order missing" | "profile_recharge_order 不存在" + ) + ) +} + pub async fn handle_wechat_pay_notify( State(state): State, headers: HeaderMap, @@ -58,16 +319,40 @@ pub async fn handle_wechat_pay_notify( let paid_at_micros = notify .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); + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知缺少 success_time") + }) + .and_then(|value| { + shared_kernel::parse_rfc3339(value).map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message(format!("微信支付成功通知 success_time 无效:{error}")) + }) + }) + .map(offset_datetime_to_unix_micros)?; + let transaction_id = notify.transaction_id.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知缺少 transaction_id") + })?; + let amount_total_cents = notify.amount_total_cents.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("微信支付成功通知缺少金额") + })?; + let (_, order) = state + .spacetime_client() + .get_profile_recharge_order(notify.out_trade_no.clone()) + .await + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_message(format!("读取微信支付本地订单失败:{error}")) + })?; + validate_wechat_pay_notify_order(&order, amount_total_cents, &transaction_id)?; state .spacetime_client() .mark_profile_recharge_order_paid( notify.out_trade_no.clone(), paid_at_micros, - notify.transaction_id.clone(), + Some(transaction_id), ) .await .map_err(|error| { @@ -83,6 +368,343 @@ pub async fn handle_wechat_pay_notify( Ok(StatusCode::NO_CONTENT) } +fn validate_wechat_pay_notify_order( + order: &module_runtime::RuntimeProfileRechargeOrderRecord, + amount_total_cents: u64, + transaction_id: &str, +) -> Result<(), AppError> { + if !matches!( + order.payment_channel.as_str(), + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + ) { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知对应订单不是普通 V3 渠道")); + } + if order.amount_cents != amount_total_cents { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知金额与本地订单不一致")); + } + if let Some(existing) = order.provider_transaction_id.as_deref() + && existing != transaction_id + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("微信支付成功通知 transaction_id 与已结算订单不一致")); + } + Ok(()) +} + +pub async fn handle_wechat_pay_refund_notify( + State(state): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + let request_ref = state + .wechat_pay_client() + .refund_notify_request_ref(&body) + .unwrap_or_else(|| "unavailable".to_string()); + debug!( + request_ref = request_ref.as_str(), + payload_bytes = body.len(), + "收到微信支付 V3 退款结果通知,开始验签解密" + ); + let notification = match state + .wechat_pay_client() + .parse_refund_notify(&headers, &body) + { + Ok(notification) => notification, + Err(error) => { + warn!( + request_ref = request_ref.as_str(), + stage = wechat_pay_refund_notify_error_stage(&error), + error_code = error.diagnostic_code(), + failure_reason = wechat_pay_refund_notify_failure_reason(&error), + payload_bytes = body.len(), + "微信支付 V3 退款结果通知处理失败" + ); + return build_wechat_pay_refund_notify_error_response(error); + } + }; + let summary = ¬ification.debug; + let received_account_bytes = summary + .user_received_account + .as_ref() + .map(|value| value.bytes); + let received_account_ref = summary + .user_received_account + .as_ref() + .map(|value| value.hmac_ref.as_str()); + debug!( + request_ref = request_ref.as_str(), + event_type = summary.event_type.as_str(), + known_event = summary.known_event, + resource_type = summary.resource_type.as_str(), + original_type = summary.original_type.as_str(), + algorithm = summary.algorithm.as_str(), + create_time = summary.create_time.as_str(), + refund_status = summary.refund_status.as_str(), + success_time = ?summary.success_time.as_deref(), + amount_total_cents = summary.amount_total_cents, + amount_refund_cents = summary.amount_refund_cents, + amount_payer_total_cents = summary.amount_payer_total_cents, + amount_payer_refund_cents = summary.amount_payer_refund_cents, + payload_bytes = summary.payload_bytes, + payload_fingerprint = summary.payload_fingerprint.as_str(), + notification_ref = summary.notification_ref.as_str(), + merchant_ref = summary.merchant_ref.as_str(), + transaction_ref = summary.transaction_ref.as_str(), + order_ref = summary.order_ref.as_str(), + refund_ref = summary.refund_ref.as_str(), + merchant_refund_ref = summary.merchant_refund_ref.as_str(), + user_received_account_bytes = ?received_account_bytes, + user_received_account_ref = ?received_account_ref, + signature_verified = true, + decrypt_succeeded = true, + business_mutation = true, + "微信支付 V3 退款结果通知诊断摘要" + ); + + let observation = match build_wechat_pay_refund_notification_observation(¬ification) { + Ok(observation) => observation, + Err(error) => { + warn!( + request_ref = request_ref.as_str(), + stage = "observation_validation", + error_code = error.diagnostic_code(), + failure_reason = wechat_pay_refund_notify_failure_reason(&error), + "微信支付 V3 退款结果通知无法构造持久化事实" + ); + return build_wechat_pay_refund_notify_error_response(error); + } + }; + let (record, settlement, duplicate, resolution_code) = match state + .spacetime_client() + .record_profile_recharge_refund_observation(observation) + .await + { + Ok(result) => result, + Err(error) => { + warn!( + request_ref = request_ref.as_str(), + stage = "refund_persistence", + error = %error, + "微信支付 V3 退款结果通知持久化失败" + ); + return build_wechat_pay_refund_notify_error_response(WechatPayError::Upstream( + format!("持久化微信支付退款事实失败:{error}"), + )); + } + }; + state.publish_profile_recharge_order_update(record.order_id.clone()); + let wallet_frozen = settlement + .as_ref() + .map(|value| value.wallet_frozen) + .unwrap_or(false); + if matches!( + resolution_code.as_str(), + "immutable_conflict" | "provider_refund_id_conflict" | "status_conflict" + ) { + warn!( + request_ref = request_ref.as_str(), + notification_fingerprint = summary.payload_fingerprint.as_str(), + duplicate, + resolution_code = resolution_code.as_str(), + wallet_frozen, + "微信支付 V3 退款结果通知已持久化为冲突观察" + ); + } else { + info!( + request_ref = request_ref.as_str(), + notification_fingerprint = summary.payload_fingerprint.as_str(), + duplicate, + resolution_code = resolution_code.as_str(), + wallet_frozen, + "微信支付 V3 退款结果通知已持久化" + ); + } + StatusCode::NO_CONTENT.into_response() +} + +fn build_wechat_pay_refund_notification_observation( + notification: &WechatPayRefundNotification, +) -> Result { + let observed_at_micros = parse_wechat_pay_refund_time( + Some(notification.create_time.as_str()), + "微信支付退款通知 create_time", + )? + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信支付退款通知缺少 create_time".to_string()) + })?; + build_wechat_pay_refund_observation( + notification.notification_id.clone(), + RuntimeProfileRechargeRefundObservationSource::Callback, + Some(notification.debug.notification_ref.clone()), + notification.debug.payload_fingerprint.clone(), + ¬ification.refund, + observed_at_micros, + ) +} + +pub(crate) fn build_wechat_pay_refund_observation( + observation_id: String, + source: RuntimeProfileRechargeRefundObservationSource, + notification_ref: Option, + payload_fingerprint: String, + refund: &WechatPayRefund, + observed_at_micros: i64, +) -> Result { + let provider_status = match refund.status.trim().to_ascii_uppercase().as_str() { + "PROCESSING" => RuntimeProfileRechargeRefundStatus::Processing, + "SUCCESS" => RuntimeProfileRechargeRefundStatus::Success, + "ABNORMAL" => RuntimeProfileRechargeRefundStatus::Abnormal, + "CLOSED" => RuntimeProfileRechargeRefundStatus::Closed, + _ => { + return Err(WechatPayError::InvalidRequest( + "微信支付退款包含未知状态".to_string(), + )); + } + }; + let success_at_micros = + parse_wechat_pay_refund_time(refund.success_time.as_deref(), "微信支付退款 success_time")?; + build_runtime_profile_recharge_refund_observation_input( + observation_id, + source, + notification_ref, + payload_fingerprint, + refund.out_refund_no.clone(), + refund.refund_id.clone(), + refund.out_trade_no.clone(), + refund.transaction_id.clone(), + provider_status, + refund.amount_total_cents, + refund.amount_refund_cents, + refund.amount_payer_total_cents, + refund.amount_payer_refund_cents, + success_at_micros, + observed_at_micros, + ) + .map_err(WechatPayError::InvalidRequest) +} + +fn parse_wechat_pay_refund_time( + value: Option<&str>, + field_name: &str, +) -> Result, WechatPayError> { + value + .map(|value| { + shared_kernel::parse_rfc3339(value) + .map(offset_datetime_to_unix_micros) + .map_err(|error| { + WechatPayError::InvalidRequest(format!("{field_name} 格式无效:{error}")) + }) + }) + .transpose() +} + +fn wechat_pay_refund_notify_error_stage(error: &WechatPayError) -> &'static str { + match error { + WechatPayError::InvalidSignature(_) => "signature_verification", + WechatPayError::Crypto(_) => "payload_decryption", + WechatPayError::Deserialize(_) => "payload_parse", + WechatPayError::InvalidRequest(_) => "contract_validation", + WechatPayError::Disabled | WechatPayError::InvalidConfig(_) => "provider_config", + WechatPayError::OrderNotExist(_) + | WechatPayError::RequestFailed(_) + | WechatPayError::Upstream(_) => "unexpected_upstream", + } +} + +fn wechat_pay_refund_notify_failure_reason(error: &WechatPayError) -> &'static str { + match error { + WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Timestamp") => { + "missing_timestamp" + } + WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Nonce") => { + "missing_nonce" + } + WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Signature") => { + "missing_signature" + } + WechatPayError::InvalidSignature(message) if message.contains("Wechatpay-Serial") => { + "missing_platform_serial" + } + WechatPayError::InvalidSignature(message) if message.contains("时间戳格式") => { + "invalid_timestamp" + } + WechatPayError::InvalidSignature(message) if message.contains("时间戳超出") => { + "timestamp_out_of_window" + } + WechatPayError::InvalidSignature(message) if message.contains("序列号不匹配") => { + "platform_serial_mismatch" + } + WechatPayError::InvalidSignature(message) if message.contains("签名探测") => { + "signature_probe" + } + WechatPayError::InvalidSignature(message) if message.contains("base64") => { + "signature_encoding_invalid" + } + WechatPayError::InvalidSignature(_) => "signature_invalid", + WechatPayError::Crypto(message) if message.contains("base64") => { + "ciphertext_encoding_invalid" + } + WechatPayError::Crypto(message) if message.contains("nonce") => "resource_nonce_invalid", + WechatPayError::Crypto(_) => "ciphertext_authentication_failed", + WechatPayError::Deserialize(message) if message.contains("解密资源") => { + "decrypted_resource_schema_invalid" + } + WechatPayError::Deserialize(_) => "notification_envelope_schema_invalid", + WechatPayError::InvalidRequest(message) if message.contains("event_type") => { + "event_type_invalid" + } + WechatPayError::InvalidRequest(message) if message.contains("退款状态不一致") => { + "event_status_mismatch" + } + WechatPayError::InvalidRequest(message) if message.contains("商户号不匹配") => { + "merchant_mismatch" + } + WechatPayError::InvalidRequest(message) if message.contains("resource_type") => { + "resource_type_invalid" + } + WechatPayError::InvalidRequest(message) if message.contains("algorithm") => { + "resource_algorithm_invalid" + } + WechatPayError::InvalidRequest(message) if message.contains("original_type") => { + "resource_original_type_invalid" + } + WechatPayError::InvalidRequest(_) => "contract_invalid", + WechatPayError::Disabled => "provider_disabled", + WechatPayError::InvalidConfig(_) => "provider_config_invalid", + WechatPayError::OrderNotExist(_) => "unexpected_order_lookup", + WechatPayError::RequestFailed(_) => "unexpected_request_failure", + WechatPayError::Upstream(_) => "unexpected_upstream_failure", + } +} + +fn build_wechat_pay_refund_notify_error_response(error: WechatPayError) -> Response { + let status = match error { + WechatPayError::InvalidSignature(_) + | WechatPayError::InvalidRequest(_) + | WechatPayError::Deserialize(_) + | WechatPayError::Crypto(_) => StatusCode::BAD_REQUEST, + WechatPayError::Disabled | WechatPayError::InvalidConfig(_) => { + StatusCode::SERVICE_UNAVAILABLE + } + WechatPayError::OrderNotExist(_) + | WechatPayError::RequestFailed(_) + | WechatPayError::Upstream(_) => StatusCode::BAD_GATEWAY, + }; + ( + status, + Json(json!({ + "code": "FAIL", + "message": "失败" + })), + ) + .into_response() +} + pub async fn handle_wechat_virtual_payment_message_push_verify( State(state): State, Query(query): Query, @@ -126,16 +748,18 @@ pub async fn handle_wechat_virtual_payment_notify( body: Bytes, ) -> Response { let response_format = detect_virtual_payment_notify_response_format(&headers, &body); + let debug_context = VirtualPaymentNotifyDebugContext::new(response_format, &body); + debug_context.log_received(body.len()); let encrypted_payload = match parse_wechat_mini_program_message_push_payload(&body) { Ok(payload) => payload, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + Err(error) => return debug_context.error(error, "encrypted_envelope_parse", None), }; let token = match read_wechat_message_push_config( state.config.wechat_mini_program_message_token.as_deref(), "WECHAT_MINIPROGRAM_MESSAGE_TOKEN", ) { Ok(token) => token, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + Err(error) => return debug_context.error(error, "message_token_config", None), }; let aes_key = match read_wechat_message_push_config( state @@ -145,7 +769,7 @@ pub async fn handle_wechat_virtual_payment_notify( "WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY", ) { Ok(value) => value, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + Err(error) => return debug_context.error(error, "message_aes_key_config", None), }; let signature = query .msg_signature @@ -157,9 +781,10 @@ pub async fn handle_wechat_virtual_payment_notify( let timestamp = query.timestamp.as_deref().map(str::trim).unwrap_or(""); let nonce = query.nonce.as_deref().map(str::trim).unwrap_or(""); if signature.is_empty() || timestamp.is_empty() || nonce.is_empty() { - return build_virtual_payment_notify_error_response( + return debug_context.error( WechatPayError::InvalidRequest("微信消息推送加密参数不完整".to_string()), - response_format, + "signature_parameters", + None, ); } if !verify_wechat_message_push_signature( @@ -169,11 +794,13 @@ pub async fn handle_wechat_virtual_payment_notify( encrypted_payload.encrypt.as_str(), signature, ) { - return build_virtual_payment_notify_error_response( + return debug_context.error( WechatPayError::InvalidSignature("微信消息推送 msg_signature 无效".to_string()), - response_format, + "signature_verification", + None, ); } + debug_context.log_signature_verified(encrypted_payload.encrypt.len()); let notify_body = match decrypt_wechat_message_push_ciphertext( aes_key, encrypted_payload.encrypt.as_str(), @@ -184,51 +811,103 @@ pub async fn handle_wechat_virtual_payment_notify( .or(state.config.wechat_app_id.as_deref()), ) { Ok(body) => body, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + Err(error) => return debug_context.error(error, "payload_decryption", None), }; + let summary = match build_virtual_payment_notify_debug_summary( + notify_body.as_bytes(), + token.as_bytes(), + ) { + Ok(summary) => summary, + Err(error) => return debug_context.error(error, "payload_summary", None), + }; + debug_context.log_summary(&summary); + let handling = classify_virtual_payment_notify(&summary); + match handling { + VirtualPaymentNotifyHandling::DebugAcknowledge => { + info!( + event = summary.event.as_str(), + notification_fingerprint = summary.payload_fingerprint.as_str(), + apple_subscription_info = summary.apple_subscription_info, + subscription_info = summary.subscription_info, + "微信虚拟支付通知已进入诊断处理,不变更订单或用户权益" + ); + return debug_context.success(&summary, handling); + } + VirtualPaymentNotifyHandling::DebugRetry => { + let (stage, message) = + if summary.raw_event() == WECHAT_VIRTUAL_PAYMENT_IOS_REFUND_QUERY_EVENT { + ( + "ios_refund_decision_unconfigured", + "iOS退款问询尚未配置基于真实履约数据的自动决策", + ) + } else { + ("unknown_event", "微信虚拟支付通知事件尚未配置处理策略") + }; + return debug_context.error( + WechatPayError::InvalidRequest(message.to_string()), + stage, + Some(&summary), + ); + } + VirtualPaymentNotifyHandling::CreditOrder => {} + } let notify = match parse_virtual_payment_notify(notify_body.as_bytes()) { Ok(notify) => notify, - Err(error) => return build_virtual_payment_notify_error_response(error, response_format), + Err(error) => return debug_context.error(error, "order_payload_parse", Some(&summary)), }; - if notify.event != "xpay_goods_deliver_notify" && notify.event != "xpay_coin_pay_notify" { - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "收到非订单入账虚拟支付推送" - ); - return build_virtual_payment_notify_success_response(response_format); - } - - let paid_at_micros = notify.paid_at_micros.unwrap_or_else(current_unix_micros); - if state - .spacetime_client() - .mark_profile_recharge_order_paid( - notify.out_trade_no.clone(), - paid_at_micros, - notify.transaction_id.clone(), - ) - .await - .is_err() + let outcome = match tokio::time::timeout( + WECHAT_VIRTUAL_PAYMENT_NOTIFY_CONFIRM_TIMEOUT, + confirm_virtual_payment_recharge_order(&state, ¬ify), + ) + .await { - warn!( - order_id = notify.out_trade_no.as_str(), - "确认微信虚拟支付订单失败" - ); - return build_virtual_payment_notify_error_response( - WechatPayError::Upstream("确认微信虚拟支付订单失败".to_string()), - response_format, - ); + Ok(Ok(outcome)) => outcome, + Ok(Err(error)) => { + return debug_context.error(error, "authoritative_order_confirmation", Some(&summary)); + } + Err(_) => { + return debug_context.error( + WechatPayError::Upstream("微信虚拟支付通知权威查单确认超出 2.5 秒预算".to_string()), + "authoritative_order_confirmation_timeout", + Some(&summary), + ); + } + }; + + match outcome { + VirtualPaymentNotifyCreditOutcome::IgnoredNoRechargeOrder => { + info!( + event = notify.event.as_str(), + order_ref = summary + .primary_identifier_ref("order_ref") + .unwrap_or("unavailable"), + "微信虚拟支付通知没有对应的本地充值订单,已按非充值通知记录" + ); + debug_context.success(&summary, VirtualPaymentNotifyHandling::DebugAcknowledge) + } + VirtualPaymentNotifyCreditOutcome::AlreadyPaid { order_id } => { + state.publish_profile_recharge_order_update(order_id); + info!( + event = notify.event.as_str(), + order_ref = summary + .primary_identifier_ref("order_ref") + .unwrap_or("unavailable"), + "微信虚拟支付通知对应的本地充值订单已入账" + ); + debug_context.success(&summary, handling) + } + VirtualPaymentNotifyCreditOutcome::Credited { order_id } => { + state.publish_profile_recharge_order_update(order_id); + info!( + event = notify.event.as_str(), + order_ref = summary + .primary_identifier_ref("order_ref") + .unwrap_or("unavailable"), + "微信虚拟支付推送已通过官方查单确认订单入账" + ); + debug_context.success(&summary, handling) + } } - - state.publish_profile_recharge_order_update(notify.out_trade_no.clone()); - - info!( - event = notify.event.as_str(), - order_id = notify.out_trade_no.as_str(), - "微信虚拟支付推送已确认订单入账" - ); - - build_virtual_payment_notify_success_response(response_format) } pub fn build_wechat_pay_config(config: &AppConfig) -> WechatPayConfig { @@ -447,11 +1126,24 @@ fn build_wechat_message_push_verify_error_response(error: WechatPayError) -> Res #[cfg(test)] mod tests { use super::{ - build_wechat_virtual_payment_query_order_request, is_wechat_virtual_payment_order_paid, - paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_virtual_payment_order, + VirtualPaymentNotifyHandling, build_wechat_pay_refund_notify_error_response, + build_wechat_pay_refund_observation, build_wechat_virtual_payment_notify_response, + build_wechat_virtual_payment_query_order_request, classify_virtual_payment_notify, + is_profile_recharge_order_not_found, is_wechat_virtual_payment_order_paid, + paid_at_micros_from_wechat_virtual_payment_order, validate_wechat_pay_notify_order, + validate_wechat_virtual_payment_order, }; use crate::config::AppConfig; - use platform_wechat::WechatVirtualPaymentOrder; + use module_runtime::{ + RuntimeProfileRechargeOrderSnapshot, RuntimeProfileRechargeOrderStatus, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeRefundObservationSource, + build_runtime_profile_recharge_order_record, + }; + use platform_wechat::{ + WechatVirtualPaymentOrder, + pay::{WechatPayError, WechatPayRefund, build_virtual_payment_notify_debug_summary}, + }; + use spacetime_client::SpacetimeClientError; #[test] fn virtual_payment_query_uses_the_key_for_the_selected_environment() { @@ -473,6 +1165,22 @@ mod tests { assert_eq!(request.env, 1); } + #[test] + fn virtual_payment_only_acks_the_exact_missing_recharge_order_error() { + assert!(is_profile_recharge_order_not_found( + &SpacetimeClientError::Procedure("profile_recharge_order missing".to_string()) + )); + assert!(is_profile_recharge_order_not_found( + &SpacetimeClientError::Procedure("profile_recharge_order 不存在".to_string()) + )); + assert!(!is_profile_recharge_order_not_found( + &SpacetimeClientError::Procedure("SpacetimeDB 连接失败".to_string()) + )); + assert!(!is_profile_recharge_order_not_found( + &SpacetimeClientError::ConnectDropped + )); + } + #[test] fn virtual_payment_query_only_treats_paid_and_delivery_states_as_paid() { assert!(!is_wechat_virtual_payment_order_paid(0)); @@ -541,13 +1249,160 @@ mod tests { assert!(validate_wechat_virtual_payment_order("order-001", 600, &order).is_err()); } } + + #[test] + fn virtual_payment_debug_routing_does_not_credit_refunds_or_subscriptions() { + for (body, expected) in [ + ( + r#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1"}"#, + VirtualPaymentNotifyHandling::CreditOrder, + ), + ( + r#"{"Event":"xpay_goods_deliver_notify","AppleSubscriptionInfo":{"ProductId":"vip_month"}}"#, + VirtualPaymentNotifyHandling::DebugAcknowledge, + ), + ( + r#"{"Event":"xpay_goods_deliver_notify","OutContractCode":"contract-1","ContractWxAppid":"wx-app-1"}"#, + VirtualPaymentNotifyHandling::DebugAcknowledge, + ), + ( + r#"{"Event":"xpay_refund_notify","MchRefundId":"refund-1"}"#, + VirtualPaymentNotifyHandling::DebugAcknowledge, + ), + ( + r#"{"Event":"xpay_subscribe_ios_refund_query_notify","PayOrderId":"order-1"}"#, + VirtualPaymentNotifyHandling::DebugRetry, + ), + ( + r#"{"Event":"xpay_subscribe_ios_refund_query_notify","AppleSubscriptionInfo":{"ProductId":"vip_month"}}"#, + VirtualPaymentNotifyHandling::DebugRetry, + ), + ( + r#"{"Event":"xpay_future_notify"}"#, + VirtualPaymentNotifyHandling::DebugRetry, + ), + ] { + let summary = + build_virtual_payment_notify_debug_summary(body.as_bytes(), b"message-token") + .expect("debug routing fixture should parse"); + assert_eq!(classify_virtual_payment_notify(&summary), expected); + } + } + + #[test] + fn virtual_payment_ios_refund_query_has_no_fake_decision_response() { + let payload = serde_json::to_value(build_wechat_virtual_payment_notify_response( + 1, + "iOS退款问询尚未配置基于真实履约数据的自动决策".to_string(), + )) + .expect("iOS refund query retry response should serialize"); + + assert_eq!(payload["ErrCode"], 1); + assert!(payload.get("IosRefundQueryResponse").is_none()); + } + + #[tokio::test] + async fn v3_refund_notify_failure_uses_the_wechat_fail_response_contract() { + let response = build_wechat_pay_refund_notify_error_response( + WechatPayError::InvalidSignature("secret header value".to_string()), + ); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), 1_024) + .await + .expect("refund failure response body should read"); + let payload: serde_json::Value = + serde_json::from_slice(&body).expect("refund failure response should be JSON"); + + assert_eq!( + payload, + serde_json::json!({ + "code": "FAIL", + "message": "失败" + }) + ); + assert!(!String::from_utf8_lossy(&body).contains("secret header value")); + } + + #[test] + fn v3_refund_observation_preserves_verified_provider_money_and_status() { + let refund = WechatPayRefund { + mch_id: Some("1900000001".to_string()), + transaction_id: "tx-1".to_string(), + out_trade_no: "order-1".to_string(), + refund_id: "refund-1".to_string(), + out_refund_no: "merchant-refund-1".to_string(), + status: "SUCCESS".to_string(), + success_time: Some("2026-07-13T18:17:23+08:00".to_string()), + create_time: Some("2026-07-13T18:17:20+08:00".to_string()), + amount_total_cents: 600, + amount_refund_cents: 600, + amount_payer_total_cents: 600, + amount_payer_refund_cents: 600, + }; + let observation = build_wechat_pay_refund_observation( + "callback:event-1".to_string(), + RuntimeProfileRechargeRefundObservationSource::Callback, + Some("hmac:event-1".to_string()), + "hmac:payload-1".to_string(), + &refund, + 1_752_411_443_000_000, + ) + .expect("verified refund fact should build"); + + assert_eq!(observation.total_cents, 600); + assert_eq!(observation.refund_cents, 600); + assert_eq!(observation.order_id, "order-1"); + assert!(observation.success_at_micros.is_some()); + + let mut missing_success_time = refund; + missing_success_time.success_time = None; + assert!( + build_wechat_pay_refund_observation( + "callback:event-2".to_string(), + RuntimeProfileRechargeRefundObservationSource::Callback, + None, + "hmac:payload-2".to_string(), + &missing_success_time, + 1, + ) + .is_err() + ); + } + + #[test] + fn v3_payment_notify_requires_local_channel_amount_and_transaction_match() { + let order = + build_runtime_profile_recharge_order_record(RuntimeProfileRechargeOrderSnapshot { + order_id: "order-1".to_string(), + user_id: "user-1".to_string(), + product_id: "points-60".to_string(), + product_title: "60 points".to_string(), + kind: RuntimeProfileRechargeProductKind::Points, + amount_cents: 600, + status: RuntimeProfileRechargeOrderStatus::Paid, + payment_channel: "wechat_native".to_string(), + paid_at_micros: Some(1), + provider_transaction_id: Some("tx-1".to_string()), + created_at_micros: 1, + points_delta: 60, + membership_expires_at_micros: None, + expired_at_micros: None, + expiration_checked_at_micros: None, + expiration_provider_state: None, + expiration_last_error: None, + }); + + validate_wechat_pay_notify_order(&order, 600, "tx-1") + .expect("matching payment fact should pass"); + assert!(validate_wechat_pay_notify_order(&order, 601, "tx-1").is_err()); + assert!(validate_wechat_pay_notify_order(&order, 600, "tx-2").is_err()); + } } fn build_virtual_payment_notify_error_response( error: WechatPayError, response_format: VirtualPaymentNotifyResponseFormat, ) -> Response { - warn!(error = %error, "微信虚拟支付通知处理失败"); let message = match error { WechatPayError::Disabled => "微信虚拟支付暂未启用".to_string(), WechatPayError::InvalidConfig(message) diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index fc804bc82..c017d706c 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -2005,6 +2005,193 @@ pub fn resolve_runtime_profile_membership_purchase_update( } } +pub fn calculate_runtime_profile_recharge_refund_target_points( + order_points_delta: i64, + cumulative_success_refund_cents: u64, + order_amount_cents: u64, +) -> Result { + if order_amount_cents == 0 { + return Err("recharge refund order amount must be positive".to_string()); + } + if cumulative_success_refund_cents > order_amount_cents { + return Err("recharge refund cumulative amount exceeds order amount".to_string()); + } + if order_points_delta <= 0 || cumulative_success_refund_cents == 0 { + return Ok(0); + } + + let order_points = order_points_delta as u64; + if cumulative_success_refund_cents == order_amount_cents { + return Ok(order_points); + } + let target = u128::from(order_points) + .saturating_mul(u128::from(cumulative_success_refund_cents)) + / u128::from(order_amount_cents); + u64::try_from(target).map_err(|_| "recharge refund target points overflow".to_string()) +} + +pub fn resolve_runtime_profile_recharge_refund_recovery( + outstanding_points: u64, + wallet_total_points: u64, + daily_free_points: u64, + membership_limited_points: u64, +) -> (u64, u64) { + resolve_runtime_profile_recharge_refund_recovery_with_holds( + outstanding_points, + wallet_total_points, + daily_free_points, + membership_limited_points, + 0, + ) +} + +pub fn resolve_runtime_profile_recharge_refund_recovery_with_holds( + outstanding_points: u64, + wallet_total_points: u64, + daily_free_points: u64, + membership_limited_points: u64, + unrelated_held_points: u64, +) -> (u64, u64) { + let permanent_points = wallet_total_points + .saturating_sub(daily_free_points) + .saturating_sub(membership_limited_points); + let available_permanent_points = permanent_points.saturating_sub(unrelated_held_points); + let recoverable_points = outstanding_points.min(available_permanent_points); + ( + recoverable_points, + outstanding_points.saturating_sub(recoverable_points), + ) +} + +pub fn validate_runtime_profile_recharge_refund_hold_capacity( + required_points: u64, + permanent_points: u64, + active_held_points: u64, +) -> Result { + let available_points = permanent_points.saturating_sub(active_held_points); + if required_points > available_points { + return Err(format!( + "可追回永久泥点不足:需要 {required_points},当前可用 {available_points}" + )); + } + Ok(available_points) +} + +pub fn resolve_runtime_profile_recharge_refund_hold_points( + incremental_target_recovery_points: u64, + refund_cents: u64, + remaining_refundable_cents: u64, +) -> u64 { + // A concurrent external partial refund can move the cumulative floor boundary by one point. + let concurrency_buffer = u64::from(refund_cents < remaining_refundable_cents); + incremental_target_recovery_points.saturating_add(concurrency_buffer) +} + +pub fn validate_runtime_profile_wallet_debit_restrictions( + amount_delta: i64, + source_type: RuntimeProfileWalletLedgerSourceType, + manual_frozen: bool, + refund_debt_frozen: bool, +) -> Result<(), String> { + if amount_delta >= 0 + || source_type == RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery + { + return Ok(()); + } + if manual_frozen { + return Err("账户已被人工冻结,暂不可继续消费泥点".to_string()); + } + if refund_debt_frozen { + return Err("账户存在充值退款权益欠款,暂不可继续消费泥点".to_string()); + } + Ok(()) +} + +pub fn validate_runtime_profile_wallet_debit_availability( + wallet_total_points: u64, + active_held_points: u64, + debit_points: u64, +) -> Result { + let spendable_points = wallet_total_points.saturating_sub(active_held_points); + if debit_points > spendable_points { + return Err(format!( + "可消费泥点不足:需要 {debit_points},扣除退款占用后可用 {spendable_points}" + )); + } + Ok(spendable_points.saturating_sub(debit_points)) +} + +pub fn build_runtime_profile_recharge_refund_settlement_plan( + current_successful_refund_count: u32, + current_cumulative_success_refund_cents: u64, + current_target_recovery_points: u64, + refund_cents: u64, + order_amount_cents: u64, + order_points_delta: i64, +) -> Result { + if current_successful_refund_count >= 50 { + return Err("recharge refund count exceeds limit".to_string()); + } + let cumulative_success_refund_cents = current_cumulative_success_refund_cents + .checked_add(refund_cents) + .ok_or_else(|| "recharge refund cumulative amount overflow".to_string())?; + if cumulative_success_refund_cents > order_amount_cents { + return Err("recharge refund cumulative amount exceeds order amount".to_string()); + } + let target_recovery_points = calculate_runtime_profile_recharge_refund_target_points( + order_points_delta, + cumulative_success_refund_cents, + order_amount_cents, + )?; + if target_recovery_points < current_target_recovery_points { + return Err("recharge refund target points regressed".to_string()); + } + Ok(RuntimeProfileRechargeRefundSettlementPlan { + successful_refund_count: current_successful_refund_count.saturating_add(1), + cumulative_success_refund_cents, + order_fully_refunded: cumulative_success_refund_cents == order_amount_cents, + target_recovery_points, + incremental_target_recovery_points: target_recovery_points + .saturating_sub(current_target_recovery_points), + }) +} + +pub fn resolve_runtime_profile_recharge_refund_status_transition( + current: RuntimeProfileRechargeRefundStatus, + observed: RuntimeProfileRechargeRefundStatus, +) -> RuntimeProfileRechargeRefundStatusTransition { + use RuntimeProfileRechargeRefundStatus::{Abnormal, Closed, Processing, Success}; + use RuntimeProfileRechargeRefundStatusTransition::{Advance, Conflict, Unchanged}; + + if current == observed { + return Unchanged; + } + match (current, observed) { + (Processing, Abnormal | Closed | Success) | (Abnormal, Closed | Success) => Advance, + (Success | Closed, _) | (Abnormal, Processing) => Conflict, + _ => Conflict, + } +} + +pub fn resolve_runtime_profile_recharge_refund_hold_status( + current: RuntimeProfileRechargeRefundHoldStatus, + provider_status: RuntimeProfileRechargeRefundStatus, +) -> RuntimeProfileRechargeRefundHoldStatus { + match provider_status { + RuntimeProfileRechargeRefundStatus::Success => { + RuntimeProfileRechargeRefundHoldStatus::Settled + } + RuntimeProfileRechargeRefundStatus::Closed + if current != RuntimeProfileRechargeRefundHoldStatus::Settled => + { + RuntimeProfileRechargeRefundHoldStatus::Released + } + RuntimeProfileRechargeRefundStatus::Processing + | RuntimeProfileRechargeRefundStatus::Abnormal + | RuntimeProfileRechargeRefundStatus::Closed => current, + } +} + pub fn build_runtime_profile_invite_code(user_id: &str, salt: u32) -> String { let mut hash = 14_695_981_039_346_656_037u64; for byte in user_id.as_bytes().iter().copied().chain(salt.to_le_bytes()) { diff --git a/server-rs/crates/module-runtime/src/commands.rs b/server-rs/crates/module-runtime/src/commands.rs index 6de8a1c63..2acb74eec 100644 --- a/server-rs/crates/module-runtime/src/commands.rs +++ b/server-rs/crates/module-runtime/src/commands.rs @@ -413,6 +413,279 @@ pub fn build_runtime_profile_recharge_order_paid_input( }) } +fn normalize_profile_recharge_refund_identifier( + value: String, + field_name: &str, +) -> Result { + let normalized = + normalize_required_string(value).ok_or_else(|| format!("{field_name} 不能为空"))?; + if normalized.len() > 128 { + return Err(format!("{field_name} 超出 128 bytes 上限")); + } + Ok(normalized) +} + +#[allow(clippy::too_many_arguments)] +pub fn build_runtime_profile_recharge_refund_observation_input( + observation_id: String, + source: RuntimeProfileRechargeRefundObservationSource, + notification_ref: Option, + payload_fingerprint: String, + out_refund_no: String, + provider_refund_id: String, + order_id: String, + provider_transaction_id: String, + provider_status: RuntimeProfileRechargeRefundStatus, + total_cents: u64, + refund_cents: u64, + payer_total_cents: u64, + payer_refund_cents: u64, + success_at_micros: Option, + observed_at_micros: i64, +) -> Result { + let observation_id = + normalize_profile_recharge_refund_identifier(observation_id, "refund.observation_id")?; + let payload_fingerprint = normalize_profile_recharge_refund_identifier( + payload_fingerprint, + "refund.payload_fingerprint", + )?; + let out_refund_no = + normalize_profile_recharge_refund_identifier(out_refund_no, "refund.out_refund_no")?; + let provider_refund_id = normalize_profile_recharge_refund_identifier( + provider_refund_id, + "refund.provider_refund_id", + )?; + let order_id = normalize_profile_recharge_refund_identifier(order_id, "refund.order_id")?; + let provider_transaction_id = normalize_profile_recharge_refund_identifier( + provider_transaction_id, + "refund.provider_transaction_id", + )?; + if total_cents == 0 { + return Err("refund.total_cents 必须大于 0".to_string()); + } + if refund_cents == 0 || refund_cents > total_cents { + return Err("refund.refund_cents 必须大于 0 且不超过 total_cents".to_string()); + } + if payer_refund_cents > payer_total_cents { + return Err("refund.payer_refund_cents 不能超过 payer_total_cents".to_string()); + } + if payer_total_cents > total_cents || payer_refund_cents > refund_cents { + return Err("refund payer 金额不能超过对应订单或退款金额".to_string()); + } + if observed_at_micros <= 0 { + return Err("refund.observed_at_micros 必须大于 0".to_string()); + } + if provider_status == RuntimeProfileRechargeRefundStatus::Success + && success_at_micros.filter(|value| *value > 0).is_none() + { + return Err("SUCCESS 退款必须提供 success_at_micros".to_string()); + } + + Ok(RuntimeProfileRechargeRefundObservationInput { + observation_id, + source, + notification_ref: normalize_optional_string(notification_ref), + payload_fingerprint, + out_refund_no, + provider_refund_id, + order_id, + provider_transaction_id, + provider_status, + total_cents, + refund_cents, + payer_total_cents, + payer_refund_cents, + success_at_micros, + observed_at_micros, + }) +} + +pub fn build_runtime_profile_recharge_refund_get_input( + out_refund_no: String, +) -> Result { + Ok(RuntimeProfileRechargeRefundGetInput { + out_refund_no: normalize_profile_recharge_refund_identifier( + out_refund_no, + "refund.out_refund_no", + )?, + }) +} + +pub fn build_runtime_profile_recharge_refund_reconciliation_list_input( + limit: u32, +) -> RuntimeProfileRechargeRefundReconciliationListInput { + RuntimeProfileRechargeRefundReconciliationListInput { + limit: if limit == 0 { 100 } else { limit.min(500) }, + } +} + +pub fn build_runtime_profile_recharge_order_admin_list_input( + order_id: Option, + user_id: Option, + provider_transaction_id: Option, + payment_channel: Option, + status: Option, + created_after_micros: Option, + created_before_micros: Option, + limit: u32, +) -> Result { + if let (Some(after), Some(before)) = (created_after_micros, created_before_micros) + && (after <= 0 || before <= 0 || after > before) + { + return Err("recharge_order 查询时间范围无效".to_string()); + } + Ok(RuntimeProfileRechargeOrderAdminListInput { + order_id: normalize_optional_string(order_id), + user_id: normalize_optional_string(user_id), + provider_transaction_id: normalize_optional_string(provider_transaction_id), + payment_channel: normalize_optional_string(payment_channel), + status, + created_after_micros, + created_before_micros, + limit: if limit == 0 { 100 } else { limit.min(500) }, + }) +} + +pub fn build_runtime_profile_recharge_refund_hold_preview_input( + order_id: String, + refund_cents: u64, +) -> Result { + if refund_cents == 0 { + return Err("refund_hold.refund_cents 必须大于 0".to_string()); + } + Ok(RuntimeProfileRechargeRefundHoldPreviewInput { + order_id: normalize_profile_recharge_refund_identifier(order_id, "refund_hold.order_id")?, + refund_cents, + }) +} + +pub fn build_runtime_profile_recharge_refund_hold_prepare_input( + order_id: String, + out_refund_no: String, + refund_cents: u64, + admin_user_id: String, + reason: String, +) -> Result { + let preview = build_runtime_profile_recharge_refund_hold_preview_input(order_id, refund_cents)?; + Ok(RuntimeProfileRechargeRefundHoldPrepareInput { + order_id: preview.order_id, + out_refund_no: normalize_profile_recharge_refund_identifier( + out_refund_no, + "refund_hold.out_refund_no", + )?, + refund_cents: preview.refund_cents, + admin_user_id: normalize_profile_recharge_refund_identifier( + admin_user_id, + "refund_hold.admin_user_id", + )?, + reason: normalize_profile_recharge_refund_reason(reason, "refund_hold.reason")?, + }) +} + +pub fn build_runtime_profile_recharge_refund_hold_release_input( + out_refund_no: String, + admin_user_id: String, + release_reason: String, +) -> Result { + Ok(RuntimeProfileRechargeRefundHoldReleaseInput { + out_refund_no: normalize_profile_recharge_refund_identifier( + out_refund_no, + "refund_hold.out_refund_no", + )?, + admin_user_id: normalize_profile_recharge_refund_identifier( + admin_user_id, + "refund_hold.admin_user_id", + )?, + release_reason: normalize_profile_recharge_refund_reason( + release_reason, + "refund_hold.release_reason", + )?, + }) +} + +pub fn build_runtime_profile_recharge_refund_hold_list_input( + limit: u32, +) -> RuntimeProfileRechargeRefundHoldListInput { + RuntimeProfileRechargeRefundHoldListInput { + limit: if limit == 0 { 100 } else { limit.min(500) }, + } +} + +pub fn build_runtime_profile_admin_wallet_get_input( + user_id: String, +) -> Result { + Ok(RuntimeProfileAdminWalletGetInput { + user_id: normalize_profile_recharge_refund_identifier(user_id, "wallet.user_id")?, + }) +} + +pub fn build_runtime_profile_wallet_manual_restriction_upsert_input( + user_id: String, + frozen: bool, + reason: String, + admin_user_id: String, +) -> Result { + Ok(RuntimeProfileWalletManualRestrictionUpsertInput { + user_id: normalize_profile_recharge_refund_identifier(user_id, "restriction.user_id")?, + frozen, + reason: normalize_profile_recharge_refund_reason(reason, "restriction.reason")?, + admin_user_id: normalize_profile_recharge_refund_identifier( + admin_user_id, + "restriction.admin_user_id", + )?, + }) +} + +fn normalize_profile_recharge_refund_reason( + value: String, + field_name: &str, +) -> Result { + let normalized = + normalize_required_string(value).ok_or_else(|| format!("{field_name} 不能为空"))?; + if normalized.len() > 256 { + return Err(format!("{field_name} 超出 256 bytes 上限")); + } + Ok(normalized) +} + +pub fn build_runtime_profile_recharge_refund_bill_checkpoint_get_input( + checkpoint_id: String, +) -> Result { + Ok(RuntimeProfileRechargeRefundBillCheckpointGetInput { + checkpoint_id: normalize_profile_recharge_refund_identifier( + checkpoint_id, + "refund_bill.checkpoint_id", + )?, + }) +} + +pub fn build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( + checkpoint_id: String, + bill_date: String, + bill_hash: String, + processed_refund_count: u32, + completed_at_micros: i64, +) -> Result { + let checkpoint_id = + normalize_profile_recharge_refund_identifier(checkpoint_id, "refund_bill.checkpoint_id")?; + let bill_date = + normalize_profile_recharge_refund_identifier(bill_date, "refund_bill.bill_date")?; + crate::parse_analytics_calendar_date_key(&bill_date) + .map_err(|_| "refund_bill.bill_date 必须是合法 YYYY-MM-DD 日期".to_string())?; + let bill_hash = + normalize_profile_recharge_refund_identifier(bill_hash, "refund_bill.bill_hash")?; + if completed_at_micros <= 0 { + return Err("refund_bill.completed_at_micros 必须大于 0".to_string()); + } + Ok(RuntimeProfileRechargeRefundBillCheckpointAdvanceInput { + checkpoint_id, + bill_date, + bill_hash, + processed_refund_count, + completed_at_micros, + }) +} + pub fn build_runtime_profile_recharge_order_close_input( order_id: String, closed_at_micros: i64, diff --git a/server-rs/crates/module-runtime/src/domain.rs b/server-rs/crates/module-runtime/src/domain.rs index 5be01a58a..56a1155ef 100644 --- a/server-rs/crates/module-runtime/src/domain.rs +++ b/server-rs/crates/module-runtime/src/domain.rs @@ -1091,6 +1091,7 @@ pub enum RuntimeProfileWalletLedgerSourceType { MembershipPeriodReset, DailyFreeGrant, DailyFreeReset, + RechargeRefundRecovery, } impl RuntimeProfileWalletLedgerSourceType { @@ -1110,6 +1111,7 @@ impl RuntimeProfileWalletLedgerSourceType { Self::DailyTaskReward => "daily_task_reward", Self::DailyFreeGrant => "daily_free_grant", Self::DailyFreeReset => "daily_free_reset", + Self::RechargeRefundRecovery => "recharge_refund_recovery", } } } @@ -1203,6 +1205,102 @@ pub enum RuntimeProfileRechargeOrderStatus { Expired, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRechargeRefundStatus { + Processing, + Success, + Abnormal, + Closed, +} + +impl RuntimeProfileRechargeRefundStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Processing => "processing", + Self::Success => "success", + Self::Abnormal => "abnormal", + Self::Closed => "closed", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRechargeRefundObservationSource { + ApiRequest, + Callback, + Query, + TradeBill, +} + +impl RuntimeProfileRechargeRefundObservationSource { + pub fn as_str(&self) -> &'static str { + match self { + Self::ApiRequest => "api_request", + Self::Callback => "callback", + Self::Query => "query", + Self::TradeBill => "trade_bill", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRechargeRefundRecoveryStatus { + Pending, + Applied, + Shortfall, + ManualReview, + NotApplicable, +} + +impl RuntimeProfileRechargeRefundRecoveryStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Pending => "pending", + Self::Applied => "applied", + Self::Shortfall => "shortfall", + Self::ManualReview => "manual_review", + Self::NotApplicable => "not_applicable", + } + } +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RuntimeProfileRechargeRefundHoldStatus { + Active, + Settled, + Released, +} + +impl RuntimeProfileRechargeRefundHoldStatus { + pub fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::Settled => "settled", + Self::Released => "released", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeProfileRechargeRefundStatusTransition { + Unchanged, + Advance, + Conflict, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RuntimeProfileRechargeRefundSettlementPlan { + pub successful_refund_count: u32, + pub cumulative_success_refund_cents: u64, + pub order_fully_refunded: bool, + pub target_recovery_points: u64, + pub incremental_target_recovery_points: u64, +} + impl RuntimeProfileRechargeOrderStatus { pub fn as_str(&self) -> &'static str { match self { @@ -1312,6 +1410,138 @@ pub struct RuntimeProfileRechargeOrderSnapshot { pub expiration_last_error: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundSnapshot { + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub user_id: Option, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub first_observed_at_micros: i64, + pub updated_at_micros: i64, + pub last_observation_source: RuntimeProfileRechargeRefundObservationSource, + pub last_observation_id: String, + pub order_settled_at_micros: Option, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub last_recovery_ledger_id: Option, + pub last_error_code: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundObservationSnapshot { + pub observation_id: String, + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub source: RuntimeProfileRechargeRefundObservationSource, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub notification_ref: Option, + pub payload_fingerprint: String, + pub resolution_code: String, + pub observed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderRefundSettlementSnapshot { + pub order_id: String, + pub user_id: String, + pub successful_refund_count: u32, + pub cumulative_success_refund_cents: u64, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub wallet_frozen: bool, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldSnapshot { + pub out_refund_no: String, + pub order_id: String, + pub user_id: String, + pub refund_cents: u64, + pub held_points: u64, + pub status: RuntimeProfileRechargeRefundHoldStatus, + pub admin_user_id: String, + pub reason: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub settled_at_micros: Option, + pub released_at_micros: Option, + pub released_by_admin_user_id: Option, + pub release_reason: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletManualRestrictionSnapshot { + pub user_id: String, + pub frozen: bool, + pub reason: String, + pub created_by_admin_user_id: String, + pub created_at_micros: i64, + pub updated_by_admin_user_id: String, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAdminWalletSnapshot { + pub user_id: String, + pub total_balance: u64, + pub spendable_balance: u64, + pub daily_free_points: u64, + pub membership_limited_points: u64, + pub permanent_points: u64, + pub held_points: u64, + pub refund_debt_points: u64, + pub manual_frozen: bool, + pub refund_debt_frozen: bool, + pub wallet_frozen: bool, + pub manual_restriction: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { + pub order: RuntimeProfileRechargeOrderSnapshot, + pub settlement: Option, + pub refunds: Vec, + pub active_hold: Option, + pub wallet: RuntimeProfileAdminWalletSnapshot, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundBillCheckpointSnapshot { + pub checkpoint_id: String, + pub bill_date: String, + pub bill_hash: String, + pub processed_refund_count: u32, + pub completed_at_micros: i64, + pub updated_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeCenterSnapshot { @@ -1416,6 +1646,113 @@ pub struct RuntimeProfileRechargeOrderPaidInput { pub provider_transaction_id: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundObservationInput { + pub observation_id: String, + pub source: RuntimeProfileRechargeRefundObservationSource, + pub notification_ref: Option, + pub payload_fingerprint: String, + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub observed_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundGetInput { + pub out_refund_no: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundReconciliationListInput { + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderAdminListInput { + pub order_id: Option, + pub user_id: Option, + pub provider_transaction_id: Option, + pub payment_channel: Option, + pub status: Option, + pub created_after_micros: Option, + pub created_before_micros: Option, + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldPreviewInput { + pub order_id: String, + pub refund_cents: u64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldPrepareInput { + pub order_id: String, + pub out_refund_no: String, + pub refund_cents: u64, + pub admin_user_id: String, + pub reason: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldReleaseInput { + pub out_refund_no: String, + pub admin_user_id: String, + pub release_reason: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldListInput { + pub limit: u32, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAdminWalletGetInput { + pub user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileWalletManualRestrictionUpsertInput { + pub user_id: String, + pub frozen: bool, + pub reason: String, + pub admin_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundBillCheckpointGetInput { + pub checkpoint_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundBillCheckpointAdvanceInput { + pub checkpoint_id: String, + pub bill_date: String, + pub bill_hash: String, + pub processed_refund_count: u32, + pub completed_at_micros: i64, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileRechargeOrderCloseInput { @@ -1496,6 +1833,68 @@ pub struct RuntimeProfileRechargeOrderExpirationCheckProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundProcedureResult { + pub ok: bool, + pub record: Option, + pub settlement: Option, + pub duplicate: bool, + pub resolution_code: String, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeOrderAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldProcedureResult { + pub ok: bool, + pub record: Option, + pub order: Option, + pub settlement: Option, + pub wallet: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundHoldListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileAdminWalletProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct RuntimeProfileWalletLedgerEntrySnapshot { diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index 2def15c25..8ee0cba40 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -906,6 +906,10 @@ mod tests { RuntimeProfileWalletLedgerSourceType::DailyFreeReset.as_str(), "daily_free_reset" ); + assert_eq!( + RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery.as_str(), + "recharge_refund_recovery" + ); } #[test] @@ -1226,6 +1230,293 @@ mod tests { ); } + #[test] + fn recharge_refund_target_points_uses_cumulative_floor_and_finishes_exactly() { + assert_eq!( + calculate_runtime_profile_recharge_refund_target_points(270, 1, 1_800), + Ok(0) + ); + assert_eq!( + calculate_runtime_profile_recharge_refund_target_points(270, 600, 1_800), + Ok(90) + ); + assert_eq!( + calculate_runtime_profile_recharge_refund_target_points(270, 1_799, 1_800), + Ok(269) + ); + assert_eq!( + calculate_runtime_profile_recharge_refund_target_points(270, 1_800, 1_800), + Ok(270) + ); + assert!( + calculate_runtime_profile_recharge_refund_target_points(270, 1_801, 1_800).is_err() + ); + } + + #[test] + fn recharge_refund_settlement_plan_keeps_partial_paid_and_full_exact() { + let partial = + build_runtime_profile_recharge_refund_settlement_plan(0, 0, 0, 600, 1_800, 270) + .unwrap(); + assert!(!partial.order_fully_refunded); + assert_eq!(partial.cumulative_success_refund_cents, 600); + assert_eq!(partial.incremental_target_recovery_points, 90); + + let full = build_runtime_profile_recharge_refund_settlement_plan( + partial.successful_refund_count, + partial.cumulative_success_refund_cents, + partial.target_recovery_points, + 1_200, + 1_800, + 270, + ) + .unwrap(); + assert!(full.order_fully_refunded); + assert_eq!(full.target_recovery_points, 270); + assert_eq!(full.incremental_target_recovery_points, 180); + assert!( + build_runtime_profile_recharge_refund_settlement_plan( + full.successful_refund_count, + full.cumulative_success_refund_cents, + full.target_recovery_points, + 1, + 1_800, + 270, + ) + .is_err() + ); + } + + #[test] + fn recharge_refund_recovery_only_uses_permanent_points() { + assert_eq!( + resolve_runtime_profile_recharge_refund_recovery(60, 150, 20, 80), + (50, 10) + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_recovery(60, 100, 20, 80), + (0, 60) + ); + } + + #[test] + fn recharge_refund_recovery_protects_unrelated_holds_and_repaid_debt_uses_permanent_points() { + assert_eq!( + resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 150, 20, 80, 30), + (20, 40) + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 170, 40, 80, 30), + (20, 40), + "新增每日免费泥点不能偿还退款欠账" + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_recovery_with_holds(60, 190, 20, 80, 30), + (60, 0), + "新增永久泥点应可偿清退款欠账" + ); + } + + #[test] + fn recharge_refund_hold_capacity_rejects_insufficient_permanent_points() { + assert_eq!( + validate_runtime_profile_recharge_refund_hold_capacity(90, 270, 0), + Ok(270) + ); + assert_eq!( + validate_runtime_profile_recharge_refund_hold_capacity(180, 270, 90), + Ok(180) + ); + assert!(validate_runtime_profile_recharge_refund_hold_capacity(181, 270, 90).is_err()); + } + + #[test] + fn recharge_refund_partial_hold_reserves_one_point_for_cumulative_rounding_races() { + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_points(30, 300, 600), + 31 + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_points(60, 600, 600), + 60, + "全额退款的累计目标是精确值,不需要舍入缓冲" + ); + } + + #[test] + fn wallet_debit_restrictions_block_manual_freeze_and_refund_debt() { + let consume = RuntimeProfileWalletLedgerSourceType::AssetOperationConsume; + assert!( + validate_runtime_profile_wallet_debit_restrictions(-1, consume, true, false).is_err() + ); + assert!( + validate_runtime_profile_wallet_debit_restrictions(-1, consume, false, true).is_err() + ); + assert!( + validate_runtime_profile_wallet_debit_restrictions( + -1, + RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery, + true, + true, + ) + .is_ok(), + "退款追回应绕过消费冻结" + ); + } + + #[test] + fn wallet_debit_availability_protects_active_refund_holds() { + assert_eq!( + validate_runtime_profile_wallet_debit_availability(100, 40, 60), + Ok(0) + ); + assert!(validate_runtime_profile_wallet_debit_availability(100, 40, 61).is_err()); + } + + #[test] + fn recharge_refund_status_transition_never_regresses_success() { + assert_eq!( + resolve_runtime_profile_recharge_refund_status_transition( + RuntimeProfileRechargeRefundStatus::Processing, + RuntimeProfileRechargeRefundStatus::Abnormal, + ), + RuntimeProfileRechargeRefundStatusTransition::Advance + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_status_transition( + RuntimeProfileRechargeRefundStatus::Abnormal, + RuntimeProfileRechargeRefundStatus::Success, + ), + RuntimeProfileRechargeRefundStatusTransition::Advance + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_status_transition( + RuntimeProfileRechargeRefundStatus::Success, + RuntimeProfileRechargeRefundStatus::Closed, + ), + RuntimeProfileRechargeRefundStatusTransition::Conflict + ); + } + + #[test] + fn recharge_refund_hold_follows_terminal_provider_status_only() { + use RuntimeProfileRechargeRefundHoldStatus::{Active, Released, Settled}; + use RuntimeProfileRechargeRefundStatus::{Abnormal, Closed, Processing, Success}; + + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Active, Processing), + Active + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Active, Abnormal), + Active + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Active, Closed), + Released + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Active, Success), + Settled + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Settled, Closed), + Settled + ); + assert_eq!( + resolve_runtime_profile_recharge_refund_hold_status(Released, Success), + Settled + ); + } + + #[test] + fn recharge_refund_observation_validates_money_and_success_time() { + let valid = build_runtime_profile_recharge_refund_observation_input( + "observation-1".to_string(), + RuntimeProfileRechargeRefundObservationSource::Callback, + Some("notification-ref".to_string()), + "payload-ref".to_string(), + "refund-1".to_string(), + "wx-refund-1".to_string(), + "order-1".to_string(), + "transaction-1".to_string(), + RuntimeProfileRechargeRefundStatus::Success, + 600, + 600, + 600, + 600, + Some(100), + 200, + ) + .unwrap(); + assert_eq!(valid.refund_cents, 600); + + assert!( + build_runtime_profile_recharge_refund_observation_input( + "observation-1".to_string(), + RuntimeProfileRechargeRefundObservationSource::Callback, + None, + "payload-ref".to_string(), + "refund-1".to_string(), + "wx-refund-1".to_string(), + "order-1".to_string(), + "transaction-1".to_string(), + RuntimeProfileRechargeRefundStatus::Success, + 600, + 601, + 600, + 600, + None, + 200, + ) + .is_err() + ); + assert!( + build_runtime_profile_recharge_refund_observation_input( + "observation-2".to_string(), + RuntimeProfileRechargeRefundObservationSource::Query, + None, + "payload-ref-2".to_string(), + "refund-2".to_string(), + "wx-refund-2".to_string(), + "order-2".to_string(), + "transaction-2".to_string(), + RuntimeProfileRechargeRefundStatus::Processing, + 600, + 300, + 601, + 300, + None, + 200, + ) + .is_err() + ); + } + + #[test] + fn recharge_refund_bill_checkpoint_requires_a_real_calendar_date() { + assert!( + build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( + "wechat-v3-refund-bill".to_string(), + "2026-02-29".to_string(), + "sha1-ref".to_string(), + 1, + 100, + ) + .is_err() + ); + assert!( + build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( + "wechat-v3-refund-bill".to_string(), + "2026-07-13".to_string(), + "sha1-ref".to_string(), + 1, + 100, + ) + .is_ok() + ); + } + #[test] fn runtime_profile_wallet_balance_calculation_guards_edges() { assert_eq!( diff --git a/server-rs/crates/platform-wechat/Cargo.toml b/server-rs/crates/platform-wechat/Cargo.toml index 2d6f4d436..872319740 100644 --- a/server-rs/crates/platform-wechat/Cargo.toml +++ b/server-rs/crates/platform-wechat/Cargo.toml @@ -8,6 +8,8 @@ license.workspace = true aes = { workspace = true } base64 = { workspace = true } cbc = { workspace = true } +csv = { workspace = true } +flate2 = { workspace = true } hex = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls"] } ring = { workspace = true } @@ -24,4 +26,5 @@ urlencoding = { workspace = true } x509-parser = { workspace = true } [dev-dependencies] +openssl = "0.10" tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/server-rs/crates/platform-wechat/src/pay.rs b/server-rs/crates/platform-wechat/src/pay.rs index ea8b77083..bcd582d83 100644 --- a/server-rs/crates/platform-wechat/src/pay.rs +++ b/server-rs/crates/platform-wechat/src/pay.rs @@ -1,7 +1,10 @@ use std::{ + collections::BTreeMap, fs, + io::Read, path::{Path, PathBuf}, sync::Arc, + time::Duration as StdDuration, }; use aes::Aes256; @@ -10,9 +13,10 @@ use base64::{ engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD as BASE64_STANDARD}, }; use cbc::cipher::{BlockDecryptMut, KeyIvInit, block_padding::NoPadding}; +use flate2::read::GzDecoder; use reqwest::header::HeaderMap; use ring::{ - aead, + aead, hmac, rand::{SecureRandom, SystemRandom}, signature, }; @@ -42,17 +46,28 @@ const WECHAT_PAY_CONTENT_TYPE_HEADER: &str = "application/json"; const WECHAT_PAY_USER_AGENT: &str = "Genarrative-WechatPay/1.0"; const WECHAT_PAY_SERIAL_HEADER: &str = "Wechatpay-Serial"; const WECHAT_PAY_SIGNATURE_TEST_PREFIX: &str = "WECHATPAY/SIGNTEST/"; +const WECHAT_PAY_HTTP_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(5); +const WECHAT_PAY_HTTP_REQUEST_TIMEOUT: StdDuration = StdDuration::from_secs(30); const WECHAT_PAY_APP_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_MCH_ID_MAX_CHARS: usize = 32; const WECHAT_PAY_DESCRIPTION_MAX_CHARS: usize = 127; const WECHAT_PAY_OUT_TRADE_NO_MAX_CHARS: usize = 32; +const WECHAT_PAY_OUT_REFUND_NO_MAX_CHARS: usize = 64; +const WECHAT_PAY_REFUND_REASON_MAX_CHARS: usize = 80; const WECHAT_PAY_NOTIFY_URL_MAX_CHARS: usize = 255; const WECHAT_PAY_OPENID_MAX_CHARS: usize = 128; 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_PAY_REFUND_PATH: &str = "/v3/refund/domestic/refunds"; +const WECHAT_PAY_TRADE_BILL_PATH: &str = "/v3/bill/tradebill"; +const WECHAT_PAY_BILL_DOWNLOAD_PATH: &str = "/v3/billdownload/file"; const WECHAT_PAY_ORDER_EXPIRE_SECONDS: i64 = 5 * 60; +const WECHAT_PAY_NOTIFY_RESOURCE_TYPE: &str = "encrypt-resource"; +const WECHAT_PAY_NOTIFY_RESOURCE_ALGORITHM: &str = "AEAD_AES_256_GCM"; +const WECHAT_PAY_REFUND_RESOURCE_ORIGINAL_TYPE: &str = "refund"; +const WECHAT_PAY_NOTIFY_TIMESTAMP_TOLERANCE_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; @@ -61,6 +76,23 @@ const WECHAT_MINIPROGRAM_MESSAGE_AES_KEY_BASE64: GeneralPurpose = GeneralPurpose &alphabet::STANDARD, GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true), ); +const WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_SCHEMA_FIELDS: usize = 128; +const WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_DEPTH: usize = 12; + +pub const WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS: [&str; 9] = [ + "xpay_goods_deliver_notify", + "xpay_coin_pay_notify", + "xpay_refund_notify", + "xpay_complaint_notify", + "xpay_wxpay_callback_notify", + "xpay_subscribe_signing_result_notify", + "xpay_subscribe_pay_fail_notify", + "xpay_apple_subscribe_signing_result_notify", + "xpay_subscribe_ios_refund_query_notify", +]; + +pub const WECHAT_PAY_REFUND_NOTIFY_EVENTS: [&str; 3] = + ["REFUND.SUCCESS", "REFUND.ABNORMAL", "REFUND.CLOSED"]; #[derive(Clone, Debug)] pub struct WechatPayConfig { @@ -101,6 +133,9 @@ pub struct RealWechatPayClient { h5_endpoint: String, native_endpoint: String, query_order_endpoint_base: String, + refund_endpoint: String, + trade_bill_endpoint: String, + api_origin: String, } #[derive(Clone, Debug)] @@ -121,10 +156,104 @@ pub struct WechatWebOrderRequest { #[derive(Clone, Debug)] pub struct WechatPayNotifyOrder { + pub app_id: Option, + pub mch_id: Option, pub out_trade_no: String, pub transaction_id: Option, pub trade_state: String, pub success_time: Option, + pub amount_total_cents: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPayRefundRequest { + pub transaction_id: String, + pub out_trade_no: String, + pub out_refund_no: String, + pub reason: Option, + pub notify_url: String, + pub refund_amount_cents: u64, + pub total_amount_cents: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPayRefund { + pub mch_id: Option, + pub transaction_id: String, + pub out_trade_no: String, + pub refund_id: String, + pub out_refund_no: String, + pub status: String, + pub success_time: Option, + pub create_time: Option, + pub amount_total_cents: u64, + pub amount_refund_cents: u64, + pub amount_payer_total_cents: u64, + pub amount_payer_refund_cents: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPayRefundNotification { + pub notification_id: String, + pub create_time: String, + pub event_type: String, + pub refund: WechatPayRefund, + pub debug: WechatPayRefundNotifyDebugSummary, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPayTradeBillDownload { + pub hash_type: String, + pub hash_value: String, + pub download_url: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WechatPayTradeBillRefundRow { + pub transaction_id: String, + pub out_trade_no: String, + pub refund_id: String, + pub out_refund_no: String, + pub accepted_time: Option, + pub success_time: Option, + pub refund_type: String, + pub bill_refund_status: String, + pub requested_refund_cents: u64, + pub refunded_cents: u64, + pub coupon_refund_cents: u64, + pub app_id: Option, + pub mch_id: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct WechatPayRefundNotifyDebugText { + pub bytes: usize, + pub hmac_ref: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct WechatPayRefundNotifyDebugSummary { + pub event_type: String, + pub known_event: bool, + pub resource_type: String, + pub original_type: String, + pub algorithm: String, + pub create_time: String, + pub refund_status: String, + pub success_time: Option, + pub amount_total_cents: u64, + pub amount_refund_cents: u64, + pub amount_payer_total_cents: u64, + pub amount_payer_refund_cents: u64, + pub payload_bytes: usize, + pub payload_fingerprint: String, + pub notification_ref: String, + pub merchant_ref: String, + pub transaction_ref: String, + pub order_ref: String, + pub refund_ref: String, + pub merchant_refund_ref: String, + pub user_received_account: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -135,6 +264,42 @@ pub struct WechatVirtualPaymentNotifyOrder { pub event: String, } +#[derive(Clone, Debug, Serialize)] +pub struct WechatVirtualPaymentNotifyDebugText { + pub bytes: usize, + pub hmac_ref: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct WechatVirtualPaymentNotifyDebugSummary { + #[serde(skip)] + raw_event: String, + pub event: String, + pub event_ref: Option, + pub known_event: bool, + pub payload_bytes: usize, + pub payload_fingerprint: String, + pub schema_fields: Vec, + pub identifier_refs: BTreeMap>, + pub safe_fields: BTreeMap, + pub sensitive_text_fields: BTreeMap, + pub apple_subscription_info: bool, + pub subscription_info: bool, +} + +impl WechatVirtualPaymentNotifyDebugSummary { + pub fn raw_event(&self) -> &str { + self.raw_event.as_str() + } + + pub fn primary_identifier_ref(&self, category: &str) -> Option<&str> { + self.identifier_refs + .get(category) + .and_then(|values| values.first()) + .map(String::as_str) + } +} + #[derive(Debug)] pub enum WechatPayError { Disabled, @@ -217,6 +382,24 @@ struct WechatCloseOrderRequest<'a> { mchid: &'a str, } +#[derive(Serialize)] +struct WechatCreateRefundRequest<'a> { + transaction_id: &'a str, + out_trade_no: &'a str, + out_refund_no: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option<&'a str>, + notify_url: &'a str, + amount: WechatCreateRefundAmount, +} + +#[derive(Serialize)] +struct WechatCreateRefundAmount { + refund: u64, + total: u64, + currency: &'static str, +} + #[derive(Deserialize)] struct WechatJsapiOrderResponse { prepay_id: Option, @@ -239,13 +422,18 @@ struct WechatNativeOrderResponse { } #[derive(Deserialize)] -struct WechatPayNotifyBody { - #[serde(default)] - resource: Option, +struct WechatPayNotifyEnvelope { + id: String, + create_time: String, + resource_type: String, + event_type: String, + resource: WechatPayNotifyResource, } #[derive(Deserialize)] struct WechatPayNotifyResource { + algorithm: String, + original_type: String, ciphertext: String, nonce: String, #[serde(default)] @@ -254,22 +442,95 @@ struct WechatPayNotifyResource { #[derive(Deserialize)] struct WechatPayTransactionResource { + appid: String, + mchid: String, out_trade_no: String, #[serde(default)] transaction_id: Option, trade_state: String, #[serde(default)] success_time: Option, + amount: WechatPayTransactionAmount, +} + +#[derive(Deserialize)] +struct WechatPayTransactionAmount { + total: u64, +} + +struct WechatPayDecryptedNotify { + id: String, + create_time: String, + resource_type: String, + event_type: String, + algorithm: String, + original_type: String, + plain_text: Vec, +} + +#[derive(Deserialize)] +struct WechatPayRefundResource { + mchid: String, + transaction_id: String, + out_trade_no: String, + refund_id: String, + out_refund_no: String, + refund_status: String, + #[serde(default)] + success_time: Option, + #[serde(default)] + user_received_account: Option, + amount: WechatPayRefundAmount, +} + +#[derive(Deserialize)] +struct WechatPayRefundAmount { + total: u64, + refund: u64, + payer_total: u64, + payer_refund: u64, } #[derive(Deserialize)] struct WechatPayQueryOrderResponse { + appid: String, + mchid: String, out_trade_no: String, #[serde(default)] transaction_id: Option, trade_state: String, #[serde(default)] success_time: Option, + amount: WechatPayTransactionAmount, +} + +#[derive(Deserialize)] +struct WechatPayRefundResponse { + refund_id: String, + out_refund_no: String, + transaction_id: String, + out_trade_no: String, + status: String, + #[serde(default)] + success_time: Option, + #[serde(default)] + create_time: Option, + amount: WechatPayRefundResponseAmount, +} + +#[derive(Deserialize)] +struct WechatPayRefundResponseAmount { + total: u64, + refund: u64, + payer_total: u64, + payer_refund: u64, +} + +#[derive(Deserialize)] +struct WechatPayTradeBillResponse { + hash_type: String, + hash_value: String, + download_url: String, } #[derive(Deserialize)] @@ -386,9 +647,21 @@ impl WechatPayClient { let native_endpoint = resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_NATIVE_PATH)?; let query_order_endpoint_base = resolve_query_order_endpoint_base(&jsapi_endpoint)?; + let refund_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_REFUND_PATH)?; + let trade_bill_endpoint = + resolve_wechat_pay_transaction_endpoint(&jsapi_endpoint, WECHAT_PAY_TRADE_BILL_PATH)?; + let api_origin = resolve_wechat_pay_api_origin(&jsapi_endpoint)?; + let client = reqwest::Client::builder() + .connect_timeout(WECHAT_PAY_HTTP_CONNECT_TIMEOUT) + .timeout(WECHAT_PAY_HTTP_REQUEST_TIMEOUT) + .build() + .map_err(|error| { + WechatPayError::InvalidConfig(format!("创建微信支付 HTTP client 失败:{error}")) + })?; Ok(Self::Real(Arc::new(RealWechatPayClient { - client: reqwest::Client::new(), + client, app_id, mch_id, merchant_serial_no, @@ -401,6 +674,9 @@ impl WechatPayClient { h5_endpoint, native_endpoint, query_order_endpoint_base, + refund_endpoint, + trade_bill_endpoint, + api_origin, }))) } @@ -449,6 +725,45 @@ impl WechatPayClient { } } + pub fn parse_refund_notify_debug( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Err(WechatPayError::InvalidConfig( + "微信支付 V3 退款通知诊断仅支持 real provider".to_string(), + )), + Self::Real(client) => client.parse_refund_notify_debug(headers, body), + } + } + + pub fn parse_refund_notify( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Err(WechatPayError::InvalidConfig( + "微信支付 V3 退款通知仅支持 real provider".to_string(), + )), + Self::Real(client) => client.parse_refund_notify(headers, body), + } + } + + pub fn refund_notify_request_ref(&self, body: &[u8]) -> Option { + match self { + Self::Real(client) => Some(payment_debug_hmac_ref( + client.api_v3_key.as_bytes(), + "v3-refund-payload", + body, + )), + Self::Disabled | Self::Mock => None, + } + } + pub async fn query_order_by_out_trade_no( &self, order_id: &str, @@ -456,10 +771,13 @@ impl WechatPayClient { match self { Self::Disabled => Err(WechatPayError::Disabled), Self::Mock => Ok(WechatPayNotifyOrder { + app_id: Some("wx-mock-app".to_string()), + mch_id: Some("1900000001".to_string()), out_trade_no: normalize_out_trade_no(order_id)?, transaction_id: Some(format!("mock-{order_id}")), trade_state: "SUCCESS".to_string(), success_time: Some(OffsetDateTime::now_utc().to_string()), + amount_total_cents: None, }), Self::Real(client) => client.query_order_by_out_trade_no(order_id).await, } @@ -475,6 +793,74 @@ impl WechatPayClient { Self::Real(client) => client.close_order_by_out_trade_no(order_id).await, } } + + pub fn supports_refund_reconciliation(&self) -> bool { + matches!(self, Self::Real(_)) + } + + pub async fn create_refund( + &self, + request: WechatPayRefundRequest, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Ok(build_mock_refund(&request, "PROCESSING")), + Self::Real(client) => client.create_refund(request).await, + } + } + + pub async fn query_refund_by_out_refund_no( + &self, + out_refund_no: &str, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => { + let out_refund_no = normalize_out_refund_no(out_refund_no)?; + Ok(WechatPayRefund { + mch_id: None, + transaction_id: format!("mock-transaction-{out_refund_no}"), + out_trade_no: format!("mock-order-{out_refund_no}"), + refund_id: format!("mock-refund-{out_refund_no}"), + out_refund_no, + status: "SUCCESS".to_string(), + success_time: Some(OffsetDateTime::now_utc().to_string()), + create_time: Some(OffsetDateTime::now_utc().to_string()), + amount_total_cents: 1, + amount_refund_cents: 1, + amount_payer_total_cents: 1, + amount_payer_refund_cents: 1, + }) + } + Self::Real(client) => client.query_refund_by_out_refund_no(out_refund_no).await, + } + } + + pub async fn request_refund_trade_bill( + &self, + bill_date: &str, + ) -> Result { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Err(WechatPayError::InvalidConfig( + "mock provider 不提供微信退款交易账单".to_string(), + )), + Self::Real(client) => client.request_refund_trade_bill(bill_date).await, + } + } + + pub async fn download_refund_trade_bill( + &self, + download: &WechatPayTradeBillDownload, + ) -> Result, WechatPayError> { + match self { + Self::Disabled => Err(WechatPayError::Disabled), + Self::Mock => Err(WechatPayError::InvalidConfig( + "mock provider 不提供微信退款交易账单".to_string(), + )), + Self::Real(client) => client.download_refund_trade_bill(download).await, + } + } } impl RealWechatPayClient { @@ -737,32 +1123,249 @@ impl RealWechatPayClient { headers: &HeaderMap, body: &[u8], ) -> Result { - self.verify_notify_signature(headers, body)?; - let notify = serde_json::from_slice::(body).map_err(|error| { - WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}")) - })?; - let resource = notify.resource.ok_or_else(|| { - WechatPayError::InvalidRequest("微信支付通知缺少 resource".to_string()) - })?; - let plain_text = decrypt_aes_256_gcm( - self.api_v3_key.as_bytes(), - resource.nonce.as_bytes(), - resource.associated_data.as_deref().unwrap_or("").as_bytes(), - resource.ciphertext.as_str(), - )?; - let transaction = serde_json::from_slice::(&plain_text) - .map_err(|error| { - WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}")) + let notify = self.decrypt_notify_resource(headers, body)?; + if notify.event_type != "TRANSACTION.SUCCESS" || notify.original_type != "transaction" { + return Err(WechatPayError::InvalidRequest( + "微信支付成功通知事件或资源类型无效".to_string(), + )); + } + let transaction = + serde_json::from_slice::(¬ify.plain_text).map_err( + |error| WechatPayError::Deserialize(format!("微信支付通知资源解析失败:{error}")), + )?; + if transaction.appid.trim() != self.app_id || transaction.mchid.trim() != self.mch_id { + return Err(WechatPayError::InvalidRequest( + "微信支付成功通知 AppID 或商户号不匹配".to_string(), + )); + } + if transaction.trade_state.trim() != "SUCCESS" { + return Err(WechatPayError::InvalidRequest( + "微信支付成功通知事件与交易状态不一致".to_string(), + )); + } + validate_out_trade_no(transaction.out_trade_no.trim())?; + if transaction.amount.total == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付成功通知金额无效".to_string(), + )); + } + let transaction_id = transaction + .transaction_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信支付成功通知缺少微信支付订单号".to_string()) })?; + if transaction + .success_time + .as_deref() + .map(str::trim) + .unwrap_or_default() + .is_empty() + { + return Err(WechatPayError::InvalidRequest( + "微信支付成功通知缺少支付成功时间".to_string(), + )); + } Ok(WechatPayNotifyOrder { + app_id: Some(refund_identifier(&transaction.appid)), + mch_id: Some(refund_identifier(&transaction.mchid)), out_trade_no: transaction.out_trade_no, - transaction_id: transaction - .transaction_id - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()), + transaction_id: Some(transaction_id), trade_state: transaction.trade_state, success_time: transaction.success_time, + amount_total_cents: Some(transaction.amount.total), + }) + } + + fn parse_refund_notify_debug( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + Ok(self.parse_refund_notify(headers, body)?.debug) + } + + fn parse_refund_notify( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + let notify = self.decrypt_notify_resource(headers, body)?; + if notify.original_type != WECHAT_PAY_REFUND_RESOURCE_ORIGINAL_TYPE { + return Err(WechatPayError::InvalidRequest( + "微信支付 V3 退款通知 resource.original_type 无效".to_string(), + )); + } + let refund = serde_json::from_slice::(¬ify.plain_text) + .map_err(|error| { + WechatPayError::Deserialize(format!( + "微信支付 V3 退款通知解密资源解析失败:{error}" + )) + })?; + if !WECHAT_PAY_REFUND_NOTIFY_EVENTS.contains(¬ify.event_type.as_str()) { + return Err(WechatPayError::InvalidRequest( + "微信支付 V3 退款通知 event_type 无效".to_string(), + )); + } + let expected_refund_status = notify + .event_type + .strip_prefix("REFUND.") + .unwrap_or_default(); + if refund.refund_status.trim() != expected_refund_status { + return Err(WechatPayError::InvalidRequest( + "微信支付 V3 退款通知事件与退款状态不一致".to_string(), + )); + } + if refund.mchid.trim() != self.mch_id { + return Err(WechatPayError::InvalidRequest( + "微信支付 V3 退款通知商户号不匹配".to_string(), + )); + } + validate_refund_notify_identifier(¬ify.id, "通知 ID")?; + validate_refund_notify_identifier(&refund.mchid, "商户号")?; + validate_refund_notify_identifier(&refund.transaction_id, "微信支付订单号")?; + validate_refund_notify_identifier(&refund.out_trade_no, "商户订单号")?; + validate_refund_notify_identifier(&refund.refund_id, "微信退款单号")?; + validate_refund_notify_identifier(&refund.out_refund_no, "商户退款单号")?; + validate_refund_notify_identifier(&refund.refund_status, "退款状态")?; + validate_wechat_refund_amounts( + refund.amount.total, + refund.amount.refund, + refund.amount.payer_total, + refund.amount.payer_refund, + "微信支付 V3 退款通知", + )?; + + let notification_id = refund_identifier(¬ify.id); + let mch_id = refund_identifier(&refund.mchid); + let transaction_id = refund_identifier(&refund.transaction_id); + let out_trade_no = refund_identifier(&refund.out_trade_no); + let refund_id = refund_identifier(&refund.refund_id); + let out_refund_no = refund_identifier(&refund.out_refund_no); + let refund_status = refund_identifier(&refund.refund_status); + let success_time = normalize_optional_refund_text(refund.success_time); + let user_received_account = normalize_optional_refund_text(refund.user_received_account); + let reference_key = self.api_v3_key.as_bytes(); + let debug = WechatPayRefundNotifyDebugSummary { + known_event: true, + event_type: notify.event_type.clone(), + resource_type: notify.resource_type.clone(), + original_type: notify.original_type.clone(), + algorithm: notify.algorithm.clone(), + create_time: notify.create_time.clone(), + refund_status: refund_status.clone(), + success_time: success_time.clone(), + amount_total_cents: refund.amount.total, + amount_refund_cents: refund.amount.refund, + amount_payer_total_cents: refund.amount.payer_total, + amount_payer_refund_cents: refund.amount.payer_refund, + payload_bytes: body.len(), + payload_fingerprint: payment_debug_hmac_ref(reference_key, "v3-refund-payload", body), + notification_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-notification-id", + notification_id.as_bytes(), + ), + merchant_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-mchid", + mch_id.as_bytes(), + ), + transaction_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-transaction-id", + transaction_id.as_bytes(), + ), + order_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-out-trade-no", + out_trade_no.as_bytes(), + ), + refund_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-refund-id", + refund_id.as_bytes(), + ), + merchant_refund_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-out-refund-no", + out_refund_no.as_bytes(), + ), + user_received_account: user_received_account.clone().map(|value| { + WechatPayRefundNotifyDebugText { + bytes: value.len(), + hmac_ref: payment_debug_hmac_ref( + reference_key, + "v3-refund-user-received-account", + value.as_bytes(), + ), + } + }), + }; + + Ok(WechatPayRefundNotification { + notification_id, + create_time: notify.create_time, + event_type: notify.event_type, + refund: WechatPayRefund { + mch_id: Some(mch_id), + transaction_id, + out_trade_no, + refund_id, + out_refund_no, + status: refund_status, + success_time, + create_time: None, + amount_total_cents: refund.amount.total, + amount_refund_cents: refund.amount.refund, + amount_payer_total_cents: refund.amount.payer_total, + amount_payer_refund_cents: refund.amount.payer_refund, + }, + debug, + }) + } + + fn decrypt_notify_resource( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result { + self.verify_notify_signature(headers, body)?; + let notify = serde_json::from_slice::(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信支付通知解析失败:{error}")) + })?; + if notify.resource_type != WECHAT_PAY_NOTIFY_RESOURCE_TYPE { + return Err(WechatPayError::InvalidRequest( + "微信支付通知 resource_type 无效".to_string(), + )); + } + if notify.resource.algorithm != WECHAT_PAY_NOTIFY_RESOURCE_ALGORITHM { + return Err(WechatPayError::InvalidRequest( + "微信支付通知 resource.algorithm 无效".to_string(), + )); + } + let plain_text = decrypt_aes_256_gcm( + self.api_v3_key.as_bytes(), + notify.resource.nonce.as_bytes(), + notify + .resource + .associated_data + .as_deref() + .unwrap_or("") + .as_bytes(), + notify.resource.ciphertext.as_str(), + )?; + + Ok(WechatPayDecryptedNotify { + id: notify.id, + create_time: notify.create_time, + resource_type: notify.resource_type, + event_type: notify.event_type, + algorithm: notify.resource.algorithm, + original_type: notify.resource.original_type, + plain_text, }) } @@ -795,11 +1398,12 @@ impl RealWechatPayClient { .await .map_err(|error| WechatPayError::RequestFailed(format!("微信支付查单请求失败:{error}")))?; let status = response.status(); - let response_text = response.text().await.map_err(|error| { + let headers = response.headers().clone(); + let response_body = response.bytes().await.map_err(|error| { WechatPayError::Deserialize(format!("微信支付查单响应读取失败:{error}")) })?; if !status.is_success() { - if let Ok(payload) = serde_json::from_str::(&response_text) + if let Ok(payload) = serde_json::from_slice::(&response_body) && payload.code.as_deref() == Some("ORDER_NOT_EXIST") { return Err(WechatPayError::OrderNotExist( @@ -810,15 +1414,31 @@ impl RealWechatPayClient { )); } + let message = parse_wechat_error_message(&response_body) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(WechatPayError::Upstream(format!( - "微信支付查单失败:HTTP {status},{response_text}" + "微信支付查单失败:{message}" ))); } - let payload = serde_json::from_str::(&response_text).map_err( - |error| WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")), - )?; + self.verify_response_signature(&headers, &response_body)?; + let payload = serde_json::from_slice::(&response_body) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付查单响应解析失败:{error}")) + })?; + if payload.appid.trim() != self.app_id || payload.mchid.trim() != self.mch_id { + return Err(WechatPayError::InvalidRequest( + "微信支付查单响应 AppID 或商户号不匹配".to_string(), + )); + } + if payload.out_trade_no.trim() != order_id || payload.amount.total == 0 { + return Err(WechatPayError::InvalidRequest( + "微信支付查单响应订单号或金额无效".to_string(), + )); + } Ok(WechatPayNotifyOrder { + app_id: Some(refund_identifier(&payload.appid)), + mch_id: Some(refund_identifier(&payload.mchid)), out_trade_no: payload.out_trade_no, transaction_id: payload .transaction_id @@ -826,6 +1446,7 @@ impl RealWechatPayClient { .filter(|value| !value.is_empty()), trade_state: payload.trade_state, success_time: payload.success_time, + amount_total_cents: Some(payload.amount.total), }) } @@ -841,7 +1462,9 @@ impl RealWechatPayClient { mchid: &self.mch_id, }) .map_err(|error| { - WechatPayError::Deserialize(format!("wechat pay close request serialize failed: {error}")) + WechatPayError::Deserialize(format!( + "wechat pay close request serialize failed: {error}" + )) })?; self.post_wechat_json(&request_url, &path, body, "wechat pay close request failed") @@ -849,7 +1472,198 @@ impl RealWechatPayClient { .map(|_| ()) } - fn verify_notify_signature( + async fn create_refund( + &self, + request: WechatPayRefundRequest, + ) -> Result { + let request = validate_refund_request(request)?; + let body = serde_json::to_string(&WechatCreateRefundRequest { + transaction_id: &request.transaction_id, + out_trade_no: &request.out_trade_no, + out_refund_no: &request.out_refund_no, + reason: request.reason.as_deref(), + notify_url: &request.notify_url, + amount: WechatCreateRefundAmount { + refund: request.refund_amount_cents, + total: request.total_amount_cents, + currency: "CNY", + }, + }) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付退款申请序列化失败:{error}")) + })?; + let response = self + .send_signed_json_request( + reqwest::Method::POST, + &self.refund_endpoint, + WECHAT_PAY_REFUND_PATH, + body, + "微信支付退款申请", + ) + .await?; + let refund = parse_refund_response(&response, "微信支付退款申请响应")?; + validate_created_refund_response(&refund, &request)?; + Ok(refund) + } + + async fn query_refund_by_out_refund_no( + &self, + out_refund_no: &str, + ) -> Result { + let out_refund_no = normalize_out_refund_no(out_refund_no)?; + let encoded = urlencoding::encode(&out_refund_no); + let canonical_path = format!("{WECHAT_PAY_REFUND_PATH}/{encoded}"); + let endpoint = format!("{}/{encoded}", self.refund_endpoint.trim_end_matches('/')); + let response = self + .send_signed_json_request( + reqwest::Method::GET, + &endpoint, + &canonical_path, + String::new(), + "微信支付退款查询", + ) + .await?; + let refund = parse_refund_response(&response, "微信支付退款查询响应")?; + validate_queried_refund_response(&refund, &out_refund_no)?; + Ok(refund) + } + + async fn request_refund_trade_bill( + &self, + bill_date: &str, + ) -> Result { + let bill_date = normalize_bill_date(bill_date)?; + let query = format!("bill_date={bill_date}&bill_type=REFUND&tar_type=GZIP"); + let canonical_path = format!("{WECHAT_PAY_TRADE_BILL_PATH}?{query}"); + let endpoint = format!("{}?{query}", self.trade_bill_endpoint); + let response = self + .send_signed_json_request( + reqwest::Method::GET, + &endpoint, + &canonical_path, + String::new(), + "微信支付退款交易账单申请", + ) + .await?; + let payload = + serde_json::from_slice::(&response).map_err(|error| { + WechatPayError::Deserialize(format!( + "微信支付退款交易账单申请响应解析失败:{error}" + )) + })?; + validate_trade_bill_download(payload) + } + + async fn download_refund_trade_bill( + &self, + download: &WechatPayTradeBillDownload, + ) -> Result, WechatPayError> { + validate_trade_bill_download_contract(download)?; + let url = Url::parse(&download.download_url) + .map_err(|_| WechatPayError::InvalidRequest("微信支付账单下载地址无效".to_string()))?; + if url.origin().ascii_serialization().trim_end_matches('/') != self.api_origin + || url.path() != WECHAT_PAY_BILL_DOWNLOAD_PATH + || url.query().is_none() + { + return Err(WechatPayError::InvalidRequest( + "微信支付账单下载地址不属于当前官方 API 源".to_string(), + )); + } + let canonical_path = match url.query() { + Some(query) => format!("{}?{query}", url.path()), + None => url.path().to_string(), + }; + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = + self.build_authorization("GET", &canonical_path, ×tamp, &nonce, "")?; + let response = with_wechat_pay_json_headers( + self.client.get(url).header("Authorization", authorization), + &self.platform_serial_no, + ) + .send() + .await + .map_err(|error| { + WechatPayError::RequestFailed(format!("微信支付退款交易账单下载失败:{error}")) + })?; + let status = response.status(); + let compressed = response.bytes().await.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付退款交易账单读取失败:{error}")) + })?; + if !status.is_success() { + return Err(WechatPayError::Upstream(format!( + "微信支付退款交易账单下载失败:HTTP {status}" + ))); + } + + let mut csv_bytes = Vec::new(); + GzDecoder::new(compressed.as_ref()) + .read_to_end(&mut csv_bytes) + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付退款交易账单 GZIP 解压失败:{error}")) + })?; + verify_trade_bill_hash(download, &csv_bytes)?; + let rows = parse_refund_trade_bill_csv(&csv_bytes)?; + for row in &rows { + if row + .mch_id + .as_deref() + .is_some_and(|value| value != self.mch_id) + || row + .app_id + .as_deref() + .is_some_and(|value| value != self.app_id) + { + return Err(WechatPayError::InvalidRequest( + "微信支付退款交易账单 AppID 或商户号不匹配".to_string(), + )); + } + } + Ok(rows) + } + + async fn send_signed_json_request( + &self, + method: reqwest::Method, + endpoint: &str, + canonical_path: &str, + body: String, + operation: &str, + ) -> Result, WechatPayError> { + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + let nonce = create_nonce()?; + let authorization = + self.build_authorization(method.as_str(), canonical_path, ×tamp, &nonce, &body)?; + let mut builder = self + .client + .request(method, endpoint) + .header("Authorization", authorization); + if !body.is_empty() { + builder = builder.body(body); + } + let response = with_wechat_pay_json_headers(builder, &self.platform_serial_no) + .send() + .await + .map_err(|error| { + WechatPayError::RequestFailed(format!("{operation}请求失败:{error}")) + })?; + let status = response.status(); + let headers = response.headers().clone(); + let response_body = response.bytes().await.map_err(|error| { + WechatPayError::Deserialize(format!("{operation}响应读取失败:{error}")) + })?; + if !status.is_success() { + return Err(map_signed_json_error_response( + operation, + status, + &response_body, + )); + } + self.verify_response_signature(&headers, &response_body)?; + Ok(response_body.to_vec()) + } + + fn verify_response_signature( &self, headers: &HeaderMap, body: &[u8], @@ -859,14 +1673,56 @@ impl RealWechatPayClient { let signature = read_required_header(headers, "Wechatpay-Signature")?; let serial = read_required_header(headers, "Wechatpay-Serial")?; if serial != self.platform_serial_no { - warn!( - received_serial = serial, - configured_serial = self.platform_serial_no.as_str(), - "微信支付通知平台公钥序列号不匹配" - ); - return Err(WechatPayError::InvalidSignature(format!( - "微信支付通知平台公钥序列号不匹配:received={serial}" - ))); + return Err(WechatPayError::InvalidSignature( + "微信支付响应平台公钥序列号不匹配".to_string(), + )); + } + if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) { + return Err(WechatPayError::InvalidSignature( + "微信支付响应签名探测值无效".to_string(), + )); + } + let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body); + let signature_bytes = BASE64_STANDARD.decode(signature).map_err(|_| { + WechatPayError::InvalidSignature("微信支付响应签名 base64 无效".to_string()) + })?; + verify_rsa_sha256_signature(&self.platform_public_key_der, &message, &signature_bytes) + .map_err(|_| WechatPayError::InvalidSignature("微信支付响应签名验签失败".to_string())) + } + + fn verify_notify_signature( + &self, + headers: &HeaderMap, + body: &[u8], + ) -> Result<(), WechatPayError> { + self.verify_notify_signature_at(headers, body, OffsetDateTime::now_utc().unix_timestamp()) + } + + fn verify_notify_signature_at( + &self, + headers: &HeaderMap, + body: &[u8], + now_unix_seconds: i64, + ) -> Result<(), WechatPayError> { + let timestamp = read_required_header(headers, "Wechatpay-Timestamp")?; + let timestamp_seconds = timestamp.parse::().map_err(|_| { + WechatPayError::InvalidSignature("微信支付通知时间戳格式无效".to_string()) + })?; + if now_unix_seconds.abs_diff(timestamp_seconds) + > WECHAT_PAY_NOTIFY_TIMESTAMP_TOLERANCE_SECONDS as u64 + { + return Err(WechatPayError::InvalidSignature( + "微信支付通知时间戳超出允许窗口".to_string(), + )); + } + let nonce = read_required_header(headers, "Wechatpay-Nonce")?; + let signature = read_required_header(headers, "Wechatpay-Signature")?; + let serial = read_required_header(headers, "Wechatpay-Serial")?; + if serial != self.platform_serial_no { + warn!("微信支付通知平台公钥序列号不匹配"); + return Err(WechatPayError::InvalidSignature( + "微信支付通知平台公钥序列号不匹配".to_string(), + )); } if signature.starts_with(WECHAT_PAY_SIGNATURE_TEST_PREFIX) { warn!("收到微信支付签名探测通知"); @@ -943,9 +1799,7 @@ fn build_mock_h5_payment(order_id: &str) -> WechatH5PaymentResponse { } } -fn build_wechat_pay_expire_time( - context: &str, -) -> Result<(OffsetDateTime, String), WechatPayError> { +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)?; @@ -977,11 +1831,38 @@ fn build_mock_native_payment(order_id: &str) -> WechatNativePaymentResponse { } } +fn build_mock_refund(request: &WechatPayRefundRequest, status: &str) -> WechatPayRefund { + WechatPayRefund { + mch_id: None, + transaction_id: request.transaction_id.clone(), + out_trade_no: request.out_trade_no.clone(), + refund_id: format!("mock-{}", request.out_refund_no), + out_refund_no: request.out_refund_no.clone(), + status: status.to_string(), + success_time: (status == "SUCCESS").then(|| OffsetDateTime::now_utc().to_string()), + create_time: Some(OffsetDateTime::now_utc().to_string()), + amount_total_cents: request.total_amount_cents, + amount_refund_cents: request.refund_amount_cents, + amount_payer_total_cents: request.total_amount_cents, + amount_payer_refund_cents: request.refund_amount_cents, + } +} + fn parse_mock_notify(body: &[u8]) -> Result { let value = serde_json::from_slice::(body).map_err(|error| { WechatPayError::Deserialize(format!("mock 微信支付通知解析失败:{error}")) })?; Ok(WechatPayNotifyOrder { + app_id: value + .get("appId") + .or_else(|| value.get("appid")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + mch_id: value + .get("mchId") + .or_else(|| value.get("mchid")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), out_trade_no: value .get("outTradeNo") .or_else(|| value.get("out_trade_no")) @@ -1010,6 +1891,10 @@ fn parse_mock_notify(body: &[u8]) -> Result Result { + if let Ok(value) = serde_json::from_slice::(body) { + return value + .get("Event") + .or_else(|| value.get("event")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string()) + }); + } + + let text = std::str::from_utf8(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}")) + })?; + extract_virtual_payment_text_value(text, "Event") + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| WechatPayError::InvalidRequest("微信虚拟支付推送缺少 Event".to_string())) +} + +pub fn build_virtual_payment_notify_debug_summary( + body: &[u8], + reference_key: &[u8], +) -> Result { + let raw_event = parse_virtual_payment_notify_event(body)?; + let known_event = WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS.contains(&raw_event.as_str()); + let mut summary = WechatVirtualPaymentNotifyDebugSummary { + event: if known_event { + raw_event.clone() + } else { + "unknown".to_string() + }, + event_ref: (!known_event) + .then(|| virtual_payment_debug_hmac_ref(reference_key, "event", raw_event.as_bytes())), + raw_event, + known_event, + payload_bytes: body.len(), + payload_fingerprint: virtual_payment_debug_hmac_ref(reference_key, "payload", body), + schema_fields: Vec::new(), + identifier_refs: BTreeMap::new(), + safe_fields: BTreeMap::new(), + sensitive_text_fields: BTreeMap::new(), + apple_subscription_info: false, + subscription_info: false, + }; + + if let Ok(value) = serde_json::from_slice::(body) { + collect_virtual_payment_json_debug_fields(&value, "", 0, reference_key, &mut summary); + } else { + let text = std::str::from_utf8(body).map_err(|error| { + WechatPayError::Deserialize(format!("微信虚拟支付推送不是合法 UTF-8:{error}")) + })?; + collect_virtual_payment_xml_debug_fields(text, reference_key, &mut summary); + } + + summary.schema_fields.sort(); + summary.schema_fields.dedup(); + Ok(summary) +} + +fn collect_virtual_payment_json_debug_fields( + value: &Value, + prefix: &str, + depth: usize, + reference_key: &[u8], + summary: &mut WechatVirtualPaymentNotifyDebugSummary, +) { + if depth >= WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_DEPTH { + return; + } + match value { + Value::Object(fields) => { + for (key, value) in fields { + let segment = virtual_payment_debug_schema_segment(key, reference_key); + if segment.is_empty() { + continue; + } + let path = if prefix.is_empty() { + segment + } else { + format!("{prefix}.{segment}") + }; + push_virtual_payment_debug_schema_field(summary, path.as_str()); + record_virtual_payment_debug_field( + summary, + path.as_str(), + key, + value, + reference_key, + ); + collect_virtual_payment_json_debug_fields( + value, + path.as_str(), + depth + 1, + reference_key, + summary, + ); + } + } + Value::Array(values) => { + let path = if prefix.is_empty() { + "[]".to_string() + } else { + format!("{prefix}[]") + }; + push_virtual_payment_debug_schema_field(summary, path.as_str()); + for value in values.iter().take(4) { + collect_virtual_payment_json_debug_fields( + value, + path.as_str(), + depth + 1, + reference_key, + summary, + ); + } + } + _ => {} + } +} + +fn collect_virtual_payment_xml_debug_fields( + text: &str, + reference_key: &[u8], + summary: &mut WechatVirtualPaymentNotifyDebugSummary, +) { + const DEBUG_XML_FIELDS: &[&str] = &[ + "Event", + "CreateTime", + "Env", + "RetryTimes", + "OpenId", + "UserOpenid", + "OutTradeNo", + "MchOrderId", + "MchOrderNo", + "PayOrderId", + "pay_order_id", + "WxOrderId", + "WxpayOrderId", + "TransactionId", + "ChannelBill", + "channel_bill", + "OriginalTransactionId", + "WxRefundId", + "MchRefundId", + "WxpayRefundTransactionId", + "ComplaintId", + "RequestId", + "ContractId", + "ContractNo", + "OutContractCode", + "ContractWxAppid", + "MerchantCode", + "BusinessCode", + "ProductId", + "product_id", + "BundleId", + "bundleid", + "RefundFee", + "RetCode", + "Action", + "SubscribePeriodDays", + "PCount", + "ProvideStatus", + "EventType", + "Code", + "State", + "BusinessState", + "AutoRenewStatus", + "RenewalTime", + "RenewalDate", + "SigningTime", + "SignedTime", + "RefundTime", + "refund_time", + "OrderTime", + "order_time", + "BusinessTime", + "StartTime", + "RefundStartTimestamp", + "EndTime", + "RefundSuccTimestamp", + "PaidTime", + "OpenorcloseTime", + "ComplaintTime", + "OrigPrice", + "ActualPrice", + "TeamType", + "TeamAction", + "p_count", + "provide_status", + "ComplaintDetail", + "RefundRequestReason", + "refund_request_reason", + "RefundReason", + "Attach", + "Remark", + "RetMsg", + ]; + for key in DEBUG_XML_FIELDS { + let Some(value) = extract_virtual_payment_text_value(text, key) else { + continue; + }; + let path = virtual_payment_debug_schema_segment(key, reference_key); + push_virtual_payment_debug_schema_field(summary, path.as_str()); + record_virtual_payment_debug_field( + summary, + path.as_str(), + key, + &Value::String(value), + reference_key, + ); + } + if extract_virtual_payment_block(text, "AppleSubscriptionInfo").is_some() { + summary.apple_subscription_info = true; + summary.subscription_info = true; + push_virtual_payment_debug_schema_field(summary, "applesubscriptioninfo"); + } +} + +fn record_virtual_payment_debug_field( + summary: &mut WechatVirtualPaymentNotifyDebugSummary, + path: &str, + key: &str, + value: &Value, + reference_key: &[u8], +) { + let normalized_key = normalize_virtual_payment_debug_key(key); + if normalized_key == "applesubscriptioninfo" && !value.is_null() { + summary.apple_subscription_info = true; + } + if is_virtual_payment_subscription_marker(normalized_key.as_str()) && !value.is_null() { + summary.subscription_info = true; + } + let Some(text) = virtual_payment_debug_scalar_text(value) else { + return; + }; + if let Some(category) = virtual_payment_debug_identifier_category(normalized_key.as_str()) { + let reference = virtual_payment_debug_hmac_ref(reference_key, category, text.as_bytes()); + let values = summary + .identifier_refs + .entry(category.to_string()) + .or_default(); + if !values.contains(&reference) { + values.push(reference); + } + return; + } + if is_virtual_payment_debug_sensitive_text(normalized_key.as_str()) { + summary.sensitive_text_fields.insert( + path.to_string(), + WechatVirtualPaymentNotifyDebugText { + bytes: text.len(), + hmac_ref: virtual_payment_debug_hmac_ref( + reference_key, + "sensitive-text", + text.as_bytes(), + ), + }, + ); + return; + } + if is_virtual_payment_debug_safe_scalar(normalized_key.as_str()) { + summary.safe_fields.insert( + path.to_string(), + sanitize_virtual_payment_debug_scalar(value, reference_key, normalized_key.as_str()), + ); + } +} + +fn normalize_virtual_payment_debug_key(key: &str) -> String { + key.chars() + .filter(|character| character.is_ascii_alphanumeric()) + .map(|character| character.to_ascii_lowercase()) + .collect() +} + +fn virtual_payment_debug_schema_segment(key: &str, reference_key: &[u8]) -> String { + let normalized = normalize_virtual_payment_debug_key(key); + if is_virtual_payment_debug_known_schema_key(normalized.as_str()) { + return normalized; + } + format!( + "unknown_{}", + virtual_payment_debug_hmac_hex(reference_key, "schema-key", key.as_bytes()) + ) +} + +fn virtual_payment_debug_scalar_text(value: &Value) -> Option { + match value { + Value::String(value) => Some(value.clone()), + Value::Number(value) => Some(value.to_string()), + Value::Bool(value) => Some(value.to_string()), + Value::Null | Value::Array(_) | Value::Object(_) => None, + } +} + +fn sanitize_virtual_payment_debug_scalar(value: &Value, reference_key: &[u8], key: &str) -> Value { + match value { + Value::String(value) => value + .parse::() + .ok() + .map(serde_json::Number::from) + .map(Value::Number) + .unwrap_or_else(|| { + let mut fields = serde_json::Map::new(); + fields.insert( + "chars".to_string(), + Value::Number(serde_json::Number::from(value.chars().count())), + ); + fields.insert( + "hmac_ref".to_string(), + Value::String(virtual_payment_debug_hmac_ref( + reference_key, + key, + value.as_bytes(), + )), + ); + Value::Object(fields) + }), + Value::Number(_) | Value::Bool(_) => value.clone(), + Value::Null | Value::Array(_) | Value::Object(_) => Value::Null, + } +} + +fn virtual_payment_debug_identifier_category(key: &str) -> Option<&'static str> { + match key { + "openid" | "useropenid" => Some("user_ref"), + "outtradeno" | "mchorderid" | "mchorderno" | "payorderid" | "orderid" => Some("order_ref"), + "wxorderid" + | "wxpayorderid" + | "transactionid" + | "channelbill" + | "originaltransactionid" => Some("provider_order_ref"), + "wxrefundid" | "mchrefundid" | "wxpayrefundtransactionid" => Some("refund_ref"), + "contractid" | "contractno" | "contractcode" | "outcontractcode" => Some("contract_ref"), + "complaintid" => Some("complaint_ref"), + "requestid" => Some("request_ref"), + "mchid" | "merchantid" | "merchantcode" => Some("merchant_ref"), + "businesscode" => Some("business_ref"), + "appid" | "wxappid" | "contractwxappid" | "bundleid" => Some("app_ref"), + _ => None, + } +} + +fn is_virtual_payment_subscription_marker(key: &str) -> bool { + matches!( + key, + "applesubscriptioninfo" + | "subscriptioninfo" + | "outcontractcode" + | "contractwxappid" + | "subscribeperioddays" + ) +} + +fn is_virtual_payment_debug_sensitive_text(key: &str) -> bool { + matches!( + key, + "complaintdetail" + | "refundrequestreason" + | "refundreason" + | "attach" + | "remark" + | "retmsg" + | "resultinfo" + | "evidence" + | "description" + | "goodsname" + | "merchantname" + | "businessname" + ) +} + +fn is_virtual_payment_debug_safe_scalar(key: &str) -> bool { + matches!( + key, + "createtime" + | "env" + | "retrytimes" + | "refundfee" + | "retcode" + | "action" + | "subscribeperioddays" + | "pcount" + | "providestatus" + | "eventtype" + | "code" + | "state" + | "businessstate" + | "autorenewstatus" + | "renewaltime" + | "renewaldate" + | "signingtime" + | "signedtime" + | "refundtime" + | "ordertime" + | "businesstime" + | "starttime" + | "refundstarttimestamp" + | "endtime" + | "refundsucctimestamp" + | "paidtime" + | "openorclosetime" + | "complainttime" + | "productid" + | "goodsprice" + | "origprice" + | "actualprice" + | "quantity" + | "buyquantity" + | "orderfee" + | "status" + | "refundstatus" + | "paystatus" + | "currency" + | "perioddays" + | "teamtype" + | "teamaction" + ) +} + +fn is_virtual_payment_debug_known_schema_key(key: &str) -> bool { + virtual_payment_debug_identifier_category(key).is_some() + || is_virtual_payment_debug_sensitive_text(key) + || is_virtual_payment_debug_safe_scalar(key) + || is_virtual_payment_subscription_marker(key) + || matches!( + key, + "event" + | "wechatpayinfo" + | "goodsinfo" + | "coininfo" + | "teaminfo" + | "refundinfo" + | "complaintinfo" + | "subscribeinfo" + | "payinfo" + ) +} + +fn validate_refund_notify_identifier(value: &str, field_name: &str) -> Result<(), WechatPayError> { + if value.trim().is_empty() { + return Err(WechatPayError::InvalidRequest(format!( + "微信支付 V3 退款通知{field_name}为空" + ))); + } + Ok(()) +} + +fn refund_identifier(value: &str) -> String { + value.trim().to_string() +} + +fn normalize_optional_refund_text(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn payment_debug_hmac_ref(reference_key: &[u8], domain: &str, value: &[u8]) -> String { + format!( + "hmac-sha256:{}", + virtual_payment_debug_hmac_hex(reference_key, domain, value) + ) +} + +fn virtual_payment_debug_hmac_ref(reference_key: &[u8], domain: &str, value: &[u8]) -> String { + payment_debug_hmac_ref(reference_key, domain, value) +} + +fn virtual_payment_debug_hmac_hex(reference_key: &[u8], domain: &str, value: &[u8]) -> String { + let key = hmac::Key::new(hmac::HMAC_SHA256, reference_key); + let mut payload = Vec::with_capacity(domain.len() + value.len() + 1); + payload.extend_from_slice(domain.as_bytes()); + payload.push(0); + payload.extend_from_slice(value); + let tag = hmac::sign(&key, payload.as_slice()); + hex::encode(&tag.as_ref()[..16]) +} + +fn push_virtual_payment_debug_schema_field( + summary: &mut WechatVirtualPaymentNotifyDebugSummary, + path: &str, +) { + if summary.schema_fields.len() >= WECHAT_VIRTUAL_PAYMENT_DEBUG_MAX_SCHEMA_FIELDS + || summary.schema_fields.iter().any(|value| value == path) + { + return; + } + summary.schema_fields.push(path.to_string()); +} + fn build_virtual_payment_notify_order( event: String, out_trade_no: Option, @@ -1258,7 +2639,8 @@ fn build_virtual_payment_notify_order( .filter(|value| !value.is_empty()); let paid_at_micros = wechat_pay_info .and_then(|info| info.paid_time) - .map(|paid_time| paid_time.saturating_mul(1_000_000)); + .filter(|paid_time| *paid_time > 0) + .and_then(|paid_time| paid_time.checked_mul(1_000_000)); Ok(WechatVirtualPaymentNotifyOrder { out_trade_no, @@ -1330,27 +2712,25 @@ fn validate_notify_url(value: &str, key: &str) -> Result<(), WechatPayError> { } fn resolve_query_order_endpoint_base(jsapi_endpoint: &str) -> Result { + let origin = resolve_wechat_pay_api_origin(jsapi_endpoint)?; + Ok(format!("{origin}/v3/pay/transactions/out-trade-no")) +} + +fn resolve_wechat_pay_api_origin(jsapi_endpoint: &str) -> Result { let url = Url::parse(jsapi_endpoint) .map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?; - let origin = url + Ok(url .origin() .ascii_serialization() .trim_end_matches('/') - .to_string(); - Ok(format!("{origin}/v3/pay/transactions/out-trade-no")) + .to_string()) } fn resolve_wechat_pay_transaction_endpoint( jsapi_endpoint: &str, transaction_path: &str, ) -> Result { - let url = Url::parse(jsapi_endpoint) - .map_err(|_| WechatPayError::InvalidConfig("WECHAT_PAY_JSAPI_ENDPOINT 无效".to_string()))?; - let origin = url - .origin() - .ascii_serialization() - .trim_end_matches('/') - .to_string(); + let origin = resolve_wechat_pay_api_origin(jsapi_endpoint)?; Ok(format!("{origin}{transaction_path}")) } @@ -1360,6 +2740,440 @@ fn normalize_out_trade_no(value: &str) -> Result { Ok(value.to_string()) } +fn normalize_out_refund_no(value: &str) -> Result { + let value = value.trim(); + validate_non_empty_max_chars( + value, + WECHAT_PAY_OUT_REFUND_NO_MAX_CHARS, + "微信支付 out_refund_no", + )?; + if !value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '|' | '*' | '@')) + { + return Err(WechatPayError::InvalidRequest( + "微信支付 out_refund_no 只能包含数字、大小写字母、_、-、|、*、@".to_string(), + )); + } + Ok(value.to_string()) +} + +fn validate_refund_request( + request: WechatPayRefundRequest, +) -> Result { + let transaction_id = request.transaction_id.trim().to_string(); + validate_non_empty_max_chars(&transaction_id, 64, "微信支付 transaction_id")?; + let out_trade_no = normalize_out_trade_no(&request.out_trade_no)?; + let out_refund_no = normalize_out_refund_no(&request.out_refund_no)?; + let reason = request + .reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if let Some(reason) = reason.as_deref() { + validate_non_empty_max_chars( + reason, + WECHAT_PAY_REFUND_REASON_MAX_CHARS, + "微信支付退款原因", + )?; + } + let notify_url = request.notify_url.trim().to_string(); + validate_non_empty_max_chars( + ¬ify_url, + WECHAT_PAY_NOTIFY_URL_MAX_CHARS, + "微信支付退款 notify_url", + )?; + validate_notify_url(¬ify_url, "微信支付退款 notify_url")?; + if !notify_url.starts_with("https://") { + return Err(WechatPayError::InvalidRequest( + "微信支付退款 notify_url 必须是 https 地址".to_string(), + )); + } + if request.refund_amount_cents == 0 + || request.total_amount_cents == 0 + || request.refund_amount_cents > request.total_amount_cents + { + return Err(WechatPayError::InvalidRequest( + "微信支付退款金额必须大于 0 且不能超过订单总额".to_string(), + )); + } + Ok(WechatPayRefundRequest { + transaction_id, + out_trade_no, + out_refund_no, + reason, + notify_url, + refund_amount_cents: request.refund_amount_cents, + total_amount_cents: request.total_amount_cents, + }) +} + +fn parse_refund_response(body: &[u8], context: &str) -> Result { + let payload = serde_json::from_slice::(body) + .map_err(|error| WechatPayError::Deserialize(format!("{context}解析失败:{error}")))?; + for (value, field_name) in [ + (&payload.transaction_id, "微信支付订单号"), + (&payload.out_trade_no, "商户订单号"), + (&payload.refund_id, "微信退款单号"), + (&payload.out_refund_no, "商户退款单号"), + (&payload.status, "退款状态"), + ] { + validate_refund_notify_identifier(value, field_name)?; + } + let status = payload.status.trim().to_ascii_uppercase(); + if !matches!( + status.as_str(), + "PROCESSING" | "SUCCESS" | "ABNORMAL" | "CLOSED" + ) { + return Err(WechatPayError::InvalidRequest(format!( + "{context}包含未知退款状态" + ))); + } + validate_wechat_refund_amounts( + payload.amount.total, + payload.amount.refund, + payload.amount.payer_total, + payload.amount.payer_refund, + context, + )?; + Ok(WechatPayRefund { + mch_id: None, + transaction_id: refund_identifier(&payload.transaction_id), + out_trade_no: refund_identifier(&payload.out_trade_no), + refund_id: refund_identifier(&payload.refund_id), + out_refund_no: normalize_out_refund_no(&payload.out_refund_no)?, + status, + success_time: normalize_optional_refund_text(payload.success_time), + create_time: normalize_optional_refund_text(payload.create_time), + amount_total_cents: payload.amount.total, + amount_refund_cents: payload.amount.refund, + amount_payer_total_cents: payload.amount.payer_total, + amount_payer_refund_cents: payload.amount.payer_refund, + }) +} + +fn validate_created_refund_response( + refund: &WechatPayRefund, + request: &WechatPayRefundRequest, +) -> Result<(), WechatPayError> { + if refund.out_refund_no != request.out_refund_no + || refund.out_trade_no != request.out_trade_no + || refund.transaction_id != request.transaction_id + || refund.amount_total_cents != request.total_amount_cents + || refund.amount_refund_cents != request.refund_amount_cents + { + return Err(WechatPayError::InvalidRequest( + "微信支付退款申请响应与本次请求不匹配".to_string(), + )); + } + Ok(()) +} + +fn validate_queried_refund_response( + refund: &WechatPayRefund, + requested_out_refund_no: &str, +) -> Result<(), WechatPayError> { + if refund.out_refund_no != requested_out_refund_no { + return Err(WechatPayError::InvalidRequest( + "微信支付退款查询响应商户退款单号与请求不匹配".to_string(), + )); + } + Ok(()) +} + +fn validate_wechat_refund_amounts( + total_cents: u64, + refund_cents: u64, + payer_total_cents: u64, + payer_refund_cents: u64, + context: &str, +) -> Result<(), WechatPayError> { + if total_cents == 0 + || refund_cents == 0 + || refund_cents > total_cents + || payer_total_cents > total_cents + || payer_refund_cents > payer_total_cents + || payer_refund_cents > refund_cents + { + return Err(WechatPayError::InvalidRequest(format!( + "{context}金额契约无效" + ))); + } + Ok(()) +} + +fn parse_wechat_error_message(body: &[u8]) -> Option { + let payload = serde_json::from_slice::(body).ok()?; + match (payload.code, payload.message) { + (Some(code), Some(message)) => Some(format!("{code}: {message}")), + (Some(code), None) => Some(code), + (None, Some(message)) => Some(message), + (None, None) => None, + } +} + +fn map_signed_json_error_response( + operation: &str, + status: reqwest::StatusCode, + body: &[u8], +) -> WechatPayError { + if let Ok(payload) = serde_json::from_slice::(body) + && matches!( + payload.code.as_deref(), + Some("ORDER_NOT_EXIST" | "RESOURCE_NOT_EXISTS") + ) + { + return WechatPayError::OrderNotExist( + payload + .message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(|| format!("{operation}对应订单不存在")), + ); + } + + let message = parse_wechat_error_message(body).unwrap_or_else(|| format!("HTTP {status}")); + WechatPayError::Upstream(format!("{operation}失败:{message}")) +} + +fn normalize_bill_date(value: &str) -> Result { + let value = value.trim(); + let bytes = value.as_bytes(); + if bytes.len() != 10 + || bytes[4] != b'-' + || bytes[7] != b'-' + || bytes + .iter() + .enumerate() + .any(|(index, byte)| !matches!(index, 4 | 7) && !byte.is_ascii_digit()) + { + return Err(WechatPayError::InvalidRequest( + "微信支付账单日期必须使用 YYYY-MM-DD".to_string(), + )); + } + let year = value[0..4].parse::().unwrap_or_default(); + let month = value[5..7].parse::().unwrap_or_default(); + let day = value[8..10].parse::().unwrap_or_default(); + let max_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if year % 400 == 0 || (year % 4 == 0 && year % 100 != 0) => 29, + 2 => 28, + _ => 0, + }; + if year < 2000 || day == 0 || day > max_day { + return Err(WechatPayError::InvalidRequest( + "微信支付账单日期无效".to_string(), + )); + } + Ok(value.to_string()) +} + +fn validate_trade_bill_download( + payload: WechatPayTradeBillResponse, +) -> Result { + let download = WechatPayTradeBillDownload { + hash_type: payload.hash_type.trim().to_ascii_uppercase(), + hash_value: payload.hash_value.trim().to_ascii_lowercase(), + download_url: payload.download_url.trim().to_string(), + }; + validate_trade_bill_download_contract(&download)?; + Ok(download) +} + +fn validate_trade_bill_download_contract( + download: &WechatPayTradeBillDownload, +) -> Result<(), WechatPayError> { + if download.hash_type != "SHA1" + || download.hash_value.len() != 40 + || !download.hash_value.chars().all(|ch| ch.is_ascii_hexdigit()) + || download.download_url.is_empty() + { + return Err(WechatPayError::InvalidRequest( + "微信支付退款交易账单下载契约无效".to_string(), + )); + } + Ok(()) +} + +fn verify_trade_bill_hash( + download: &WechatPayTradeBillDownload, + csv_bytes: &[u8], +) -> Result<(), WechatPayError> { + let actual = hex::encode(Sha1::digest(csv_bytes)); + if !actual.eq_ignore_ascii_case(&download.hash_value) { + return Err(WechatPayError::InvalidSignature( + "微信支付退款交易账单 SHA1 校验失败".to_string(), + )); + } + Ok(()) +} + +fn parse_refund_trade_bill_csv( + csv_bytes: &[u8], +) -> Result, WechatPayError> { + let mut reader = csv::ReaderBuilder::new() + .flexible(true) + .from_reader(csv_bytes); + let headers = reader + .headers() + .map_err(|error| { + WechatPayError::Deserialize(format!("微信支付退款交易账单表头解析失败:{error}")) + })? + .iter() + .map(normalize_trade_bill_field) + .collect::>(); + let header_index = headers + .iter() + .enumerate() + .map(|(index, value)| (value.as_str(), index)) + .collect::>(); + let mut rows = Vec::new(); + for record in reader.records() { + let record = record.map_err(|error| { + WechatPayError::Deserialize(format!("微信支付退款交易账单行解析失败:{error}")) + })?; + let trade_state = trade_bill_value(&record, &header_index, &["交易状态", "trade_state"]) + .unwrap_or_default(); + if !trade_state.eq_ignore_ascii_case("REFUND") { + continue; + } + let transaction_id = + required_trade_bill_value(&record, &header_index, &["微信订单号", "transaction_id"])?; + let out_trade_no = + required_trade_bill_value(&record, &header_index, &["商户订单号", "out_trade_no"])?; + let refund_id = + required_trade_bill_value(&record, &header_index, &["微信退款单号", "refund_id"])?; + let out_refund_no = + required_trade_bill_value(&record, &header_index, &["商户退款单号", "out_refund_no"])?; + rows.push(WechatPayTradeBillRefundRow { + transaction_id, + out_trade_no, + refund_id, + out_refund_no, + accepted_time: trade_bill_value( + &record, + &header_index, + &["退款申请时间", "交易时间", "refund_apply_time"], + ), + success_time: trade_bill_value( + &record, + &header_index, + &["退款成功时间", "refund_success_time"], + ), + refund_type: required_trade_bill_value( + &record, + &header_index, + &["退款类型", "refund_type"], + )?, + bill_refund_status: required_trade_bill_value( + &record, + &header_index, + &["退款状态", "refund_status"], + )?, + requested_refund_cents: parse_yuan_to_cents(&required_trade_bill_value( + &record, + &header_index, + &["申请退款金额", "requested_refund_amount"], + )?)?, + refunded_cents: parse_optional_trade_bill_amount(trade_bill_value( + &record, + &header_index, + &["退款金额", "refund_amount"], + ))?, + coupon_refund_cents: parse_optional_trade_bill_amount(trade_bill_value( + &record, + &header_index, + &["充值券退款金额", "coupon_refund_amount"], + ))?, + app_id: trade_bill_value(&record, &header_index, &["公众账号ID", "appid"]), + mch_id: trade_bill_value(&record, &header_index, &["商户号", "mchid"]), + }); + } + Ok(rows) +} + +fn normalize_trade_bill_field(value: &str) -> String { + value + .trim_start_matches('\u{feff}') + .trim() + .trim_start_matches('`') + .trim() + .to_string() +} + +fn trade_bill_value( + record: &csv::StringRecord, + header_index: &BTreeMap<&str, usize>, + aliases: &[&str], +) -> Option { + aliases.iter().find_map(|alias| { + let index = header_index.get(alias)?; + record + .get(*index) + .map(normalize_trade_bill_field) + .filter(|value| !value.is_empty()) + }) +} + +fn required_trade_bill_value( + record: &csv::StringRecord, + header_index: &BTreeMap<&str, usize>, + aliases: &[&str], +) -> Result { + trade_bill_value(record, header_index, aliases).ok_or_else(|| { + WechatPayError::Deserialize(format!("微信支付退款交易账单缺少字段:{}", aliases[0])) + }) +} + +fn parse_optional_trade_bill_amount(value: Option) -> Result { + value + .as_deref() + .map(parse_yuan_to_cents) + .transpose() + .map(|value| value.unwrap_or_default()) +} + +fn parse_yuan_to_cents(value: &str) -> Result { + let value = normalize_trade_bill_field(value) + .trim_start_matches('¥') + .trim_start_matches('+') + .to_string(); + if value.is_empty() || value.starts_with('-') { + return Err(WechatPayError::Deserialize( + "微信支付退款交易账单金额无效".to_string(), + )); + } + let mut parts = value.split('.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() || whole.is_empty() || !whole.chars().all(|ch| ch.is_ascii_digit()) { + return Err(WechatPayError::Deserialize( + "微信支付退款交易账单金额格式无效".to_string(), + )); + } + let fraction_cents = match fraction { + None | Some("") => 0, + Some(value) if value.len() <= 2 && value.chars().all(|ch| ch.is_ascii_digit()) => { + let value = value.parse::().unwrap_or_default(); + if fraction.unwrap_or_default().len() == 1 { + value * 10 + } else { + value + } + } + _ => { + return Err(WechatPayError::Deserialize( + "微信支付退款交易账单金额精度超过分".to_string(), + )); + } + }; + whole + .parse::() + .ok() + .and_then(|whole| whole.checked_mul(100)) + .and_then(|whole| whole.checked_add(fraction_cents)) + .ok_or_else(|| WechatPayError::Deserialize("微信支付退款交易账单金额超出范围".to_string())) +} + fn validate_jsapi_order_request( client: &RealWechatPayClient, request: &WechatMiniProgramOrderRequest, @@ -1690,12 +3504,30 @@ impl std::fmt::Display for WechatPayError { } } +impl WechatPayError { + pub fn diagnostic_code(&self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::InvalidConfig(_) => "invalid_config", + Self::InvalidRequest(_) => "invalid_request", + Self::OrderNotExist(_) => "order_not_exist", + Self::RequestFailed(_) => "request_failed", + Self::Upstream(_) => "upstream", + Self::Deserialize(_) => "deserialize", + Self::Crypto(_) => "crypto", + Self::InvalidSignature(_) => "invalid_signature", + } + } +} + impl std::error::Error for WechatPayError {} #[cfg(test)] mod tests { use super::*; use cbc::cipher::{BlockEncryptMut, block_padding::NoPadding}; + use openssl::{pkey::PKey, rsa::Rsa}; + use reqwest::header::HeaderValue; use serde_json::json; const TEST_RSA_PUBLIC_KEY_SPKI_PEM: &str = r#"-----BEGIN PUBLIC KEY----- @@ -1709,6 +3541,9 @@ KwIDAQAB -----END PUBLIC KEY-----"#; const TEST_RSA_SIGNATURE_BASE64: &str = "cuwiNfJ/Ck0UiG1xZl6T1tyAtap91hAXrfpH4FPI+8nzrbnX9NHj5T5DiUdGeuR+Y1BQ+N+y4M4SOih4g2oArdmWeGgfoKE/N7O61fN3SaEGfhSqSedrtTc02j3Yvk/2HeBtsdcgaLG8xb/ZFGifVTHAeGaHpT4Yy1tmP6V+Kd6FUoMVJsXdyDBxRRzWqssIKfEIfBO0gCZ/j34Hqrt6KLYeBu6hMW77YCe0ShpZO4An3MxpjcYAlkeF8fuhWdQsPz4DcF00mtoJWYg2ncTb6OCsCc4YeUC2dKHWo6S7vxsnr9qwp2XvRnRuYp9kHN7oTOCfs851lYmTYJJfMvjlLg=="; + const TEST_REFUND_API_V3_KEY: &str = "0123456789abcdef0123456789abcdef"; + const TEST_REFUND_MCH_ID: &str = "1900000100"; + const TEST_REFUND_PLATFORM_SERIAL: &str = "PUB_KEY_ID_REFUND_TEST"; #[test] fn mock_pay_params_use_request_payment_shape() { @@ -1720,6 +3555,75 @@ KwIDAQAB assert!(!params.pay_sign.is_empty()); } + #[test] + fn signed_json_error_preserves_refund_not_found_for_reconciliation() { + let error = map_signed_json_error_response( + "微信支付退款查询", + reqwest::StatusCode::NOT_FOUND, + r#"{"code":"RESOURCE_NOT_EXISTS","message":"退款单不存在"}"#.as_bytes(), + ); + + assert!(matches!( + error, + WechatPayError::OrderNotExist(message) if message == "退款单不存在" + )); + + let error = map_signed_json_error_response( + "微信支付退款查询", + reqwest::StatusCode::BAD_GATEWAY, + r#"{"code":"SYSTEM_ERROR","message":"系统错误"}"#.as_bytes(), + ); + assert!(matches!( + error, + WechatPayError::Upstream(message) + if message == "微信支付退款查询失败:SYSTEM_ERROR: 系统错误" + )); + } + + #[test] + fn out_refund_no_accepts_all_provider_documented_ascii_symbols() { + assert_eq!( + normalize_out_refund_no("refund_A-1|2*@3").expect("out_refund_no should be valid"), + "refund_A-1|2*@3" + ); + } + + #[test] + fn refund_response_must_correlate_with_the_signed_request() { + let request = WechatPayRefundRequest { + transaction_id: "transaction-1".to_string(), + out_trade_no: "order-1".to_string(), + out_refund_no: "refund-1".to_string(), + reason: None, + notify_url: "https://api.example.com/refund-notify".to_string(), + refund_amount_cents: 300, + total_amount_cents: 600, + }; + let refund = build_mock_refund(&request, "PROCESSING"); + + assert!(validate_created_refund_response(&refund, &request).is_ok()); + assert!(validate_queried_refund_response(&refund, "refund-1").is_ok()); + + let mut mismatched_order = refund.clone(); + mismatched_order.out_trade_no = "order-2".to_string(); + assert!(matches!( + validate_created_refund_response(&mismatched_order, &request), + Err(WechatPayError::InvalidRequest(_)) + )); + + let mut mismatched_amount = refund.clone(); + mismatched_amount.amount_refund_cents = 200; + assert!(matches!( + validate_created_refund_response(&mismatched_amount, &request), + Err(WechatPayError::InvalidRequest(_)) + )); + + assert!(matches!( + validate_queried_refund_response(&refund, "refund-2"), + Err(WechatPayError::InvalidRequest(_)) + )); + } + #[test] fn jsapi_order_request_uses_wechat_v3_snake_case_fields() { let body = serde_json::to_value(WechatJsapiOrderRequest { @@ -1966,6 +3870,287 @@ KwIDAQAB ); } + #[test] + fn v3_transaction_notify_still_uses_the_shared_verified_envelope() { + let client = build_refund_notify_test_client(); + let (headers, body) = build_transaction_notify_test_request(&client); + let notify = client + .parse_notify(&headers, &body) + .expect("signed encrypted transaction callback should parse"); + + assert_eq!(notify.app_id.as_deref(), Some("wx-refund-test")); + assert_eq!(notify.mch_id.as_deref(), Some(TEST_REFUND_MCH_ID)); + assert_eq!(notify.out_trade_no, "transaction-order-secret"); + assert_eq!( + notify.transaction_id.as_deref(), + Some("transaction-id-secret") + ); + assert_eq!(notify.trade_state, "SUCCESS"); + assert_eq!( + notify.success_time.as_deref(), + Some("2026-07-13T10:34:57+08:00") + ); + assert_eq!(notify.amount_total_cents, Some(600)); + } + + #[test] + fn v3_refund_notify_verifies_decrypts_and_summarizes_all_terminal_events() { + let client = build_refund_notify_test_client(); + for (event_type, refund_status) in [ + ("REFUND.SUCCESS", "SUCCESS"), + ("REFUND.ABNORMAL", "ABNORMAL"), + ("REFUND.CLOSED", "CLOSED"), + ] { + let (headers, body) = + build_refund_notify_test_request(&client, event_type, refund_status); + let summary = client + .parse_refund_notify_debug(&headers, &body) + .expect("signed encrypted refund callback should parse"); + + assert_eq!(summary.event_type, event_type); + assert_eq!(summary.refund_status, refund_status); + assert!(summary.known_event); + assert_eq!(summary.original_type, "refund"); + assert_eq!(summary.algorithm, "AEAD_AES_256_GCM"); + assert_eq!(summary.amount_total_cents, 999); + assert_eq!(summary.amount_refund_cents, 888); + assert_eq!(summary.amount_payer_total_cents, 999); + assert_eq!(summary.amount_payer_refund_cents, 888); + assert!(summary.payload_fingerprint.starts_with("hmac-sha256:")); + } + } + + #[test] + fn v3_refund_notify_returns_verified_business_fact_alongside_safe_debug_summary() { + let client = build_refund_notify_test_client(); + let (headers, body) = + build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS"); + let notification = client + .parse_refund_notify(&headers, &body) + .expect("verified refund notification should parse"); + + assert_eq!(notification.notification_id, "EV-REFUND-SECRET"); + assert_eq!(notification.event_type, "REFUND.SUCCESS"); + assert_eq!( + notification.refund.mch_id.as_deref(), + Some(TEST_REFUND_MCH_ID) + ); + assert_eq!( + notification.refund.transaction_id, + "transaction-secret-value" + ); + assert_eq!(notification.refund.out_trade_no, "order-secret-value"); + assert_eq!(notification.refund.refund_id, "refund-secret-value"); + assert_eq!( + notification.refund.out_refund_no, + "merchant-refund-secret-value" + ); + assert_eq!(notification.refund.status, "SUCCESS"); + assert_eq!(notification.refund.amount_total_cents, 999); + assert_eq!(notification.refund.amount_refund_cents, 888); + assert_eq!(notification.debug.refund_status, "SUCCESS"); + } + + #[test] + fn v3_refund_amount_contract_rejects_payer_amounts_above_provider_totals() { + assert!(validate_wechat_refund_amounts(600, 300, 601, 300, "test").is_err()); + assert!(validate_wechat_refund_amounts(600, 300, 600, 301, "test").is_err()); + } + + #[test] + fn v3_signed_response_verification_rejects_body_and_serial_tampering() { + let client = build_refund_notify_test_client(); + let body = br#"{"status":"SUCCESS"}"#; + let headers = sign_refund_notify_test_headers(&client, body); + client + .verify_response_signature(&headers, body) + .expect("signed response should verify"); + + assert!(matches!( + client.verify_response_signature(&headers, br#"{"status":"CLOSED"}"#), + Err(WechatPayError::InvalidSignature(_)) + )); + let mut wrong_serial = headers; + wrong_serial.insert( + "Wechatpay-Serial", + HeaderValue::from_static("PUB_KEY_ID_WRONG"), + ); + assert!(matches!( + client.verify_response_signature(&wrong_serial, body), + Err(WechatPayError::InvalidSignature(_)) + )); + } + + #[test] + fn refund_trade_bill_csv_parses_platform_refund_with_decimal_cents() { + let csv = concat!( + "交易时间,公众账号ID,商户号,微信订单号,商户订单号,交易状态,微信退款单号,商户退款单号,退款申请时间,退款成功时间,申请退款金额,退款金额,充值券退款金额,退款类型,退款状态\n", + "`2026-07-13 18:17:20,`wx-test,`1900000001,`tx-1,`order-1,`REFUND,`refund-1,`merchant-refund-1,`2026-07-13 18:17:20,`2026-07-13 18:17:23,`6.00,`5.50,`0.50,`PLATFORM-ORIGINAL,`SUCCESS\n", + "总交易单数,1,,,,,,,,,,,,,\n" + ); + let rows = parse_refund_trade_bill_csv(csv.as_bytes()) + .expect("official-shaped refund bill should parse"); + + assert_eq!(rows.len(), 1); + let row = &rows[0]; + assert_eq!(row.transaction_id, "tx-1"); + assert_eq!(row.out_trade_no, "order-1"); + assert_eq!(row.refund_id, "refund-1"); + assert_eq!(row.out_refund_no, "merchant-refund-1"); + assert_eq!(row.refund_type, "PLATFORM-ORIGINAL"); + assert_eq!(row.bill_refund_status, "SUCCESS"); + assert_eq!(row.requested_refund_cents, 600); + assert_eq!(row.refunded_cents, 550); + assert_eq!(row.coupon_refund_cents, 50); + } + + #[test] + fn refund_trade_bill_amount_parser_is_fixed_point_and_rejects_extra_precision() { + assert_eq!(parse_yuan_to_cents("`0").expect("whole yuan"), 0); + assert_eq!(parse_yuan_to_cents("`6.0").expect("one decimal"), 600); + assert_eq!(parse_yuan_to_cents("`6.01").expect("two decimals"), 601); + assert!(parse_yuan_to_cents("6.001").is_err()); + assert!(parse_yuan_to_cents("-1.00").is_err()); + } + + #[test] + fn refund_trade_bill_hash_checks_decompressed_csv_bytes() { + let csv = b"header\nvalue\n"; + let download = WechatPayTradeBillDownload { + hash_type: "SHA1".to_string(), + hash_value: hex::encode(Sha1::digest(csv)), + download_url: "https://api.example.com/v3/billdownload/file?token=test".to_string(), + }; + verify_trade_bill_hash(&download, csv).expect("matching hash should pass"); + assert!(verify_trade_bill_hash(&download, b"tampered").is_err()); + } + + #[test] + fn v3_refund_notify_rejects_signature_and_ciphertext_tampering() { + let client = build_refund_notify_test_client(); + let (mut headers, body) = + build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS"); + headers.insert("Wechatpay-Signature", HeaderValue::from_static("AAAA")); + assert!(matches!( + client.parse_refund_notify_debug(&headers, &body), + Err(WechatPayError::InvalidSignature(_)) + )); + + let mut envelope: Value = + serde_json::from_slice(&body).expect("refund envelope should parse for tampering"); + let ciphertext = envelope["resource"]["ciphertext"] + .as_str() + .expect("ciphertext should be a string"); + let replacement = if ciphertext.starts_with('A') { + "B" + } else { + "A" + }; + envelope["resource"]["ciphertext"] = + Value::String(format!("{replacement}{}", &ciphertext[1..])); + let tampered_body = + serde_json::to_vec(&envelope).expect("tampered envelope should serialize"); + let tampered_headers = sign_refund_notify_test_headers(&client, &tampered_body); + assert!(matches!( + client.parse_refund_notify_debug(&tampered_headers, &tampered_body), + Err(WechatPayError::Crypto(_)) + )); + } + + #[test] + fn v3_refund_notify_rejects_replays_probes_and_inconsistent_status() { + let client = build_refund_notify_test_client(); + let (headers, body) = + build_refund_notify_test_request(&client, "REFUND.SUCCESS", "ABNORMAL"); + assert!(matches!( + client.parse_refund_notify_debug(&headers, &body), + Err(WechatPayError::InvalidRequest(_)) + )); + + let (headers, body) = build_refund_notify_test_request(&client, "REFUND.FUTURE", "FUTURE"); + assert!(matches!( + client.parse_refund_notify_debug(&headers, &body), + Err(WechatPayError::InvalidRequest(_)) + )); + + let (mut headers, body) = + build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS"); + headers.insert( + "Wechatpay-Nonce", + HeaderValue::from_static("tampered-header-nonce"), + ); + assert!(matches!( + client.parse_refund_notify_debug(&headers, &body), + Err(WechatPayError::InvalidSignature(_)) + )); + + let mut serial_headers = sign_refund_notify_test_headers(&client, &body); + serial_headers.insert( + "Wechatpay-Serial", + HeaderValue::from_static("PUB_KEY_ID_WRONG"), + ); + assert!(matches!( + client.parse_refund_notify_debug(&serial_headers, &body), + Err(WechatPayError::InvalidSignature(_)) + )); + + let mut probe_headers = sign_refund_notify_test_headers(&client, &body); + probe_headers.insert( + "Wechatpay-Signature", + HeaderValue::from_static("WECHATPAY/SIGNTEST/AAAA"), + ); + assert!(matches!( + client.parse_refund_notify_debug(&probe_headers, &body), + Err(WechatPayError::InvalidSignature(_)) + )); + + let expired_timestamp = (OffsetDateTime::now_utc().unix_timestamp() - 301).to_string(); + let expired_headers = + sign_refund_notify_test_headers_at(&client, &body, &expired_timestamp); + assert!(matches!( + client.parse_refund_notify_debug(&expired_headers, &body), + Err(WechatPayError::InvalidSignature(_)) + )); + } + + #[test] + fn v3_refund_notify_duplicate_is_stably_acknowledgeable_without_plaintext_leaks() { + let client = build_refund_notify_test_client(); + let (headers, body) = + build_refund_notify_test_request(&client, "REFUND.SUCCESS", "SUCCESS"); + let first = client + .parse_refund_notify_debug(&headers, &body) + .expect("first refund callback should parse"); + let duplicate = client + .parse_refund_notify_debug(&headers, &body) + .expect("duplicate refund callback should parse"); + assert_eq!(first, duplicate); + + let serialized = serde_json::to_string(&first).expect("summary should serialize"); + for plaintext in [ + "EV-REFUND-SECRET", + TEST_REFUND_MCH_ID, + "transaction-secret-value", + "order-secret-value", + "refund-secret-value", + "merchant-refund-secret-value", + "招商银行信用卡0403", + ] { + assert!( + !serialized.contains(plaintext), + "refund debug summary must not contain plaintext: {plaintext}" + ); + } + assert_eq!( + first + .user_received_account + .as_ref() + .expect("received account fingerprint should exist") + .bytes, + "招商银行信用卡0403".len() + ); + } + #[test] fn parse_mock_notify_defaults_success_state() { let notify = @@ -2002,6 +4187,164 @@ KwIDAQAB assert_eq!(notify.paid_at_micros, Some(1_710_000_001_000_000)); } + #[test] + fn parse_virtual_payment_notify_event_supports_notifications_without_order_id() { + assert_eq!( + parse_virtual_payment_notify_event( + br#"{"Event":"xpay_refund_notify","MchRefundId":"refund-1"}"#, + ) + .expect("refund event should parse"), + "xpay_refund_notify" + ); + assert_eq!( + parse_virtual_payment_notify_event( + br#""#, + ) + .expect("iOS refund query event should parse"), + "xpay_subscribe_ios_refund_query_notify" + ); + } + + #[test] + fn virtual_payment_debug_summary_recognizes_all_official_events() { + for event in WECHAT_VIRTUAL_PAYMENT_NOTIFY_EVENTS { + let body = format!(r#"{{"Event":"{event}"}}"#); + let summary = + build_virtual_payment_notify_debug_summary(body.as_bytes(), b"message-token") + .expect("official event should build a debug summary"); + + assert_eq!(summary.event, event); + assert!(summary.known_event, "event should be recognized: {event}"); + } + + let summary = build_virtual_payment_notify_debug_summary( + br#"{"Event":"xpay_future_notify"}"#, + b"message-token", + ) + .expect("unknown event should still build a debug summary"); + assert!(!summary.known_event); + assert_eq!(summary.event, "unknown"); + assert!(summary.event_ref.is_some()); + assert!( + !serde_json::to_string(&summary) + .expect("unknown summary should serialize") + .contains("xpay_future_notify") + ); + } + + #[test] + fn virtual_payment_debug_summary_is_useful_without_logging_plaintext_secrets() { + let body = serde_json::to_vec(&json!({ + "Event": "xpay_goods_deliver_notify", + "CreateTime": 1_777_111_300, + "Env": 0, + "OpenId": "openid-secret-value", + "OutTradeNo": "order-secret-value", + "WeChatPayInfo": { + "TransactionId": "transaction-secret-value", + "PaidTime": 1_777_111_301 + }, + "AppleSubscriptionInfo": { + "OriginalTransactionId": "apple-transaction-secret-value", + "ProductId": "vip_month", + "SubscribePeriodDays": 30, + "Attach": "private-attach-value" + }, + "ComplaintDetail": "private-complaint-value", + "MerchantCode": "merchant-secret-value", + "BusinessCode": "business-secret-value", + "BusinessState": "state-secret-value", + "Code": "code-secret-value", + "user-secret-as-key": "not-recorded" + })) + .expect("debug fixture should serialize"); + + let summary = build_virtual_payment_notify_debug_summary(&body, b"message-token") + .expect("debug summary should parse"); + let serialized = serde_json::to_string(&summary).expect("debug summary should serialize"); + + assert!(summary.apple_subscription_info); + assert!(summary.subscription_info); + assert!(summary.primary_identifier_ref("user_ref").is_some()); + assert!(summary.primary_identifier_ref("order_ref").is_some()); + assert!( + summary + .primary_identifier_ref("provider_order_ref") + .is_some() + ); + assert_eq!( + summary.safe_fields.get("wechatpayinfo.paidtime"), + Some(&json!(1_777_111_301_i64)) + ); + assert_eq!( + summary.safe_fields["applesubscriptioninfo.productid"]["chars"], + json!(9) + ); + assert!( + summary + .sensitive_text_fields + .contains_key("applesubscriptioninfo.attach") + ); + assert!(summary.schema_fields.contains(&"openid".to_string())); + assert!( + summary + .schema_fields + .iter() + .any(|field| field.starts_with("unknown_")) + ); + assert!(summary.payload_fingerprint.starts_with("hmac-sha256:")); + for secret in [ + "openid-secret-value", + "order-secret-value", + "transaction-secret-value", + "apple-transaction-secret-value", + "private-attach-value", + "private-complaint-value", + "merchant-secret-value", + "business-secret-value", + "state-secret-value", + "code-secret-value", + "user-secret-as-key", + "vip_month", + ] { + assert!( + !serialized.contains(secret), + "debug output must not contain plaintext secret: {secret}" + ); + } + } + + #[test] + fn virtual_payment_debug_summary_covers_ios_refund_xml_fields() { + let summary = build_virtual_payment_notify_debug_summary( + br#"17771113001"#, + b"message-token", + ) + .expect("iOS refund query XML should build a debug summary"); + let serialized = serde_json::to_string(&summary).expect("summary should serialize"); + + assert_eq!(summary.event, "xpay_subscribe_ios_refund_query_notify"); + assert!(summary.primary_identifier_ref("order_ref").is_some()); + assert!( + summary + .primary_identifier_ref("provider_order_ref") + .is_some() + ); + assert_eq!( + summary.safe_fields.get("refundtime"), + Some(&json!(1_777_111_300_i64)) + ); + assert_eq!(summary.safe_fields.get("pcount"), Some(&json!(1_i64))); + assert!( + summary + .sensitive_text_fields + .contains_key("refundrequestreason") + ); + for secret in ["order-secret", "channel-secret", "private-reason"] { + assert!(!serialized.contains(secret)); + } + } + #[test] fn parse_virtual_payment_notify_rejects_missing_order_no() { let error = parse_virtual_payment_notify(br#"{"Event":"xpay_goods_deliver_notify"}"#) @@ -2015,6 +4358,19 @@ KwIDAQAB } } + #[test] + fn parse_virtual_payment_notify_leaves_missing_paid_time_for_authoritative_query() { + for body in [ + br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1"}"#.as_slice(), + br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"PaidTime":0}}"#.as_slice(), + br#"{"Event":"xpay_goods_deliver_notify","OutTradeNo":"order-1","WeChatPayInfo":{"PaidTime":9223372036854775807}}"#.as_slice(), + ] { + let notify = parse_virtual_payment_notify(body) + .expect("order id is sufficient before authoritative query"); + assert_eq!(notify.paid_at_micros, None); + } + } + #[test] fn decode_wechat_message_push_encoding_aes_key_allows_trailing_bits() { let canonical_key = BASE64_STANDARD.encode([0u8; 32]); @@ -2108,6 +4464,185 @@ KwIDAQAB } } + fn build_refund_notify_test_client() -> RealWechatPayClient { + let rsa = Rsa::generate(2_048).expect("refund test RSA key should generate"); + let key_pair = PKey::from_rsa(rsa).expect("refund test key pair should build"); + let private_key_pem = String::from_utf8( + key_pair + .private_key_to_pem_pkcs8() + .expect("refund test private key should encode"), + ) + .expect("refund test private key PEM should be UTF-8"); + let public_key_pem = String::from_utf8( + key_pair + .public_key_to_pem() + .expect("refund test public key should encode"), + ) + .expect("refund test public key PEM should be UTF-8"); + RealWechatPayClient { + client: reqwest::Client::new(), + app_id: "wx-refund-test".to_string(), + mch_id: TEST_REFUND_MCH_ID.to_string(), + merchant_serial_no: "merchant-refund-test".to_string(), + private_key: Arc::new( + parse_rsa_private_key(&private_key_pem) + .expect("refund test private key should parse"), + ), + platform_public_key_der: parse_public_key_pem(&public_key_pem) + .expect("refund test public key should parse"), + platform_serial_no: TEST_REFUND_PLATFORM_SERIAL.to_string(), + api_v3_key: TEST_REFUND_API_V3_KEY.to_string(), + notify_url: "https://api.example.com/api/profile/recharge/wechat/notify".to_string(), + jsapi_endpoint: "https://api.example.com/v3/pay/transactions/jsapi".to_string(), + h5_endpoint: "https://api.example.com/v3/pay/transactions/h5".to_string(), + native_endpoint: "https://api.example.com/v3/pay/transactions/native".to_string(), + query_order_endpoint_base: "https://api.example.com/v3/pay/transactions/out-trade-no" + .to_string(), + refund_endpoint: "https://api.example.com/v3/refund/domestic/refunds".to_string(), + trade_bill_endpoint: "https://api.example.com/v3/bill/tradebill".to_string(), + api_origin: "https://api.example.com".to_string(), + } + } + + fn build_transaction_notify_test_request(client: &RealWechatPayClient) -> (HeaderMap, Vec) { + let plain_text = serde_json::to_vec(&json!({ + "appid": "wx-refund-test", + "mchid": TEST_REFUND_MCH_ID, + "out_trade_no": "transaction-order-secret", + "transaction_id": "transaction-id-secret", + "trade_state": "SUCCESS", + "success_time": "2026-07-13T10:34:57+08:00", + "amount": { "total": 600 } + })) + .expect("transaction resource should serialize"); + let associated_data = "transaction-associated-data"; + let nonce = b"transnonce12"; + let ciphertext = encrypt_refund_notify_test_resource( + client.api_v3_key.as_bytes(), + nonce, + associated_data.as_bytes(), + &plain_text, + ); + let body = serde_json::to_vec(&json!({ + "id": "EV-TRANSACTION-SECRET", + "create_time": "2026-07-13T10:34:56+08:00", + "resource_type": "encrypt-resource", + "event_type": "TRANSACTION.SUCCESS", + "summary": "支付成功", + "resource": { + "algorithm": "AEAD_AES_256_GCM", + "original_type": "transaction", + "ciphertext": ciphertext, + "nonce": String::from_utf8_lossy(nonce), + "associated_data": associated_data + } + })) + .expect("transaction notification envelope should serialize"); + let headers = sign_refund_notify_test_headers(client, &body); + (headers, body) + } + + fn build_refund_notify_test_request( + client: &RealWechatPayClient, + event_type: &str, + refund_status: &str, + ) -> (HeaderMap, Vec) { + let success_time = (refund_status == "SUCCESS").then_some("2026-07-13T10:34:57+08:00"); + let plain_text = serde_json::to_vec(&json!({ + "mchid": TEST_REFUND_MCH_ID, + "transaction_id": "transaction-secret-value", + "out_trade_no": "order-secret-value", + "refund_id": "refund-secret-value", + "out_refund_no": "merchant-refund-secret-value", + "refund_status": refund_status, + "success_time": success_time, + "user_received_account": "招商银行信用卡0403", + "amount": { + "total": 999, + "refund": 888, + "payer_total": 999, + "payer_refund": 888 + } + })) + .expect("refund resource should serialize"); + let associated_data = "refund-associated-data"; + let nonce = b"refundnonce1"; + let ciphertext = encrypt_refund_notify_test_resource( + client.api_v3_key.as_bytes(), + nonce, + associated_data.as_bytes(), + &plain_text, + ); + let body = serde_json::to_vec(&json!({ + "id": "EV-REFUND-SECRET", + "create_time": "2026-07-13T10:34:56+08:00", + "resource_type": "encrypt-resource", + "event_type": event_type, + "summary": "退款结果", + "resource": { + "algorithm": "AEAD_AES_256_GCM", + "original_type": "refund", + "ciphertext": ciphertext, + "nonce": String::from_utf8_lossy(nonce), + "associated_data": associated_data + } + })) + .expect("refund notification envelope should serialize"); + let headers = sign_refund_notify_test_headers(client, &body); + (headers, body) + } + + fn sign_refund_notify_test_headers(client: &RealWechatPayClient, body: &[u8]) -> HeaderMap { + let timestamp = OffsetDateTime::now_utc().unix_timestamp().to_string(); + sign_refund_notify_test_headers_at(client, body, ×tamp) + } + + fn sign_refund_notify_test_headers_at( + client: &RealWechatPayClient, + body: &[u8], + timestamp: &str, + ) -> HeaderMap { + let nonce = "refund-header-nonce"; + let message = build_notify_signature_message(timestamp.as_bytes(), nonce.as_bytes(), body); + let signature = client + .sign_message( + std::str::from_utf8(&message).expect("test signature message should be UTF-8"), + ) + .expect("refund test callback should sign"); + let mut headers = HeaderMap::new(); + headers.insert( + "Wechatpay-Timestamp", + HeaderValue::from_str(timestamp).expect("timestamp header should be valid"), + ); + headers.insert("Wechatpay-Nonce", HeaderValue::from_static(nonce)); + headers.insert( + "Wechatpay-Signature", + HeaderValue::from_str(&signature).expect("signature header should be valid"), + ); + headers.insert( + "Wechatpay-Serial", + HeaderValue::from_static(TEST_REFUND_PLATFORM_SERIAL), + ); + headers + } + + fn encrypt_refund_notify_test_resource( + key: &[u8], + nonce: &[u8], + associated_data: &[u8], + plain_text: &[u8], + ) -> String { + let nonce = aead::Nonce::try_assume_unique_for_key(nonce) + .expect("refund test nonce should be valid"); + let key = aead::UnboundKey::new(&aead::AES_256_GCM, key) + .expect("refund test key should be valid"); + let key = aead::LessSafeKey::new(key); + let mut ciphertext = plain_text.to_vec(); + key.seal_in_place_append_tag(nonce, aead::Aad::from(associated_data), &mut ciphertext) + .expect("refund test resource should encrypt"); + BASE64_STANDARD.encode(ciphertext) + } + fn build_wechat_message_push_test_signature( token: &str, timestamp: &str, diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index 5927f2e4d..159d54c9e 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -640,6 +640,216 @@ pub struct AdminTrackingEventKeyListResponse { pub event_keys: Vec, } +/// 后台充值订单筛选参数。陶泥号由 BFF 解析为内部用户 ID 后再访问运行时读模型。 +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeOrderListQuery { + pub order_id: Option, + pub provider_transaction_id: Option, + pub user_id: Option, + pub public_user_code: Option, + pub payment_channel: Option, + pub status: Option, + pub created_after: Option, + pub created_before: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUserSummaryPayload { + pub user_id: String, + pub public_user_code: String, + pub display_name: String, + pub avatar_url: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminWalletManualRestrictionPayload { + pub frozen: bool, + pub reason: String, + pub created_by_admin_user_id: String, + pub created_at_micros: i64, + pub updated_by_admin_user_id: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminProfileWalletPayload { + pub user_id: String, + pub total_balance: u64, + pub spendable_balance: u64, + pub daily_free_points: u64, + pub membership_limited_points: u64, + pub permanent_points: u64, + pub held_points: u64, + pub refund_debt_points: u64, + pub manual_frozen: bool, + pub refund_debt_frozen: bool, + pub wallet_frozen: bool, + pub manual_restriction: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundPayload { + pub out_refund_no: String, + pub provider_refund_id: String, + pub provider_status: String, + pub refund_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub first_observed_at_micros: i64, + pub updated_at_micros: i64, + pub observation_source: String, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: String, + pub last_error_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundHoldPayload { + pub out_refund_no: String, + pub refund_cents: u64, + pub held_points: u64, + pub status: String, + pub admin_user_id: String, + pub reason: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeOrderEntryPayload { + pub order_id: String, + pub user_id: String, + pub user: Option, + pub product_id: String, + pub product_title: String, + pub product_kind: String, + pub amount_cents: u64, + pub status: String, + pub payment_channel: String, + pub paid_at_micros: Option, + pub provider_transaction_id: Option, + pub created_at_micros: i64, + pub points_delta: i64, + pub cumulative_success_refund_cents: u64, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: Option, + pub wallet: AdminProfileWalletPayload, + pub refunds: Vec, + pub active_hold: Option, + pub remaining_refundable_cents: u64, + pub refund_eligible: bool, + pub refund_block_reason_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeOrderListResponse { + pub entries: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUserDetailQuery { + pub user_id: Option, + pub public_user_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminUserDetailResponse { + pub user_id: String, + pub public_user_code: String, + pub display_name: String, + pub avatar_url: Option, + pub phone_number_masked: Option, + pub login_method: String, + pub binding_status: String, + pub phone_bound: bool, + pub wechat_bound: bool, + pub wallet: AdminProfileWalletPayload, + pub recharge_orders: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundPreviewRequest { + pub order_id: String, + pub refund_amount_cents: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundExecuteRequest { + pub order_id: String, + pub refund_amount_cents: u64, + pub request_id: String, + pub reason: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundRegisterRequest { + pub out_refund_no: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminWalletRestrictionRequest { + pub user_id: String, + pub frozen: bool, + pub reason: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminWechatPaymentCheckPayload { + pub verified: bool, + pub trade_state: String, + pub transaction_id: Option, + pub amount_total_cents: Option, + pub known_refunds_refreshed: u32, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundPreviewResponse { + pub order: AdminRechargeOrderEntryPayload, + pub payment_check: AdminWechatPaymentCheckPayload, + pub refund_amount_cents: u64, + pub incremental_recovery_points: u64, + pub remaining_refundable_cents: u64, + pub can_submit: bool, + pub block_reason_code: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminRechargeRefundActionResponse { + pub out_refund_no: String, + pub provider_status: String, + pub result_code: String, + pub provider_status_unknown: bool, + pub order: AdminRechargeOrderEntryPayload, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AdminWalletRestrictionResponse { + pub wallet: AdminProfileWalletPayload, +} + #[cfg(test)] mod tests { use serde_json::json; diff --git a/server-rs/crates/shared-contracts/src/runtime.rs b/server-rs/crates/shared-contracts/src/runtime.rs index 67afc5fa0..aeab11183 100644 --- a/server-rs/crates/shared-contracts/src/runtime.rs +++ b/server-rs/crates/shared-contracts/src/runtime.rs @@ -13,6 +13,8 @@ pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_MEMBERSHIP_PERIOD_RESET: &str = "membership_period_reset"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_GRANT: &str = "daily_free_grant"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_DAILY_FREE_RESET: &str = "daily_free_reset"; +pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_RECHARGE_REFUND_RECOVERY: &str = + "recharge_refund_recovery"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITER_REWARD: &str = "invite_inviter_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_INVITE_INVITEE_REWARD: &str = "invite_invitee_reward"; pub const PROFILE_WALLET_LEDGER_SOURCE_TYPE_ASSET_OPERATION_CONSUME: &str = diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 43c2d4b2a..baf9e351e 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -294,12 +294,14 @@ pub(crate) use self::runtime::{ parse_json_string_array, parse_json_value, parse_supported_actions_json, }; pub(crate) use self::runtime_profile::{ - map_analytics_metric_query_procedure_result, map_runtime_profile_dashboard_procedure_result, + map_analytics_metric_query_procedure_result, map_runtime_profile_admin_wallet_procedure_result, + map_runtime_profile_dashboard_procedure_result, map_runtime_profile_feedback_submission_procedure_result, map_runtime_profile_invite_code_admin_list_procedure_result, 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_admin_list_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, @@ -307,6 +309,12 @@ pub(crate) use self::runtime_profile::{ map_runtime_profile_recharge_order_procedure_result, map_runtime_profile_recharge_product_admin_list_procedure_result, map_runtime_profile_recharge_product_admin_procedure_result, + map_runtime_profile_recharge_refund_bill_checkpoint_optional_result, + map_runtime_profile_recharge_refund_bill_checkpoint_required_result, + map_runtime_profile_recharge_refund_hold_list_procedure_result, + map_runtime_profile_recharge_refund_hold_procedure_result, + map_runtime_profile_recharge_refund_list_procedure_result, + map_runtime_profile_recharge_refund_procedure_result, map_runtime_profile_redeem_code_admin_list_procedure_result, map_runtime_profile_redeem_code_admin_procedure_result, map_runtime_profile_reward_code_redeem_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs index 47bdfc651..b893ce225 100644 --- a/server-rs/crates/spacetime-client/src/mapper/puzzle.rs +++ b/server-rs/crates/spacetime-client/src/mapper/puzzle.rs @@ -615,6 +615,9 @@ pub(crate) fn map_runtime_profile_wallet_ledger_source_type_back( crate::module_bindings::RuntimeProfileWalletLedgerSourceType::DailyFreeReset => { module_runtime::RuntimeProfileWalletLedgerSourceType::DailyFreeReset } + crate::module_bindings::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery => { + module_runtime::RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery + } } } diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 1294e945f..9cf7895a7 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -99,6 +99,155 @@ impl From } } +impl From + for RuntimeProfileRechargeOrderAdminListInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeOrderAdminListInput) -> Self { + Self { + order_id: input.order_id, + user_id: input.user_id, + provider_transaction_id: input.provider_transaction_id, + payment_channel: input.payment_channel, + status: input.status.map(map_runtime_profile_recharge_order_status), + created_after_micros: input.created_after_micros, + created_before_micros: input.created_before_micros, + limit: input.limit, + } + } +} + +impl From + for RuntimeProfileRechargeRefundHoldPreviewInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundHoldPreviewInput) -> Self { + Self { + order_id: input.order_id, + refund_cents: input.refund_cents, + } + } +} + +impl From + for RuntimeProfileRechargeRefundHoldPrepareInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundHoldPrepareInput) -> Self { + Self { + order_id: input.order_id, + out_refund_no: input.out_refund_no, + refund_cents: input.refund_cents, + admin_user_id: input.admin_user_id, + reason: input.reason, + } + } +} + +impl From + for RuntimeProfileRechargeRefundHoldReleaseInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundHoldReleaseInput) -> Self { + Self { + out_refund_no: input.out_refund_no, + admin_user_id: input.admin_user_id, + release_reason: input.release_reason, + } + } +} + +impl From + for RuntimeProfileRechargeRefundHoldListInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundHoldListInput) -> Self { + Self { limit: input.limit } + } +} + +impl From for RuntimeProfileAdminWalletGetInput { + fn from(input: module_runtime::RuntimeProfileAdminWalletGetInput) -> Self { + Self { + user_id: input.user_id, + } + } +} + +impl From + for RuntimeProfileWalletManualRestrictionUpsertInput +{ + fn from(input: module_runtime::RuntimeProfileWalletManualRestrictionUpsertInput) -> Self { + Self { + user_id: input.user_id, + frozen: input.frozen, + reason: input.reason, + admin_user_id: input.admin_user_id, + } + } +} + +impl From + for RuntimeProfileRechargeRefundObservationInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundObservationInput) -> Self { + Self { + observation_id: input.observation_id, + source: map_runtime_profile_recharge_refund_observation_source(input.source), + notification_ref: input.notification_ref, + payload_fingerprint: input.payload_fingerprint, + out_refund_no: input.out_refund_no, + provider_refund_id: input.provider_refund_id, + order_id: input.order_id, + provider_transaction_id: input.provider_transaction_id, + provider_status: map_runtime_profile_recharge_refund_status(input.provider_status), + total_cents: input.total_cents, + refund_cents: input.refund_cents, + payer_total_cents: input.payer_total_cents, + payer_refund_cents: input.payer_refund_cents, + success_at_micros: input.success_at_micros, + observed_at_micros: input.observed_at_micros, + } + } +} + +impl From + for RuntimeProfileRechargeRefundGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundGetInput) -> Self { + Self { + out_refund_no: input.out_refund_no, + } + } +} + +impl From + for RuntimeProfileRechargeRefundReconciliationListInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundReconciliationListInput) -> Self { + Self { limit: input.limit } + } +} + +impl From + for RuntimeProfileRechargeRefundBillCheckpointGetInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundBillCheckpointGetInput) -> Self { + Self { + checkpoint_id: input.checkpoint_id, + } + } +} + +impl From + for RuntimeProfileRechargeRefundBillCheckpointAdvanceInput +{ + fn from(input: module_runtime::RuntimeProfileRechargeRefundBillCheckpointAdvanceInput) -> Self { + Self { + checkpoint_id: input.checkpoint_id, + bill_date: input.bill_date, + bill_hash: input.bill_hash, + processed_refund_count: input.processed_refund_count, + completed_at_micros: input.completed_at_micros, + } + } +} + impl From for RuntimeProfileFeedbackSubmissionInput { @@ -514,6 +663,120 @@ pub(crate) fn map_runtime_profile_recharge_order_procedure_result( )) } +pub(crate) fn map_runtime_profile_recharge_refund_procedure_result( + result: RuntimeProfileRechargeRefundProcedureResult, +) -> Result< + ( + module_runtime::RuntimeProfileRechargeRefundSnapshot, + Option, + bool, + String, + ), + SpacetimeClientError, +> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + let record = result + .record + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge refund 快照"))?; + Ok(( + map_runtime_profile_recharge_refund_snapshot(record), + result + .settlement + .map(map_runtime_profile_recharge_order_refund_settlement_snapshot), + result.duplicate, + result.resolution_code, + )) +} + +pub(crate) fn map_runtime_profile_recharge_refund_list_procedure_result( + result: RuntimeProfileRechargeRefundListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .entries + .into_iter() + .map(map_runtime_profile_recharge_refund_snapshot) + .collect()) +} + +pub(crate) fn map_runtime_profile_recharge_order_admin_list_procedure_result( + result: RuntimeProfileRechargeOrderAdminListProcedureResult, +) -> Result, SpacetimeClientError> +{ + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .entries + .into_iter() + .map(map_runtime_profile_recharge_order_admin_entry_snapshot) + .collect()) +} + +pub(crate) fn map_runtime_profile_recharge_refund_hold_procedure_result( + result: RuntimeProfileRechargeRefundHoldProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .record + .map(map_runtime_profile_recharge_refund_hold_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile recharge refund hold 快照")) +} + +pub(crate) fn map_runtime_profile_recharge_refund_hold_list_procedure_result( + result: RuntimeProfileRechargeRefundHoldListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .entries + .into_iter() + .map(map_runtime_profile_recharge_refund_hold_snapshot) + .collect()) +} + +pub(crate) fn map_runtime_profile_admin_wallet_procedure_result( + result: RuntimeProfileAdminWalletProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + result + .record + .map(map_runtime_profile_admin_wallet_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("profile admin wallet 快照")) +} + +pub(crate) fn map_runtime_profile_recharge_refund_bill_checkpoint_optional_result( + result: RuntimeProfileRechargeRefundBillCheckpointProcedureResult, +) -> Result< + Option, + SpacetimeClientError, +> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + Ok(result + .record + .map(map_runtime_profile_recharge_refund_bill_checkpoint_snapshot)) +} + +pub(crate) fn map_runtime_profile_recharge_refund_bill_checkpoint_required_result( + result: RuntimeProfileRechargeRefundBillCheckpointProcedureResult, +) -> Result +{ + map_runtime_profile_recharge_refund_bill_checkpoint_optional_result(result)?.ok_or_else(|| { + SpacetimeClientError::missing_snapshot("profile recharge refund bill checkpoint 快照") + }) +} + pub(crate) fn map_runtime_profile_recharge_order_expiration_claim_procedure_result( result: RuntimeProfileRechargeOrderExpirationClaimProcedureResult, ) -> Result< @@ -1124,6 +1387,147 @@ pub(crate) fn map_runtime_profile_recharge_order_snapshot( } } +pub(crate) fn map_runtime_profile_recharge_refund_snapshot( + snapshot: RuntimeProfileRechargeRefundSnapshot, +) -> module_runtime::RuntimeProfileRechargeRefundSnapshot { + module_runtime::RuntimeProfileRechargeRefundSnapshot { + out_refund_no: snapshot.out_refund_no, + provider_refund_id: snapshot.provider_refund_id, + order_id: snapshot.order_id, + provider_transaction_id: snapshot.provider_transaction_id, + user_id: snapshot.user_id, + provider_status: map_runtime_profile_recharge_refund_status_back(snapshot.provider_status), + total_cents: snapshot.total_cents, + refund_cents: snapshot.refund_cents, + payer_total_cents: snapshot.payer_total_cents, + payer_refund_cents: snapshot.payer_refund_cents, + success_at_micros: snapshot.success_at_micros, + first_observed_at_micros: snapshot.first_observed_at_micros, + updated_at_micros: snapshot.updated_at_micros, + last_observation_source: map_runtime_profile_recharge_refund_observation_source_back( + snapshot.last_observation_source, + ), + last_observation_id: snapshot.last_observation_id, + order_settled_at_micros: snapshot.order_settled_at_micros, + target_recovery_points: snapshot.target_recovery_points, + recovered_points: snapshot.recovered_points, + unrecovered_points: snapshot.unrecovered_points, + recovery_status: map_runtime_profile_recharge_refund_recovery_status_back( + snapshot.recovery_status, + ), + last_recovery_ledger_id: snapshot.last_recovery_ledger_id, + last_error_code: snapshot.last_error_code, + } +} + +pub(crate) fn map_runtime_profile_recharge_order_refund_settlement_snapshot( + snapshot: RuntimeProfileRechargeOrderRefundSettlementSnapshot, +) -> module_runtime::RuntimeProfileRechargeOrderRefundSettlementSnapshot { + module_runtime::RuntimeProfileRechargeOrderRefundSettlementSnapshot { + order_id: snapshot.order_id, + user_id: snapshot.user_id, + successful_refund_count: snapshot.successful_refund_count, + cumulative_success_refund_cents: snapshot.cumulative_success_refund_cents, + target_recovery_points: snapshot.target_recovery_points, + recovered_points: snapshot.recovered_points, + unrecovered_points: snapshot.unrecovered_points, + recovery_status: map_runtime_profile_recharge_refund_recovery_status_back( + snapshot.recovery_status, + ), + wallet_frozen: snapshot.wallet_frozen, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_hold_snapshot( + snapshot: RuntimeProfileRechargeRefundHoldSnapshot, +) -> module_runtime::RuntimeProfileRechargeRefundHoldSnapshot { + module_runtime::RuntimeProfileRechargeRefundHoldSnapshot { + out_refund_no: snapshot.out_refund_no, + order_id: snapshot.order_id, + user_id: snapshot.user_id, + refund_cents: snapshot.refund_cents, + held_points: snapshot.held_points, + status: map_runtime_profile_recharge_refund_hold_status_back(snapshot.status), + admin_user_id: snapshot.admin_user_id, + reason: snapshot.reason, + created_at_micros: snapshot.created_at_micros, + updated_at_micros: snapshot.updated_at_micros, + settled_at_micros: snapshot.settled_at_micros, + released_at_micros: snapshot.released_at_micros, + released_by_admin_user_id: snapshot.released_by_admin_user_id, + release_reason: snapshot.release_reason, + } +} + +pub(crate) fn map_runtime_profile_wallet_manual_restriction_snapshot( + snapshot: RuntimeProfileWalletManualRestrictionSnapshot, +) -> module_runtime::RuntimeProfileWalletManualRestrictionSnapshot { + module_runtime::RuntimeProfileWalletManualRestrictionSnapshot { + user_id: snapshot.user_id, + frozen: snapshot.frozen, + reason: snapshot.reason, + created_by_admin_user_id: snapshot.created_by_admin_user_id, + created_at_micros: snapshot.created_at_micros, + updated_by_admin_user_id: snapshot.updated_by_admin_user_id, + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_runtime_profile_admin_wallet_snapshot( + snapshot: RuntimeProfileAdminWalletSnapshot, +) -> module_runtime::RuntimeProfileAdminWalletSnapshot { + module_runtime::RuntimeProfileAdminWalletSnapshot { + user_id: snapshot.user_id, + total_balance: snapshot.total_balance, + spendable_balance: snapshot.spendable_balance, + daily_free_points: snapshot.daily_free_points, + membership_limited_points: snapshot.membership_limited_points, + permanent_points: snapshot.permanent_points, + held_points: snapshot.held_points, + refund_debt_points: snapshot.refund_debt_points, + manual_frozen: snapshot.manual_frozen, + refund_debt_frozen: snapshot.refund_debt_frozen, + wallet_frozen: snapshot.wallet_frozen, + manual_restriction: snapshot + .manual_restriction + .map(map_runtime_profile_wallet_manual_restriction_snapshot), + } +} + +pub(crate) fn map_runtime_profile_recharge_order_admin_entry_snapshot( + snapshot: RuntimeProfileRechargeOrderAdminEntrySnapshot, +) -> module_runtime::RuntimeProfileRechargeOrderAdminEntrySnapshot { + module_runtime::RuntimeProfileRechargeOrderAdminEntrySnapshot { + order: map_runtime_profile_recharge_order_snapshot(snapshot.order), + settlement: snapshot + .settlement + .map(map_runtime_profile_recharge_order_refund_settlement_snapshot), + refunds: snapshot + .refunds + .into_iter() + .map(map_runtime_profile_recharge_refund_snapshot) + .collect(), + active_hold: snapshot + .active_hold + .map(map_runtime_profile_recharge_refund_hold_snapshot), + wallet: map_runtime_profile_admin_wallet_snapshot(snapshot.wallet), + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_bill_checkpoint_snapshot( + snapshot: RuntimeProfileRechargeRefundBillCheckpointSnapshot, +) -> module_runtime::RuntimeProfileRechargeRefundBillCheckpointSnapshot { + module_runtime::RuntimeProfileRechargeRefundBillCheckpointSnapshot { + checkpoint_id: snapshot.checkpoint_id, + bill_date: snapshot.bill_date, + bill_hash: snapshot.bill_hash, + processed_refund_count: snapshot.processed_refund_count, + completed_at_micros: snapshot.completed_at_micros, + updated_at_micros: snapshot.updated_at_micros, + } +} + pub(crate) fn map_runtime_profile_recharge_order_expiration_schedule_snapshot( snapshot: RuntimeProfileRechargeOrderExpirationScheduleSnapshot, ) -> module_runtime::RuntimeProfileRechargeOrderExpirationScheduleSnapshot { @@ -1562,6 +1966,145 @@ pub(crate) fn map_runtime_profile_recharge_order_status_back( } } +pub(crate) fn map_runtime_profile_recharge_order_status( + value: module_runtime::RuntimeProfileRechargeOrderStatus, +) -> crate::module_bindings::RuntimeProfileRechargeOrderStatus { + match value { + module_runtime::RuntimeProfileRechargeOrderStatus::Pending => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Pending + } + module_runtime::RuntimeProfileRechargeOrderStatus::Paid => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Paid + } + module_runtime::RuntimeProfileRechargeOrderStatus::Failed => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Failed + } + module_runtime::RuntimeProfileRechargeOrderStatus::Closed => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Closed + } + module_runtime::RuntimeProfileRechargeOrderStatus::Refunded => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Refunded + } + module_runtime::RuntimeProfileRechargeOrderStatus::Expired => { + crate::module_bindings::RuntimeProfileRechargeOrderStatus::Expired + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_status( + value: module_runtime::RuntimeProfileRechargeRefundStatus, +) -> crate::module_bindings::RuntimeProfileRechargeRefundStatus { + match value { + module_runtime::RuntimeProfileRechargeRefundStatus::Processing => { + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Processing + } + module_runtime::RuntimeProfileRechargeRefundStatus::Success => { + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Success + } + module_runtime::RuntimeProfileRechargeRefundStatus::Abnormal => { + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Abnormal + } + module_runtime::RuntimeProfileRechargeRefundStatus::Closed => { + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Closed + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_status_back( + value: crate::module_bindings::RuntimeProfileRechargeRefundStatus, +) -> module_runtime::RuntimeProfileRechargeRefundStatus { + match value { + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Processing => { + module_runtime::RuntimeProfileRechargeRefundStatus::Processing + } + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Success => { + module_runtime::RuntimeProfileRechargeRefundStatus::Success + } + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Abnormal => { + module_runtime::RuntimeProfileRechargeRefundStatus::Abnormal + } + crate::module_bindings::RuntimeProfileRechargeRefundStatus::Closed => { + module_runtime::RuntimeProfileRechargeRefundStatus::Closed + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_hold_status_back( + value: crate::module_bindings::RuntimeProfileRechargeRefundHoldStatus, +) -> module_runtime::RuntimeProfileRechargeRefundHoldStatus { + match value { + crate::module_bindings::RuntimeProfileRechargeRefundHoldStatus::Active => { + module_runtime::RuntimeProfileRechargeRefundHoldStatus::Active + } + crate::module_bindings::RuntimeProfileRechargeRefundHoldStatus::Settled => { + module_runtime::RuntimeProfileRechargeRefundHoldStatus::Settled + } + crate::module_bindings::RuntimeProfileRechargeRefundHoldStatus::Released => { + module_runtime::RuntimeProfileRechargeRefundHoldStatus::Released + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_observation_source( + value: module_runtime::RuntimeProfileRechargeRefundObservationSource, +) -> crate::module_bindings::RuntimeProfileRechargeRefundObservationSource { + match value { + module_runtime::RuntimeProfileRechargeRefundObservationSource::ApiRequest => { + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::ApiRequest + } + module_runtime::RuntimeProfileRechargeRefundObservationSource::Callback => { + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::Callback + } + module_runtime::RuntimeProfileRechargeRefundObservationSource::Query => { + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::Query + } + module_runtime::RuntimeProfileRechargeRefundObservationSource::TradeBill => { + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::TradeBill + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_observation_source_back( + value: crate::module_bindings::RuntimeProfileRechargeRefundObservationSource, +) -> module_runtime::RuntimeProfileRechargeRefundObservationSource { + match value { + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::ApiRequest => { + module_runtime::RuntimeProfileRechargeRefundObservationSource::ApiRequest + } + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::Callback => { + module_runtime::RuntimeProfileRechargeRefundObservationSource::Callback + } + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::Query => { + module_runtime::RuntimeProfileRechargeRefundObservationSource::Query + } + crate::module_bindings::RuntimeProfileRechargeRefundObservationSource::TradeBill => { + module_runtime::RuntimeProfileRechargeRefundObservationSource::TradeBill + } + } +} + +pub(crate) fn map_runtime_profile_recharge_refund_recovery_status_back( + value: crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus, +) -> module_runtime::RuntimeProfileRechargeRefundRecoveryStatus { + match value { + crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus::Pending => { + module_runtime::RuntimeProfileRechargeRefundRecoveryStatus::Pending + } + crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus::Applied => { + module_runtime::RuntimeProfileRechargeRefundRecoveryStatus::Applied + } + crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus::Shortfall => { + module_runtime::RuntimeProfileRechargeRefundRecoveryStatus::Shortfall + } + crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus::ManualReview => { + module_runtime::RuntimeProfileRechargeRefundRecoveryStatus::ManualReview + } + crate::module_bindings::RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable => { + module_runtime::RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable + } + } +} + pub(crate) fn map_runtime_profile_feedback_status_back( value: crate::module_bindings::RuntimeProfileFeedbackStatus, ) -> module_runtime::RuntimeProfileFeedbackStatus { diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 01e478d4f..23f9a60f5 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -15,10 +15,12 @@ pub mod admin_disable_profile_task_config_procedure; pub mod admin_editor_asset_list_input_type; pub mod admin_editor_asset_list_procedure_result_type; pub mod admin_editor_asset_snapshot_type; +pub mod admin_get_profile_wallet_and_return_procedure; pub mod admin_get_profile_wallet_config_procedure; pub mod admin_list_editor_assets_and_return_procedure; pub mod admin_list_editor_showcase_assets_and_return_procedure; pub mod admin_list_profile_invite_codes_procedure; +pub mod admin_list_profile_recharge_orders_and_return_procedure; pub mod admin_list_profile_recharge_products_procedure; pub mod admin_list_profile_redeem_codes_procedure; pub mod admin_list_profile_task_configs_procedure; @@ -30,11 +32,13 @@ pub mod admin_upsert_profile_recharge_product_procedure; pub mod admin_upsert_profile_redeem_code_procedure; pub mod admin_upsert_profile_task_config_procedure; pub mod admin_upsert_profile_wallet_config_procedure; +pub mod admin_upsert_profile_wallet_manual_restriction_and_return_procedure; pub mod admin_work_visibility_list_input_type; pub mod admin_work_visibility_list_procedure_result_type; pub mod admin_work_visibility_procedure_result_type; pub mod admin_work_visibility_snapshot_type; pub mod admin_work_visibility_update_input_type; +pub mod advance_profile_recharge_refund_bill_checkpoint_and_return_procedure; pub mod advance_puzzle_clear_next_level_procedure; pub mod advance_puzzle_next_level_procedure; pub mod ai_result_reference_input_type; @@ -555,6 +559,8 @@ pub mod get_profile_dashboard_procedure; pub mod get_profile_play_stats_procedure; pub mod get_profile_recharge_center_procedure; pub mod get_profile_recharge_order_and_return_procedure; +pub mod get_profile_recharge_refund_and_return_procedure; +pub mod get_profile_recharge_refund_bill_checkpoint_and_return_procedure; pub mod get_profile_referral_invite_center_procedure; pub mod get_profile_task_center_procedure; pub mod get_puzzle_agent_session_procedure; @@ -661,6 +667,8 @@ pub mod list_external_generation_jobs_and_return_procedure; pub mod list_jump_hop_works_procedure; pub mod list_match_3_d_works_procedure; pub mod list_platform_browse_history_procedure; +pub mod list_profile_recharge_refund_holds_for_reconciliation_procedure; +pub mod list_profile_recharge_refunds_for_reconciliation_procedure; pub mod list_profile_save_archives_procedure; pub mod list_profile_wallet_ledger_procedure; pub mod list_public_editor_project_resources_and_return_procedure; @@ -740,6 +748,8 @@ pub mod player_progression_procedure_result_type; pub mod player_progression_snapshot_type; pub mod player_progression_table; pub mod player_progression_type; +pub mod prepare_profile_recharge_refund_hold_and_return_procedure; +pub mod preview_profile_recharge_refund_hold_and_return_procedure; pub mod profile_code_operation_table; pub mod profile_code_operation_type; pub mod profile_daily_free_points_table; @@ -758,10 +768,20 @@ 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_refund_settlement_table; +pub mod profile_recharge_order_refund_settlement_type; pub mod profile_recharge_order_table; pub mod profile_recharge_order_type; pub mod profile_recharge_product_config_table; pub mod profile_recharge_product_config_type; +pub mod profile_recharge_refund_bill_checkpoint_table; +pub mod profile_recharge_refund_bill_checkpoint_type; +pub mod profile_recharge_refund_hold_table; +pub mod profile_recharge_refund_hold_type; +pub mod profile_recharge_refund_observation_table; +pub mod profile_recharge_refund_observation_type; +pub mod profile_recharge_refund_table; +pub mod profile_recharge_refund_type; pub mod profile_redeem_code_table; pub mod profile_redeem_code_type; pub mod profile_redeem_code_usage_table; @@ -780,6 +800,8 @@ pub mod profile_wallet_config_table; pub mod profile_wallet_config_type; pub mod profile_wallet_ledger_table; pub mod profile_wallet_ledger_type; +pub mod profile_wallet_manual_restriction_table; +pub mod profile_wallet_manual_restriction_type; pub mod public_work_asset_read_grant_table; pub mod public_work_asset_read_grant_type; pub mod public_work_detail_entry_table; @@ -958,6 +980,7 @@ pub mod record_big_fish_play_procedure; pub mod record_custom_world_profile_like_procedure; pub mod record_custom_world_profile_play_procedure; pub mod record_daily_login_tracking_event_and_return_procedure; +pub mod record_profile_recharge_refund_observation_and_return_procedure; pub mod record_puzzle_work_like_procedure; pub mod record_tracking_event_and_return_procedure; pub mod record_tracking_events_and_return_procedure; @@ -967,6 +990,7 @@ pub mod redeem_profile_reward_code_procedure; pub mod refresh_session_table; pub mod refresh_session_type; pub mod refund_profile_wallet_points_and_return_procedure; +pub mod release_profile_recharge_refund_hold_and_return_procedure; pub mod release_puzzle_background_compile_task_procedure; pub mod remix_big_fish_work_procedure; pub mod remix_custom_world_profile_procedure; @@ -1019,6 +1043,9 @@ pub mod runtime_item_equipment_slot_type; pub mod runtime_item_reward_item_rarity_type; pub mod runtime_item_reward_item_snapshot_type; pub mod runtime_platform_theme_type; +pub mod runtime_profile_admin_wallet_get_input_type; +pub mod runtime_profile_admin_wallet_procedure_result_type; +pub mod runtime_profile_admin_wallet_snapshot_type; pub mod runtime_profile_code_operation_snapshot_type; pub mod runtime_profile_daily_free_points_snapshot_type; pub mod runtime_profile_dashboard_get_input_type; @@ -1045,6 +1072,9 @@ pub mod runtime_profile_played_world_snapshot_type; pub mod runtime_profile_recharge_center_get_input_type; pub mod runtime_profile_recharge_center_procedure_result_type; pub mod runtime_profile_recharge_center_snapshot_type; +pub mod runtime_profile_recharge_order_admin_entry_snapshot_type; +pub mod runtime_profile_recharge_order_admin_list_input_type; +pub mod runtime_profile_recharge_order_admin_list_procedure_result_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; @@ -1058,6 +1088,7 @@ pub mod runtime_profile_recharge_order_expiration_complete_procedure_result_type pub mod runtime_profile_recharge_order_expiration_schedule_snapshot_type; pub mod runtime_profile_recharge_order_get_input_type; pub mod runtime_profile_recharge_order_paid_input_type; +pub mod runtime_profile_recharge_order_refund_settlement_snapshot_type; pub mod runtime_profile_recharge_order_snapshot_type; pub mod runtime_profile_recharge_order_status_type; pub mod runtime_profile_recharge_product_admin_list_input_type; @@ -1067,6 +1098,27 @@ pub mod runtime_profile_recharge_product_admin_upsert_input_type; pub mod runtime_profile_recharge_product_config_snapshot_type; pub mod runtime_profile_recharge_product_kind_type; pub mod runtime_profile_recharge_product_snapshot_type; +pub mod runtime_profile_recharge_refund_bill_checkpoint_advance_input_type; +pub mod runtime_profile_recharge_refund_bill_checkpoint_get_input_type; +pub mod runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type; +pub mod runtime_profile_recharge_refund_bill_checkpoint_snapshot_type; +pub mod runtime_profile_recharge_refund_get_input_type; +pub mod runtime_profile_recharge_refund_hold_list_input_type; +pub mod runtime_profile_recharge_refund_hold_list_procedure_result_type; +pub mod runtime_profile_recharge_refund_hold_prepare_input_type; +pub mod runtime_profile_recharge_refund_hold_preview_input_type; +pub mod runtime_profile_recharge_refund_hold_procedure_result_type; +pub mod runtime_profile_recharge_refund_hold_release_input_type; +pub mod runtime_profile_recharge_refund_hold_snapshot_type; +pub mod runtime_profile_recharge_refund_hold_status_type; +pub mod runtime_profile_recharge_refund_list_procedure_result_type; +pub mod runtime_profile_recharge_refund_observation_input_type; +pub mod runtime_profile_recharge_refund_observation_source_type; +pub mod runtime_profile_recharge_refund_procedure_result_type; +pub mod runtime_profile_recharge_refund_reconciliation_list_input_type; +pub mod runtime_profile_recharge_refund_recovery_status_type; +pub mod runtime_profile_recharge_refund_snapshot_type; +pub mod runtime_profile_recharge_refund_status_type; pub mod runtime_profile_redeem_code_admin_disable_input_type; pub mod runtime_profile_redeem_code_admin_list_input_type; pub mod runtime_profile_redeem_code_admin_list_procedure_result_type; @@ -1106,6 +1158,8 @@ pub mod runtime_profile_wallet_ledger_entry_snapshot_type; pub mod runtime_profile_wallet_ledger_list_input_type; pub mod runtime_profile_wallet_ledger_procedure_result_type; pub mod runtime_profile_wallet_ledger_source_type_type; +pub mod runtime_profile_wallet_manual_restriction_snapshot_type; +pub mod runtime_profile_wallet_manual_restriction_upsert_input_type; pub mod runtime_referral_invite_center_get_input_type; pub mod runtime_referral_invite_center_procedure_result_type; pub mod runtime_referral_invite_center_snapshot_type; @@ -1360,10 +1414,12 @@ pub use admin_disable_profile_task_config_procedure::admin_disable_profile_task_ pub use admin_editor_asset_list_input_type::AdminEditorAssetListInput; pub use admin_editor_asset_list_procedure_result_type::AdminEditorAssetListProcedureResult; pub use admin_editor_asset_snapshot_type::AdminEditorAssetSnapshot; +pub use admin_get_profile_wallet_and_return_procedure::admin_get_profile_wallet_and_return; pub use admin_get_profile_wallet_config_procedure::admin_get_profile_wallet_config; pub use admin_list_editor_assets_and_return_procedure::admin_list_editor_assets_and_return; pub use admin_list_editor_showcase_assets_and_return_procedure::admin_list_editor_showcase_assets_and_return; pub use admin_list_profile_invite_codes_procedure::admin_list_profile_invite_codes; +pub use admin_list_profile_recharge_orders_and_return_procedure::admin_list_profile_recharge_orders_and_return; pub use admin_list_profile_recharge_products_procedure::admin_list_profile_recharge_products; pub use admin_list_profile_redeem_codes_procedure::admin_list_profile_redeem_codes; pub use admin_list_profile_task_configs_procedure::admin_list_profile_task_configs; @@ -1375,11 +1431,13 @@ pub use admin_upsert_profile_recharge_product_procedure::admin_upsert_profile_re pub use admin_upsert_profile_redeem_code_procedure::admin_upsert_profile_redeem_code; pub use admin_upsert_profile_task_config_procedure::admin_upsert_profile_task_config; pub use admin_upsert_profile_wallet_config_procedure::admin_upsert_profile_wallet_config; +pub use admin_upsert_profile_wallet_manual_restriction_and_return_procedure::admin_upsert_profile_wallet_manual_restriction_and_return; pub use admin_work_visibility_list_input_type::AdminWorkVisibilityListInput; pub use admin_work_visibility_list_procedure_result_type::AdminWorkVisibilityListProcedureResult; pub use admin_work_visibility_procedure_result_type::AdminWorkVisibilityProcedureResult; pub use admin_work_visibility_snapshot_type::AdminWorkVisibilitySnapshot; pub use admin_work_visibility_update_input_type::AdminWorkVisibilityUpdateInput; +pub use advance_profile_recharge_refund_bill_checkpoint_and_return_procedure::advance_profile_recharge_refund_bill_checkpoint_and_return; pub use advance_puzzle_clear_next_level_procedure::advance_puzzle_clear_next_level; pub use advance_puzzle_next_level_procedure::advance_puzzle_next_level; pub use ai_result_reference_input_type::AiResultReferenceInput; @@ -1900,6 +1958,8 @@ pub use get_profile_dashboard_procedure::get_profile_dashboard; pub use get_profile_play_stats_procedure::get_profile_play_stats; pub use get_profile_recharge_center_procedure::get_profile_recharge_center; pub use get_profile_recharge_order_and_return_procedure::get_profile_recharge_order_and_return; +pub use get_profile_recharge_refund_and_return_procedure::get_profile_recharge_refund_and_return; +pub use get_profile_recharge_refund_bill_checkpoint_and_return_procedure::get_profile_recharge_refund_bill_checkpoint_and_return; pub use get_profile_referral_invite_center_procedure::get_profile_referral_invite_center; pub use get_profile_task_center_procedure::get_profile_task_center; pub use get_puzzle_agent_session_procedure::get_puzzle_agent_session; @@ -2006,6 +2066,8 @@ pub use list_external_generation_jobs_and_return_procedure::list_external_genera pub use list_jump_hop_works_procedure::list_jump_hop_works; pub use list_match_3_d_works_procedure::list_match_3_d_works; pub use list_platform_browse_history_procedure::list_platform_browse_history; +pub use list_profile_recharge_refund_holds_for_reconciliation_procedure::list_profile_recharge_refund_holds_for_reconciliation; +pub use list_profile_recharge_refunds_for_reconciliation_procedure::list_profile_recharge_refunds_for_reconciliation; pub use list_profile_save_archives_procedure::list_profile_save_archives; pub use list_profile_wallet_ledger_procedure::list_profile_wallet_ledger; pub use list_public_editor_project_resources_and_return_procedure::list_public_editor_project_resources_and_return; @@ -2085,6 +2147,8 @@ pub use player_progression_procedure_result_type::PlayerProgressionProcedureResu pub use player_progression_snapshot_type::PlayerProgressionSnapshot; pub use player_progression_table::*; pub use player_progression_type::PlayerProgression; +pub use prepare_profile_recharge_refund_hold_and_return_procedure::prepare_profile_recharge_refund_hold_and_return; +pub use preview_profile_recharge_refund_hold_and_return_procedure::preview_profile_recharge_refund_hold_and_return; pub use profile_code_operation_table::*; pub use profile_code_operation_type::ProfileCodeOperation; pub use profile_daily_free_points_table::*; @@ -2103,10 +2167,20 @@ 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_refund_settlement_table::*; +pub use profile_recharge_order_refund_settlement_type::ProfileRechargeOrderRefundSettlement; pub use profile_recharge_order_table::*; pub use profile_recharge_order_type::ProfileRechargeOrder; pub use profile_recharge_product_config_table::*; pub use profile_recharge_product_config_type::ProfileRechargeProductConfig; +pub use profile_recharge_refund_bill_checkpoint_table::*; +pub use profile_recharge_refund_bill_checkpoint_type::ProfileRechargeRefundBillCheckpoint; +pub use profile_recharge_refund_hold_table::*; +pub use profile_recharge_refund_hold_type::ProfileRechargeRefundHold; +pub use profile_recharge_refund_observation_table::*; +pub use profile_recharge_refund_observation_type::ProfileRechargeRefundObservation; +pub use profile_recharge_refund_table::*; +pub use profile_recharge_refund_type::ProfileRechargeRefund; pub use profile_redeem_code_table::*; pub use profile_redeem_code_type::ProfileRedeemCode; pub use profile_redeem_code_usage_table::*; @@ -2125,6 +2199,8 @@ pub use profile_wallet_config_table::*; pub use profile_wallet_config_type::ProfileWalletConfig; pub use profile_wallet_ledger_table::*; pub use profile_wallet_ledger_type::ProfileWalletLedger; +pub use profile_wallet_manual_restriction_table::*; +pub use profile_wallet_manual_restriction_type::ProfileWalletManualRestriction; pub use public_work_asset_read_grant_table::*; pub use public_work_asset_read_grant_type::PublicWorkAssetReadGrant; pub use public_work_detail_entry_table::*; @@ -2303,6 +2379,7 @@ pub use record_big_fish_play_procedure::record_big_fish_play; pub use record_custom_world_profile_like_procedure::record_custom_world_profile_like; pub use record_custom_world_profile_play_procedure::record_custom_world_profile_play; pub use record_daily_login_tracking_event_and_return_procedure::record_daily_login_tracking_event_and_return; +pub use record_profile_recharge_refund_observation_and_return_procedure::record_profile_recharge_refund_observation_and_return; pub use record_puzzle_work_like_procedure::record_puzzle_work_like; pub use record_tracking_event_and_return_procedure::record_tracking_event_and_return; pub use record_tracking_events_and_return_procedure::record_tracking_events_and_return; @@ -2312,6 +2389,7 @@ pub use redeem_profile_reward_code_procedure::redeem_profile_reward_code; pub use refresh_session_table::*; pub use refresh_session_type::RefreshSession; pub use refund_profile_wallet_points_and_return_procedure::refund_profile_wallet_points_and_return; +pub use release_profile_recharge_refund_hold_and_return_procedure::release_profile_recharge_refund_hold_and_return; pub use release_puzzle_background_compile_task_procedure::release_puzzle_background_compile_task; pub use remix_big_fish_work_procedure::remix_big_fish_work; pub use remix_custom_world_profile_procedure::remix_custom_world_profile; @@ -2364,6 +2442,9 @@ pub use runtime_item_equipment_slot_type::RuntimeItemEquipmentSlot; pub use runtime_item_reward_item_rarity_type::RuntimeItemRewardItemRarity; pub use runtime_item_reward_item_snapshot_type::RuntimeItemRewardItemSnapshot; pub use runtime_platform_theme_type::RuntimePlatformTheme; +pub use runtime_profile_admin_wallet_get_input_type::RuntimeProfileAdminWalletGetInput; +pub use runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; +pub use runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; pub use runtime_profile_code_operation_snapshot_type::RuntimeProfileCodeOperationSnapshot; pub use runtime_profile_daily_free_points_snapshot_type::RuntimeProfileDailyFreePointsSnapshot; pub use runtime_profile_dashboard_get_input_type::RuntimeProfileDashboardGetInput; @@ -2390,6 +2471,9 @@ pub use runtime_profile_played_world_snapshot_type::RuntimeProfilePlayedWorldSna pub use runtime_profile_recharge_center_get_input_type::RuntimeProfileRechargeCenterGetInput; pub use runtime_profile_recharge_center_procedure_result_type::RuntimeProfileRechargeCenterProcedureResult; pub use runtime_profile_recharge_center_snapshot_type::RuntimeProfileRechargeCenterSnapshot; +pub use runtime_profile_recharge_order_admin_entry_snapshot_type::RuntimeProfileRechargeOrderAdminEntrySnapshot; +pub use runtime_profile_recharge_order_admin_list_input_type::RuntimeProfileRechargeOrderAdminListInput; +pub use runtime_profile_recharge_order_admin_list_procedure_result_type::RuntimeProfileRechargeOrderAdminListProcedureResult; 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; @@ -2403,6 +2487,7 @@ pub use runtime_profile_recharge_order_expiration_complete_procedure_result_type pub use runtime_profile_recharge_order_expiration_schedule_snapshot_type::RuntimeProfileRechargeOrderExpirationScheduleSnapshot; pub use runtime_profile_recharge_order_get_input_type::RuntimeProfileRechargeOrderGetInput; pub use runtime_profile_recharge_order_paid_input_type::RuntimeProfileRechargeOrderPaidInput; +pub use runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; pub use runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; pub use runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; pub use runtime_profile_recharge_product_admin_list_input_type::RuntimeProfileRechargeProductAdminListInput; @@ -2412,6 +2497,27 @@ pub use runtime_profile_recharge_product_admin_upsert_input_type::RuntimeProfile pub use runtime_profile_recharge_product_config_snapshot_type::RuntimeProfileRechargeProductConfigSnapshot; pub use runtime_profile_recharge_product_kind_type::RuntimeProfileRechargeProductKind; pub use runtime_profile_recharge_product_snapshot_type::RuntimeProfileRechargeProductSnapshot; +pub use runtime_profile_recharge_refund_bill_checkpoint_advance_input_type::RuntimeProfileRechargeRefundBillCheckpointAdvanceInput; +pub use runtime_profile_recharge_refund_bill_checkpoint_get_input_type::RuntimeProfileRechargeRefundBillCheckpointGetInput; +pub use runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type::RuntimeProfileRechargeRefundBillCheckpointProcedureResult; +pub use runtime_profile_recharge_refund_bill_checkpoint_snapshot_type::RuntimeProfileRechargeRefundBillCheckpointSnapshot; +pub use runtime_profile_recharge_refund_get_input_type::RuntimeProfileRechargeRefundGetInput; +pub use runtime_profile_recharge_refund_hold_list_input_type::RuntimeProfileRechargeRefundHoldListInput; +pub use runtime_profile_recharge_refund_hold_list_procedure_result_type::RuntimeProfileRechargeRefundHoldListProcedureResult; +pub use runtime_profile_recharge_refund_hold_prepare_input_type::RuntimeProfileRechargeRefundHoldPrepareInput; +pub use runtime_profile_recharge_refund_hold_preview_input_type::RuntimeProfileRechargeRefundHoldPreviewInput; +pub use runtime_profile_recharge_refund_hold_procedure_result_type::RuntimeProfileRechargeRefundHoldProcedureResult; +pub use runtime_profile_recharge_refund_hold_release_input_type::RuntimeProfileRechargeRefundHoldReleaseInput; +pub use runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; +pub use runtime_profile_recharge_refund_hold_status_type::RuntimeProfileRechargeRefundHoldStatus; +pub use runtime_profile_recharge_refund_list_procedure_result_type::RuntimeProfileRechargeRefundListProcedureResult; +pub use runtime_profile_recharge_refund_observation_input_type::RuntimeProfileRechargeRefundObservationInput; +pub use runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +pub use runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult; +pub use runtime_profile_recharge_refund_reconciliation_list_input_type::RuntimeProfileRechargeRefundReconciliationListInput; +pub use runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; +pub use runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; +pub use runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; pub use runtime_profile_redeem_code_admin_disable_input_type::RuntimeProfileRedeemCodeAdminDisableInput; pub use runtime_profile_redeem_code_admin_list_input_type::RuntimeProfileRedeemCodeAdminListInput; pub use runtime_profile_redeem_code_admin_list_procedure_result_type::RuntimeProfileRedeemCodeAdminListProcedureResult; @@ -2451,6 +2557,8 @@ pub use runtime_profile_wallet_ledger_entry_snapshot_type::RuntimeProfileWalletL pub use runtime_profile_wallet_ledger_list_input_type::RuntimeProfileWalletLedgerListInput; pub use runtime_profile_wallet_ledger_procedure_result_type::RuntimeProfileWalletLedgerProcedureResult; pub use runtime_profile_wallet_ledger_source_type_type::RuntimeProfileWalletLedgerSourceType; +pub use runtime_profile_wallet_manual_restriction_snapshot_type::RuntimeProfileWalletManualRestrictionSnapshot; +pub use runtime_profile_wallet_manual_restriction_upsert_input_type::RuntimeProfileWalletManualRestrictionUpsertInput; pub use runtime_referral_invite_center_get_input_type::RuntimeReferralInviteCenterGetInput; pub use runtime_referral_invite_center_procedure_result_type::RuntimeReferralInviteCenterProcedureResult; pub use runtime_referral_invite_center_snapshot_type::RuntimeReferralInviteCenterSnapshot; @@ -3062,7 +3170,14 @@ pub struct DbUpdate { __sdk::TableUpdate, profile_recharge_order_expiration_timer: __sdk::TableUpdate, + profile_recharge_order_refund_settlement: + __sdk::TableUpdate, profile_recharge_product_config: __sdk::TableUpdate, + profile_recharge_refund: __sdk::TableUpdate, + profile_recharge_refund_bill_checkpoint: + __sdk::TableUpdate, + profile_recharge_refund_hold: __sdk::TableUpdate, + profile_recharge_refund_observation: __sdk::TableUpdate, profile_redeem_code: __sdk::TableUpdate, profile_redeem_code_usage: __sdk::TableUpdate, profile_referral_relation: __sdk::TableUpdate, @@ -3072,6 +3187,7 @@ pub struct DbUpdate { profile_task_reward_claim: __sdk::TableUpdate, profile_wallet_config: __sdk::TableUpdate, profile_wallet_ledger: __sdk::TableUpdate, + profile_wallet_manual_restriction: __sdk::TableUpdate, public_work_asset_read_grant: __sdk::TableUpdate, public_work_detail_entry: __sdk::TableUpdate, public_work_gallery_entry: __sdk::TableUpdate, @@ -3398,11 +3514,38 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { )?, ) } + "profile_recharge_order_refund_settlement" => { + db_update.profile_recharge_order_refund_settlement.append( + profile_recharge_order_refund_settlement_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)?, ) } + "profile_recharge_refund" => db_update.profile_recharge_refund.append( + profile_recharge_refund_table::parse_table_update(table_update)?, + ), + "profile_recharge_refund_bill_checkpoint" => { + db_update.profile_recharge_refund_bill_checkpoint.append( + profile_recharge_refund_bill_checkpoint_table::parse_table_update( + table_update, + )?, + ) + } + "profile_recharge_refund_hold" => db_update.profile_recharge_refund_hold.append( + profile_recharge_refund_hold_table::parse_table_update(table_update)?, + ), + "profile_recharge_refund_observation" => { + db_update.profile_recharge_refund_observation.append( + profile_recharge_refund_observation_table::parse_table_update( + table_update, + )?, + ) + } "profile_redeem_code" => db_update .profile_redeem_code .append(profile_redeem_code_table::parse_table_update(table_update)?), @@ -3430,6 +3573,11 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "profile_wallet_ledger" => db_update.profile_wallet_ledger.append( profile_wallet_ledger_table::parse_table_update(table_update)?, ), + "profile_wallet_manual_restriction" => { + db_update.profile_wallet_manual_restriction.append( + profile_wallet_manual_restriction_table::parse_table_update(table_update)?, + ) + } "public_work_asset_read_grant" => db_update.public_work_asset_read_grant.append( public_work_asset_read_grant_table::parse_table_update(table_update)?, ), @@ -4008,12 +4156,42 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_recharge_order_expiration_timer, ) .with_updates_by_pk(|row| &row.scheduled_id); + diff.profile_recharge_order_refund_settlement = cache + .apply_diff_to_table::( + "profile_recharge_order_refund_settlement", + &self.profile_recharge_order_refund_settlement, + ) + .with_updates_by_pk(|row| &row.order_id); diff.profile_recharge_product_config = cache .apply_diff_to_table::( "profile_recharge_product_config", &self.profile_recharge_product_config, ) .with_updates_by_pk(|row| &row.product_id); + diff.profile_recharge_refund = cache + .apply_diff_to_table::( + "profile_recharge_refund", + &self.profile_recharge_refund, + ) + .with_updates_by_pk(|row| &row.out_refund_no); + diff.profile_recharge_refund_bill_checkpoint = cache + .apply_diff_to_table::( + "profile_recharge_refund_bill_checkpoint", + &self.profile_recharge_refund_bill_checkpoint, + ) + .with_updates_by_pk(|row| &row.checkpoint_id); + diff.profile_recharge_refund_hold = cache + .apply_diff_to_table::( + "profile_recharge_refund_hold", + &self.profile_recharge_refund_hold, + ) + .with_updates_by_pk(|row| &row.out_refund_no); + diff.profile_recharge_refund_observation = cache + .apply_diff_to_table::( + "profile_recharge_refund_observation", + &self.profile_recharge_refund_observation, + ) + .with_updates_by_pk(|row| &row.observation_id); diff.profile_redeem_code = cache .apply_diff_to_table::( "profile_redeem_code", @@ -4068,6 +4246,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.profile_wallet_ledger, ) .with_updates_by_pk(|row| &row.wallet_ledger_id); + diff.profile_wallet_manual_restriction = cache + .apply_diff_to_table::( + "profile_wallet_manual_restriction", + &self.profile_wallet_manual_restriction, + ) + .with_updates_by_pk(|row| &row.user_id); diff.public_work_like = cache .apply_diff_to_table::("public_work_like", &self.public_work_like) .with_updates_by_pk(|row| &row.like_id); @@ -4571,9 +4755,24 @@ impl __sdk::DbUpdate for DbUpdate { "profile_recharge_order_expiration_timer" => db_update .profile_recharge_order_expiration_timer .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_recharge_order_refund_settlement" => db_update + .profile_recharge_order_refund_settlement + .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)?), + "profile_recharge_refund" => db_update + .profile_recharge_refund + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_recharge_refund_bill_checkpoint" => db_update + .profile_recharge_refund_bill_checkpoint + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_recharge_refund_hold" => db_update + .profile_recharge_refund_hold + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_recharge_refund_observation" => db_update + .profile_recharge_refund_observation + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "profile_redeem_code" => db_update .profile_redeem_code .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4601,6 +4800,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "profile_wallet_manual_restriction" => db_update + .profile_wallet_manual_restriction + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "public_work_asset_read_grant" => db_update .public_work_asset_read_grant .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -5004,9 +5206,24 @@ impl __sdk::DbUpdate for DbUpdate { "profile_recharge_order_expiration_timer" => db_update .profile_recharge_order_expiration_timer .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_recharge_order_refund_settlement" => db_update + .profile_recharge_order_refund_settlement + .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)?), + "profile_recharge_refund" => db_update + .profile_recharge_refund + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_recharge_refund_bill_checkpoint" => db_update + .profile_recharge_refund_bill_checkpoint + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_recharge_refund_hold" => db_update + .profile_recharge_refund_hold + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_recharge_refund_observation" => db_update + .profile_recharge_refund_observation + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "profile_redeem_code" => db_update .profile_redeem_code .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5034,6 +5251,9 @@ impl __sdk::DbUpdate for DbUpdate { "profile_wallet_ledger" => db_update .profile_wallet_ledger .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "profile_wallet_manual_restriction" => db_update + .profile_wallet_manual_restriction + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "public_work_asset_read_grant" => db_update .public_work_asset_read_grant .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5284,7 +5504,15 @@ pub struct AppliedDiff<'r> { __sdk::TableAppliedDiff<'r, ProfileRechargeOrderExpirationSchedule>, profile_recharge_order_expiration_timer: __sdk::TableAppliedDiff<'r, ProfileRechargeOrderExpirationTimer>, + profile_recharge_order_refund_settlement: + __sdk::TableAppliedDiff<'r, ProfileRechargeOrderRefundSettlement>, profile_recharge_product_config: __sdk::TableAppliedDiff<'r, ProfileRechargeProductConfig>, + profile_recharge_refund: __sdk::TableAppliedDiff<'r, ProfileRechargeRefund>, + profile_recharge_refund_bill_checkpoint: + __sdk::TableAppliedDiff<'r, ProfileRechargeRefundBillCheckpoint>, + profile_recharge_refund_hold: __sdk::TableAppliedDiff<'r, ProfileRechargeRefundHold>, + profile_recharge_refund_observation: + __sdk::TableAppliedDiff<'r, ProfileRechargeRefundObservation>, profile_redeem_code: __sdk::TableAppliedDiff<'r, ProfileRedeemCode>, profile_redeem_code_usage: __sdk::TableAppliedDiff<'r, ProfileRedeemCodeUsage>, profile_referral_relation: __sdk::TableAppliedDiff<'r, ProfileReferralRelation>, @@ -5294,6 +5522,7 @@ pub struct AppliedDiff<'r> { profile_task_reward_claim: __sdk::TableAppliedDiff<'r, ProfileTaskRewardClaim>, profile_wallet_config: __sdk::TableAppliedDiff<'r, ProfileWalletConfig>, profile_wallet_ledger: __sdk::TableAppliedDiff<'r, ProfileWalletLedger>, + profile_wallet_manual_restriction: __sdk::TableAppliedDiff<'r, ProfileWalletManualRestriction>, public_work_asset_read_grant: __sdk::TableAppliedDiff<'r, PublicWorkAssetReadGrant>, public_work_detail_entry: __sdk::TableAppliedDiff<'r, PublicWorkDetailEntry>, public_work_gallery_entry: __sdk::TableAppliedDiff<'r, PublicWorkGalleryEntry>, @@ -5746,11 +5975,36 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_recharge_order_expiration_timer, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_order_refund_settlement", + &self.profile_recharge_order_refund_settlement, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_recharge_product_config", &self.profile_recharge_product_config, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_refund", + &self.profile_recharge_refund, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_refund_bill_checkpoint", + &self.profile_recharge_refund_bill_checkpoint, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_refund_hold", + &self.profile_recharge_refund_hold, + event, + ); + callbacks.invoke_table_row_callbacks::( + "profile_recharge_refund_observation", + &self.profile_recharge_refund_observation, + event, + ); callbacks.invoke_table_row_callbacks::( "profile_redeem_code", &self.profile_redeem_code, @@ -5796,6 +6050,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.profile_wallet_ledger, event, ); + callbacks.invoke_table_row_callbacks::( + "profile_wallet_manual_restriction", + &self.profile_wallet_manual_restriction, + event, + ); callbacks.invoke_table_row_callbacks::( "public_work_asset_read_grant", &self.public_work_asset_read_grant, @@ -6778,7 +7037,12 @@ impl __sdk::SpacetimeModule for RemoteModule { 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_order_refund_settlement_table::register_table(client_cache); profile_recharge_product_config_table::register_table(client_cache); + profile_recharge_refund_table::register_table(client_cache); + profile_recharge_refund_bill_checkpoint_table::register_table(client_cache); + profile_recharge_refund_hold_table::register_table(client_cache); + profile_recharge_refund_observation_table::register_table(client_cache); profile_redeem_code_table::register_table(client_cache); profile_redeem_code_usage_table::register_table(client_cache); profile_referral_relation_table::register_table(client_cache); @@ -6788,6 +7052,7 @@ impl __sdk::SpacetimeModule for RemoteModule { profile_task_reward_claim_table::register_table(client_cache); profile_wallet_config_table::register_table(client_cache); profile_wallet_ledger_table::register_table(client_cache); + profile_wallet_manual_restriction_table::register_table(client_cache); public_work_asset_read_grant_table::register_table(client_cache); public_work_detail_entry_table::register_table(client_cache); public_work_gallery_entry_table::register_table(client_cache); @@ -6920,7 +7185,12 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_recharge_order", "profile_recharge_order_expiration_schedule", "profile_recharge_order_expiration_timer", + "profile_recharge_order_refund_settlement", "profile_recharge_product_config", + "profile_recharge_refund", + "profile_recharge_refund_bill_checkpoint", + "profile_recharge_refund_hold", + "profile_recharge_refund_observation", "profile_redeem_code", "profile_redeem_code_usage", "profile_referral_relation", @@ -6930,6 +7200,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "profile_task_reward_claim", "profile_wallet_config", "profile_wallet_ledger", + "profile_wallet_manual_restriction", "public_work_asset_read_grant", "public_work_detail_entry", "public_work_gallery_entry", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs new file mode 100644 index 000000000..b482cdd15 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_get_profile_wallet_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_admin_wallet_get_input_type::RuntimeProfileAdminWalletGetInput; +use super::runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminGetProfileWalletAndReturnArgs { + pub input: RuntimeProfileAdminWalletGetInput, +} + +impl __sdk::InModule for AdminGetProfileWalletAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_get_profile_wallet_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_get_profile_wallet_and_return { + fn admin_get_profile_wallet_and_return(&self, input: RuntimeProfileAdminWalletGetInput) { + self.admin_get_profile_wallet_and_return_then(input, |_, _| {}); + } + + fn admin_get_profile_wallet_and_return_then( + &self, + input: RuntimeProfileAdminWalletGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_get_profile_wallet_and_return for super::RemoteProcedures { + fn admin_get_profile_wallet_and_return_then( + &self, + input: RuntimeProfileAdminWalletGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>( + "admin_get_profile_wallet_and_return", + AdminGetProfileWalletAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs new file mode 100644 index 000000000..d0c49b1a7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_list_profile_recharge_orders_and_return_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_order_admin_list_input_type::RuntimeProfileRechargeOrderAdminListInput; +use super::runtime_profile_recharge_order_admin_list_procedure_result_type::RuntimeProfileRechargeOrderAdminListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminListProfileRechargeOrdersAndReturnArgs { + pub input: RuntimeProfileRechargeOrderAdminListInput, +} + +impl __sdk::InModule for AdminListProfileRechargeOrdersAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_list_profile_recharge_orders_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_list_profile_recharge_orders_and_return { + fn admin_list_profile_recharge_orders_and_return( + &self, + input: RuntimeProfileRechargeOrderAdminListInput, + ) { + self.admin_list_profile_recharge_orders_and_return_then(input, |_, _| {}); + } + + fn admin_list_profile_recharge_orders_and_return_then( + &self, + input: RuntimeProfileRechargeOrderAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_list_profile_recharge_orders_and_return for super::RemoteProcedures { + fn admin_list_profile_recharge_orders_and_return_then( + &self, + input: RuntimeProfileRechargeOrderAdminListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeOrderAdminListProcedureResult>( + "admin_list_profile_recharge_orders_and_return", + AdminListProfileRechargeOrdersAndReturnArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs new file mode 100644 index 000000000..54786f87a --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/admin_upsert_profile_wallet_manual_restriction_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_admin_wallet_procedure_result_type::RuntimeProfileAdminWalletProcedureResult; +use super::runtime_profile_wallet_manual_restriction_upsert_input_type::RuntimeProfileWalletManualRestrictionUpsertInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdminUpsertProfileWalletManualRestrictionAndReturnArgs { + pub input: RuntimeProfileWalletManualRestrictionUpsertInput, +} + +impl __sdk::InModule for AdminUpsertProfileWalletManualRestrictionAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `admin_upsert_profile_wallet_manual_restriction_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait admin_upsert_profile_wallet_manual_restriction_and_return { + fn admin_upsert_profile_wallet_manual_restriction_and_return( + &self, + input: RuntimeProfileWalletManualRestrictionUpsertInput, + ) { + self.admin_upsert_profile_wallet_manual_restriction_and_return_then(input, |_, _| {}); + } + + fn admin_upsert_profile_wallet_manual_restriction_and_return_then( + &self, + input: RuntimeProfileWalletManualRestrictionUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl admin_upsert_profile_wallet_manual_restriction_and_return for super::RemoteProcedures { + fn admin_upsert_profile_wallet_manual_restriction_and_return_then( + &self, + input: RuntimeProfileWalletManualRestrictionUpsertInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileAdminWalletProcedureResult>( + "admin_upsert_profile_wallet_manual_restriction_and_return", + AdminUpsertProfileWalletManualRestrictionAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs new file mode 100644 index 000000000..064148be6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/advance_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs @@ -0,0 +1,67 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_bill_checkpoint_advance_input_type::RuntimeProfileRechargeRefundBillCheckpointAdvanceInput; +use super::runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type::RuntimeProfileRechargeRefundBillCheckpointProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct AdvanceProfileRechargeRefundBillCheckpointAndReturnArgs { + pub input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, +} + +impl __sdk::InModule for AdvanceProfileRechargeRefundBillCheckpointAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `advance_profile_recharge_refund_bill_checkpoint_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait advance_profile_recharge_refund_bill_checkpoint_and_return { + fn advance_profile_recharge_refund_bill_checkpoint_and_return( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, + ) { + self.advance_profile_recharge_refund_bill_checkpoint_and_return_then(input, |_, _| {}); + } + + fn advance_profile_recharge_refund_bill_checkpoint_and_return_then( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeRefundBillCheckpointProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, + ); +} + +impl advance_profile_recharge_refund_bill_checkpoint_and_return for super::RemoteProcedures { + fn advance_profile_recharge_refund_bill_checkpoint_and_return_then( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeRefundBillCheckpointProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundBillCheckpointProcedureResult>( + "advance_profile_recharge_refund_bill_checkpoint_and_return", + AdvanceProfileRechargeRefundBillCheckpointAndReturnArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs new file mode 100644 index 000000000..d1a584f1b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_get_input_type::RuntimeProfileRechargeRefundGetInput; +use super::runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileRechargeRefundAndReturnArgs { + pub input: RuntimeProfileRechargeRefundGetInput, +} + +impl __sdk::InModule for GetProfileRechargeRefundAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_recharge_refund_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_recharge_refund_and_return { + fn get_profile_recharge_refund_and_return(&self, input: RuntimeProfileRechargeRefundGetInput) { + self.get_profile_recharge_refund_and_return_then(input, |_, _| {}); + } + + fn get_profile_recharge_refund_and_return_then( + &self, + input: RuntimeProfileRechargeRefundGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_profile_recharge_refund_and_return for super::RemoteProcedures { + fn get_profile_recharge_refund_and_return_then( + &self, + input: RuntimeProfileRechargeRefundGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>( + "get_profile_recharge_refund_and_return", + GetProfileRechargeRefundAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs new file mode 100644 index 000000000..9cb29fc5b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_profile_recharge_refund_bill_checkpoint_and_return_procedure.rs @@ -0,0 +1,67 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_bill_checkpoint_get_input_type::RuntimeProfileRechargeRefundBillCheckpointGetInput; +use super::runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type::RuntimeProfileRechargeRefundBillCheckpointProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetProfileRechargeRefundBillCheckpointAndReturnArgs { + pub input: RuntimeProfileRechargeRefundBillCheckpointGetInput, +} + +impl __sdk::InModule for GetProfileRechargeRefundBillCheckpointAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_profile_recharge_refund_bill_checkpoint_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_profile_recharge_refund_bill_checkpoint_and_return { + fn get_profile_recharge_refund_bill_checkpoint_and_return( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointGetInput, + ) { + self.get_profile_recharge_refund_bill_checkpoint_and_return_then(input, |_, _| {}); + } + + fn get_profile_recharge_refund_bill_checkpoint_and_return_then( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeRefundBillCheckpointProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, + ); +} + +impl get_profile_recharge_refund_bill_checkpoint_and_return for super::RemoteProcedures { + fn get_profile_recharge_refund_bill_checkpoint_and_return_then( + &self, + input: RuntimeProfileRechargeRefundBillCheckpointGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result< + RuntimeProfileRechargeRefundBillCheckpointProcedureResult, + __sdk::InternalError, + >, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundBillCheckpointProcedureResult>( + "get_profile_recharge_refund_bill_checkpoint_and_return", + GetProfileRechargeRefundBillCheckpointAndReturnArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs new file mode 100644 index 000000000..6bcdf41c3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refund_holds_for_reconciliation_procedure.rs @@ -0,0 +1,61 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_list_input_type::RuntimeProfileRechargeRefundHoldListInput; +use super::runtime_profile_recharge_refund_hold_list_procedure_result_type::RuntimeProfileRechargeRefundHoldListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListProfileRechargeRefundHoldsForReconciliationArgs { + pub input: RuntimeProfileRechargeRefundHoldListInput, +} + +impl __sdk::InModule for ListProfileRechargeRefundHoldsForReconciliationArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_profile_recharge_refund_holds_for_reconciliation`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_profile_recharge_refund_holds_for_reconciliation { + fn list_profile_recharge_refund_holds_for_reconciliation( + &self, + input: RuntimeProfileRechargeRefundHoldListInput, + ) { + self.list_profile_recharge_refund_holds_for_reconciliation_then(input, |_, _| {}); + } + + fn list_profile_recharge_refund_holds_for_reconciliation_then( + &self, + input: RuntimeProfileRechargeRefundHoldListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_profile_recharge_refund_holds_for_reconciliation for super::RemoteProcedures { + fn list_profile_recharge_refund_holds_for_reconciliation_then( + &self, + input: RuntimeProfileRechargeRefundHoldListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldListProcedureResult>( + "list_profile_recharge_refund_holds_for_reconciliation", + ListProfileRechargeRefundHoldsForReconciliationArgs { input, }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs new file mode 100644 index 000000000..0a77320bf --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_profile_recharge_refunds_for_reconciliation_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_list_procedure_result_type::RuntimeProfileRechargeRefundListProcedureResult; +use super::runtime_profile_recharge_refund_reconciliation_list_input_type::RuntimeProfileRechargeRefundReconciliationListInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListProfileRechargeRefundsForReconciliationArgs { + pub input: RuntimeProfileRechargeRefundReconciliationListInput, +} + +impl __sdk::InModule for ListProfileRechargeRefundsForReconciliationArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_profile_recharge_refunds_for_reconciliation`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_profile_recharge_refunds_for_reconciliation { + fn list_profile_recharge_refunds_for_reconciliation( + &self, + input: RuntimeProfileRechargeRefundReconciliationListInput, + ) { + self.list_profile_recharge_refunds_for_reconciliation_then(input, |_, _| {}); + } + + fn list_profile_recharge_refunds_for_reconciliation_then( + &self, + input: RuntimeProfileRechargeRefundReconciliationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_profile_recharge_refunds_for_reconciliation for super::RemoteProcedures { + fn list_profile_recharge_refunds_for_reconciliation_then( + &self, + input: RuntimeProfileRechargeRefundReconciliationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundListProcedureResult>( + "list_profile_recharge_refunds_for_reconciliation", + ListProfileRechargeRefundsForReconciliationArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs new file mode 100644 index 000000000..e23c2e5c3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/prepare_profile_recharge_refund_hold_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_prepare_input_type::RuntimeProfileRechargeRefundHoldPrepareInput; +use super::runtime_profile_recharge_refund_hold_procedure_result_type::RuntimeProfileRechargeRefundHoldProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PrepareProfileRechargeRefundHoldAndReturnArgs { + pub input: RuntimeProfileRechargeRefundHoldPrepareInput, +} + +impl __sdk::InModule for PrepareProfileRechargeRefundHoldAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `prepare_profile_recharge_refund_hold_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait prepare_profile_recharge_refund_hold_and_return { + fn prepare_profile_recharge_refund_hold_and_return( + &self, + input: RuntimeProfileRechargeRefundHoldPrepareInput, + ) { + self.prepare_profile_recharge_refund_hold_and_return_then(input, |_, _| {}); + } + + fn prepare_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldPrepareInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl prepare_profile_recharge_refund_hold_and_return for super::RemoteProcedures { + fn prepare_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldPrepareInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( + "prepare_profile_recharge_refund_hold_and_return", + PrepareProfileRechargeRefundHoldAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs new file mode 100644 index 000000000..ac8898bb6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/preview_profile_recharge_refund_hold_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_preview_input_type::RuntimeProfileRechargeRefundHoldPreviewInput; +use super::runtime_profile_recharge_refund_hold_procedure_result_type::RuntimeProfileRechargeRefundHoldProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct PreviewProfileRechargeRefundHoldAndReturnArgs { + pub input: RuntimeProfileRechargeRefundHoldPreviewInput, +} + +impl __sdk::InModule for PreviewProfileRechargeRefundHoldAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `preview_profile_recharge_refund_hold_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait preview_profile_recharge_refund_hold_and_return { + fn preview_profile_recharge_refund_hold_and_return( + &self, + input: RuntimeProfileRechargeRefundHoldPreviewInput, + ) { + self.preview_profile_recharge_refund_hold_and_return_then(input, |_, _| {}); + } + + fn preview_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldPreviewInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl preview_profile_recharge_refund_hold_and_return for super::RemoteProcedures { + fn preview_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldPreviewInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( + "preview_profile_recharge_refund_hold_and_return", + PreviewProfileRechargeRefundHoldAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_table.rs new file mode 100644 index 000000000..01a538eb1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_table.rs @@ -0,0 +1,175 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_recharge_order_refund_settlement_type::ProfileRechargeOrderRefundSettlement; +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_order_refund_settlement`. +/// +/// Obtain a handle from the [`ProfileRechargeOrderRefundSettlementTableAccess::profile_recharge_order_refund_settlement`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_order_refund_settlement()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_order_refund_settlement().on_insert(...)`. +pub struct ProfileRechargeOrderRefundSettlementTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_order_refund_settlement`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeOrderRefundSettlementTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeOrderRefundSettlementTableHandle`], which mediates access to the table `profile_recharge_order_refund_settlement`. + fn profile_recharge_order_refund_settlement( + &self, + ) -> ProfileRechargeOrderRefundSettlementTableHandle<'_>; +} + +impl ProfileRechargeOrderRefundSettlementTableAccess for super::RemoteTables { + fn profile_recharge_order_refund_settlement( + &self, + ) -> ProfileRechargeOrderRefundSettlementTableHandle<'_> { + ProfileRechargeOrderRefundSettlementTableHandle { + imp: self.imp.get_table::( + "profile_recharge_order_refund_settlement", + ), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeOrderRefundSettlementInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeOrderRefundSettlementDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeOrderRefundSettlementTableHandle<'ctx> { + type Row = ProfileRechargeOrderRefundSettlement; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeOrderRefundSettlementInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeOrderRefundSettlementInsertCallbackId { + ProfileRechargeOrderRefundSettlementInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeOrderRefundSettlementInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeOrderRefundSettlementDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeOrderRefundSettlementDeleteCallbackId { + ProfileRechargeOrderRefundSettlementDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeOrderRefundSettlementDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeOrderRefundSettlementUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeOrderRefundSettlementTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeOrderRefundSettlementUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeOrderRefundSettlementUpdateCallbackId { + ProfileRechargeOrderRefundSettlementUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeOrderRefundSettlementUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `order_id` unique index on the table `profile_recharge_order_refund_settlement`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeOrderRefundSettlementOrderIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_order_refund_settlement().order_id().find(...)`. +pub struct ProfileRechargeOrderRefundSettlementOrderIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeOrderRefundSettlementTableHandle<'ctx> { + /// Get a handle on the `order_id` unique index on the table `profile_recharge_order_refund_settlement`. + pub fn order_id(&self) -> ProfileRechargeOrderRefundSettlementOrderIdUnique<'ctx> { + ProfileRechargeOrderRefundSettlementOrderIdUnique { + imp: self.imp.get_unique_constraint::("order_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeOrderRefundSettlementOrderIdUnique<'ctx> { + /// Find the subscribed row whose `order_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::( + "profile_recharge_order_refund_settlement", + ); + _table.add_unique_constraint::("order_id", |row| &row.order_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileRechargeOrderRefundSettlement`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_order_refund_settlementQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeOrderRefundSettlement`. + fn profile_recharge_order_refund_settlement( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_order_refund_settlementQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_order_refund_settlement( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_order_refund_settlement") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_type.rs new file mode 100644 index 000000000..0b50c9705 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_order_refund_settlement_type.rs @@ -0,0 +1,96 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeOrderRefundSettlement { + pub order_id: String, + pub user_id: String, + pub successful_refund_count: u32, + pub cumulative_success_refund_cents: u64, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub wallet_frozen: bool, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRechargeOrderRefundSettlement { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeOrderRefundSettlement`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeOrderRefundSettlementCols { + pub order_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub successful_refund_count: + __sdk::__query_builder::Col, + pub cumulative_success_refund_cents: + __sdk::__query_builder::Col, + pub target_recovery_points: + __sdk::__query_builder::Col, + pub recovered_points: __sdk::__query_builder::Col, + pub unrecovered_points: __sdk::__query_builder::Col, + pub recovery_status: __sdk::__query_builder::Col< + ProfileRechargeOrderRefundSettlement, + RuntimeProfileRechargeRefundRecoveryStatus, + >, + pub wallet_frozen: __sdk::__query_builder::Col, + pub updated_at: + __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeOrderRefundSettlement { + type Cols = ProfileRechargeOrderRefundSettlementCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeOrderRefundSettlementCols { + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + successful_refund_count: __sdk::__query_builder::Col::new( + table_name, + "successful_refund_count", + ), + cumulative_success_refund_cents: __sdk::__query_builder::Col::new( + table_name, + "cumulative_success_refund_cents", + ), + target_recovery_points: __sdk::__query_builder::Col::new( + table_name, + "target_recovery_points", + ), + recovered_points: __sdk::__query_builder::Col::new(table_name, "recovered_points"), + unrecovered_points: __sdk::__query_builder::Col::new(table_name, "unrecovered_points"), + recovery_status: __sdk::__query_builder::Col::new(table_name, "recovery_status"), + wallet_frozen: __sdk::__query_builder::Col::new(table_name, "wallet_frozen"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeOrderRefundSettlement`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeOrderRefundSettlementIxCols { + pub order_id: __sdk::__query_builder::IxCol, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeOrderRefundSettlement { + type IxCols = ProfileRechargeOrderRefundSettlementIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeOrderRefundSettlementIxCols { + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeOrderRefundSettlement {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_table.rs new file mode 100644 index 000000000..2baa9dfb7 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_table.rs @@ -0,0 +1,174 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_recharge_refund_bill_checkpoint_type::ProfileRechargeRefundBillCheckpoint; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_refund_bill_checkpoint`. +/// +/// Obtain a handle from the [`ProfileRechargeRefundBillCheckpointTableAccess::profile_recharge_refund_bill_checkpoint`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_refund_bill_checkpoint()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_bill_checkpoint().on_insert(...)`. +pub struct ProfileRechargeRefundBillCheckpointTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_refund_bill_checkpoint`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeRefundBillCheckpointTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeRefundBillCheckpointTableHandle`], which mediates access to the table `profile_recharge_refund_bill_checkpoint`. + fn profile_recharge_refund_bill_checkpoint( + &self, + ) -> ProfileRechargeRefundBillCheckpointTableHandle<'_>; +} + +impl ProfileRechargeRefundBillCheckpointTableAccess for super::RemoteTables { + fn profile_recharge_refund_bill_checkpoint( + &self, + ) -> ProfileRechargeRefundBillCheckpointTableHandle<'_> { + ProfileRechargeRefundBillCheckpointTableHandle { + imp: self.imp.get_table::( + "profile_recharge_refund_bill_checkpoint", + ), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeRefundBillCheckpointInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeRefundBillCheckpointDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeRefundBillCheckpointTableHandle<'ctx> { + type Row = ProfileRechargeRefundBillCheckpoint; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeRefundBillCheckpointInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundBillCheckpointInsertCallbackId { + ProfileRechargeRefundBillCheckpointInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeRefundBillCheckpointInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeRefundBillCheckpointDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundBillCheckpointDeleteCallbackId { + ProfileRechargeRefundBillCheckpointDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeRefundBillCheckpointDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeRefundBillCheckpointUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeRefundBillCheckpointTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeRefundBillCheckpointUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundBillCheckpointUpdateCallbackId { + ProfileRechargeRefundBillCheckpointUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeRefundBillCheckpointUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `checkpoint_id` unique index on the table `profile_recharge_refund_bill_checkpoint`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeRefundBillCheckpointCheckpointIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_bill_checkpoint().checkpoint_id().find(...)`. +pub struct ProfileRechargeRefundBillCheckpointCheckpointIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeRefundBillCheckpointTableHandle<'ctx> { + /// Get a handle on the `checkpoint_id` unique index on the table `profile_recharge_refund_bill_checkpoint`. + pub fn checkpoint_id(&self) -> ProfileRechargeRefundBillCheckpointCheckpointIdUnique<'ctx> { + ProfileRechargeRefundBillCheckpointCheckpointIdUnique { + imp: self.imp.get_unique_constraint::("checkpoint_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeRefundBillCheckpointCheckpointIdUnique<'ctx> { + /// Find the subscribed row whose `checkpoint_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::( + "profile_recharge_refund_bill_checkpoint", + ); + _table.add_unique_constraint::("checkpoint_id", |row| &row.checkpoint_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileRechargeRefundBillCheckpoint`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_refund_bill_checkpointQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeRefundBillCheckpoint`. + fn profile_recharge_refund_bill_checkpoint( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_refund_bill_checkpointQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_refund_bill_checkpoint( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_refund_bill_checkpoint") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_type.rs new file mode 100644 index 000000000..fff166920 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_bill_checkpoint_type.rs @@ -0,0 +1,70 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeRefundBillCheckpoint { + pub checkpoint_id: String, + pub bill_date: String, + pub bill_hash: String, + pub processed_refund_count: u32, + pub completed_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRechargeRefundBillCheckpoint { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeRefundBillCheckpoint`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeRefundBillCheckpointCols { + pub checkpoint_id: __sdk::__query_builder::Col, + pub bill_date: __sdk::__query_builder::Col, + pub bill_hash: __sdk::__query_builder::Col, + pub processed_refund_count: + __sdk::__query_builder::Col, + pub completed_at: + __sdk::__query_builder::Col, + pub updated_at: + __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeRefundBillCheckpoint { + type Cols = ProfileRechargeRefundBillCheckpointCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeRefundBillCheckpointCols { + checkpoint_id: __sdk::__query_builder::Col::new(table_name, "checkpoint_id"), + bill_date: __sdk::__query_builder::Col::new(table_name, "bill_date"), + bill_hash: __sdk::__query_builder::Col::new(table_name, "bill_hash"), + processed_refund_count: __sdk::__query_builder::Col::new( + table_name, + "processed_refund_count", + ), + completed_at: __sdk::__query_builder::Col::new(table_name, "completed_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeRefundBillCheckpoint`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeRefundBillCheckpointIxCols { + pub checkpoint_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeRefundBillCheckpoint { + type IxCols = ProfileRechargeRefundBillCheckpointIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeRefundBillCheckpointIxCols { + checkpoint_id: __sdk::__query_builder::IxCol::new(table_name, "checkpoint_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeRefundBillCheckpoint {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_table.rs new file mode 100644 index 000000000..5d417560d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_table.rs @@ -0,0 +1,167 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_recharge_refund_hold_type::ProfileRechargeRefundHold; +use super::runtime_profile_recharge_refund_hold_status_type::RuntimeProfileRechargeRefundHoldStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_refund_hold`. +/// +/// Obtain a handle from the [`ProfileRechargeRefundHoldTableAccess::profile_recharge_refund_hold`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_refund_hold()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_hold().on_insert(...)`. +pub struct ProfileRechargeRefundHoldTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_refund_hold`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeRefundHoldTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeRefundHoldTableHandle`], which mediates access to the table `profile_recharge_refund_hold`. + fn profile_recharge_refund_hold(&self) -> ProfileRechargeRefundHoldTableHandle<'_>; +} + +impl ProfileRechargeRefundHoldTableAccess for super::RemoteTables { + fn profile_recharge_refund_hold(&self) -> ProfileRechargeRefundHoldTableHandle<'_> { + ProfileRechargeRefundHoldTableHandle { + imp: self + .imp + .get_table::("profile_recharge_refund_hold"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeRefundHoldInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeRefundHoldDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeRefundHoldTableHandle<'ctx> { + type Row = ProfileRechargeRefundHold; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeRefundHoldInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundHoldInsertCallbackId { + ProfileRechargeRefundHoldInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeRefundHoldInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeRefundHoldDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundHoldDeleteCallbackId { + ProfileRechargeRefundHoldDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeRefundHoldDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeRefundHoldUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeRefundHoldTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeRefundHoldUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundHoldUpdateCallbackId { + ProfileRechargeRefundHoldUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeRefundHoldUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `out_refund_no` unique index on the table `profile_recharge_refund_hold`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeRefundHoldOutRefundNoUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_hold().out_refund_no().find(...)`. +pub struct ProfileRechargeRefundHoldOutRefundNoUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeRefundHoldTableHandle<'ctx> { + /// Get a handle on the `out_refund_no` unique index on the table `profile_recharge_refund_hold`. + pub fn out_refund_no(&self) -> ProfileRechargeRefundHoldOutRefundNoUnique<'ctx> { + ProfileRechargeRefundHoldOutRefundNoUnique { + imp: self.imp.get_unique_constraint::("out_refund_no"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeRefundHoldOutRefundNoUnique<'ctx> { + /// Find the subscribed row whose `out_refund_no` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("profile_recharge_refund_hold"); + _table.add_unique_constraint::("out_refund_no", |row| &row.out_refund_no); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileRechargeRefundHold`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_refund_holdQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeRefundHold`. + fn profile_recharge_refund_hold( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_refund_holdQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_refund_hold( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_refund_hold") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_type.rs new file mode 100644 index 000000000..537fd3810 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_hold_type.rs @@ -0,0 +1,108 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_status_type::RuntimeProfileRechargeRefundHoldStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeRefundHold { + pub out_refund_no: String, + pub order_id: String, + pub user_id: String, + pub refund_cents: u64, + pub held_points: u64, + pub status: RuntimeProfileRechargeRefundHoldStatus, + pub admin_user_id: String, + pub reason: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, + pub settled_at: Option<__sdk::Timestamp>, + pub released_at: Option<__sdk::Timestamp>, + pub released_by_admin_user_id: Option, + pub release_reason: Option, +} + +impl __sdk::InModule for ProfileRechargeRefundHold { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeRefundHold`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeRefundHoldCols { + pub out_refund_no: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub refund_cents: __sdk::__query_builder::Col, + pub held_points: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col< + ProfileRechargeRefundHold, + RuntimeProfileRechargeRefundHoldStatus, + >, + pub admin_user_id: __sdk::__query_builder::Col, + pub reason: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub settled_at: + __sdk::__query_builder::Col>, + pub released_at: + __sdk::__query_builder::Col>, + pub released_by_admin_user_id: + __sdk::__query_builder::Col>, + pub release_reason: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeRefundHold { + type Cols = ProfileRechargeRefundHoldCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeRefundHoldCols { + out_refund_no: __sdk::__query_builder::Col::new(table_name, "out_refund_no"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + refund_cents: __sdk::__query_builder::Col::new(table_name, "refund_cents"), + held_points: __sdk::__query_builder::Col::new(table_name, "held_points"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + admin_user_id: __sdk::__query_builder::Col::new(table_name, "admin_user_id"), + reason: __sdk::__query_builder::Col::new(table_name, "reason"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + settled_at: __sdk::__query_builder::Col::new(table_name, "settled_at"), + released_at: __sdk::__query_builder::Col::new(table_name, "released_at"), + released_by_admin_user_id: __sdk::__query_builder::Col::new( + table_name, + "released_by_admin_user_id", + ), + release_reason: __sdk::__query_builder::Col::new(table_name, "release_reason"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeRefundHold`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeRefundHoldIxCols { + pub order_id: __sdk::__query_builder::IxCol, + pub out_refund_no: __sdk::__query_builder::IxCol, + pub status: __sdk::__query_builder::IxCol< + ProfileRechargeRefundHold, + RuntimeProfileRechargeRefundHoldStatus, + >, + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeRefundHold { + type IxCols = ProfileRechargeRefundHoldIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeRefundHoldIxCols { + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + out_refund_no: __sdk::__query_builder::IxCol::new(table_name, "out_refund_no"), + status: __sdk::__query_builder::IxCol::new(table_name, "status"), + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeRefundHold {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_table.rs new file mode 100644 index 000000000..b9e32dd3e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_table.rs @@ -0,0 +1,176 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_recharge_refund_observation_type::ProfileRechargeRefundObservation; +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_refund_observation`. +/// +/// Obtain a handle from the [`ProfileRechargeRefundObservationTableAccess::profile_recharge_refund_observation`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_refund_observation()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_observation().on_insert(...)`. +pub struct ProfileRechargeRefundObservationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_refund_observation`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeRefundObservationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeRefundObservationTableHandle`], which mediates access to the table `profile_recharge_refund_observation`. + fn profile_recharge_refund_observation( + &self, + ) -> ProfileRechargeRefundObservationTableHandle<'_>; +} + +impl ProfileRechargeRefundObservationTableAccess for super::RemoteTables { + fn profile_recharge_refund_observation( + &self, + ) -> ProfileRechargeRefundObservationTableHandle<'_> { + ProfileRechargeRefundObservationTableHandle { + imp: self.imp.get_table::( + "profile_recharge_refund_observation", + ), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeRefundObservationInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeRefundObservationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeRefundObservationTableHandle<'ctx> { + type Row = ProfileRechargeRefundObservation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeRefundObservationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundObservationInsertCallbackId { + ProfileRechargeRefundObservationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeRefundObservationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeRefundObservationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundObservationDeleteCallbackId { + ProfileRechargeRefundObservationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeRefundObservationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeRefundObservationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeRefundObservationTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeRefundObservationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundObservationUpdateCallbackId { + ProfileRechargeRefundObservationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeRefundObservationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `observation_id` unique index on the table `profile_recharge_refund_observation`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeRefundObservationObservationIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund_observation().observation_id().find(...)`. +pub struct ProfileRechargeRefundObservationObservationIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeRefundObservationTableHandle<'ctx> { + /// Get a handle on the `observation_id` unique index on the table `profile_recharge_refund_observation`. + pub fn observation_id(&self) -> ProfileRechargeRefundObservationObservationIdUnique<'ctx> { + ProfileRechargeRefundObservationObservationIdUnique { + imp: self.imp.get_unique_constraint::("observation_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeRefundObservationObservationIdUnique<'ctx> { + /// Find the subscribed row whose `observation_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::( + "profile_recharge_refund_observation", + ); + _table.add_unique_constraint::("observation_id", |row| &row.observation_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileRechargeRefundObservation`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_refund_observationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeRefundObservation`. + fn profile_recharge_refund_observation( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_refund_observationQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_refund_observation( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_refund_observation") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_type.rs new file mode 100644 index 000000000..3a2cece85 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_observation_type.rs @@ -0,0 +1,113 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeRefundObservation { + pub observation_id: String, + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub source: RuntimeProfileRechargeRefundObservationSource, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at: Option<__sdk::Timestamp>, + pub notification_ref: Option, + pub payload_fingerprint: String, + pub resolution_code: String, + pub observed_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileRechargeRefundObservation { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeRefundObservation`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeRefundObservationCols { + pub observation_id: __sdk::__query_builder::Col, + pub out_refund_no: __sdk::__query_builder::Col, + pub provider_refund_id: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub provider_transaction_id: + __sdk::__query_builder::Col, + pub source: __sdk::__query_builder::Col< + ProfileRechargeRefundObservation, + RuntimeProfileRechargeRefundObservationSource, + >, + pub provider_status: __sdk::__query_builder::Col< + ProfileRechargeRefundObservation, + RuntimeProfileRechargeRefundStatus, + >, + pub total_cents: __sdk::__query_builder::Col, + pub refund_cents: __sdk::__query_builder::Col, + pub payer_total_cents: __sdk::__query_builder::Col, + pub payer_refund_cents: __sdk::__query_builder::Col, + pub success_at: + __sdk::__query_builder::Col>, + pub notification_ref: + __sdk::__query_builder::Col>, + pub payload_fingerprint: __sdk::__query_builder::Col, + pub resolution_code: __sdk::__query_builder::Col, + pub observed_at: + __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeRefundObservation { + type Cols = ProfileRechargeRefundObservationCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeRefundObservationCols { + observation_id: __sdk::__query_builder::Col::new(table_name, "observation_id"), + out_refund_no: __sdk::__query_builder::Col::new(table_name, "out_refund_no"), + provider_refund_id: __sdk::__query_builder::Col::new(table_name, "provider_refund_id"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + provider_transaction_id: __sdk::__query_builder::Col::new( + table_name, + "provider_transaction_id", + ), + source: __sdk::__query_builder::Col::new(table_name, "source"), + provider_status: __sdk::__query_builder::Col::new(table_name, "provider_status"), + total_cents: __sdk::__query_builder::Col::new(table_name, "total_cents"), + refund_cents: __sdk::__query_builder::Col::new(table_name, "refund_cents"), + payer_total_cents: __sdk::__query_builder::Col::new(table_name, "payer_total_cents"), + payer_refund_cents: __sdk::__query_builder::Col::new(table_name, "payer_refund_cents"), + success_at: __sdk::__query_builder::Col::new(table_name, "success_at"), + notification_ref: __sdk::__query_builder::Col::new(table_name, "notification_ref"), + payload_fingerprint: __sdk::__query_builder::Col::new( + table_name, + "payload_fingerprint", + ), + resolution_code: __sdk::__query_builder::Col::new(table_name, "resolution_code"), + observed_at: __sdk::__query_builder::Col::new(table_name, "observed_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeRefundObservation`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeRefundObservationIxCols { + pub observation_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeRefundObservation { + type IxCols = ProfileRechargeRefundObservationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeRefundObservationIxCols { + observation_id: __sdk::__query_builder::IxCol::new(table_name, "observation_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeRefundObservation {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_table.rs new file mode 100644 index 000000000..eb20b13af --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_table.rs @@ -0,0 +1,197 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_recharge_refund_type::ProfileRechargeRefund; +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_recharge_refund`. +/// +/// Obtain a handle from the [`ProfileRechargeRefundTableAccess::profile_recharge_refund`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_recharge_refund()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund().on_insert(...)`. +pub struct ProfileRechargeRefundTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_recharge_refund`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileRechargeRefundTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileRechargeRefundTableHandle`], which mediates access to the table `profile_recharge_refund`. + fn profile_recharge_refund(&self) -> ProfileRechargeRefundTableHandle<'_>; +} + +impl ProfileRechargeRefundTableAccess for super::RemoteTables { + fn profile_recharge_refund(&self) -> ProfileRechargeRefundTableHandle<'_> { + ProfileRechargeRefundTableHandle { + imp: self + .imp + .get_table::("profile_recharge_refund"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileRechargeRefundInsertCallbackId(__sdk::CallbackId); +pub struct ProfileRechargeRefundDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileRechargeRefundTableHandle<'ctx> { + type Row = ProfileRechargeRefund; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileRechargeRefundInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundInsertCallbackId { + ProfileRechargeRefundInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileRechargeRefundInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileRechargeRefundDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundDeleteCallbackId { + ProfileRechargeRefundDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileRechargeRefundDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileRechargeRefundUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileRechargeRefundTableHandle<'ctx> { + type UpdateCallbackId = ProfileRechargeRefundUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileRechargeRefundUpdateCallbackId { + ProfileRechargeRefundUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileRechargeRefundUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `out_refund_no` unique index on the table `profile_recharge_refund`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeRefundOutRefundNoUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund().out_refund_no().find(...)`. +pub struct ProfileRechargeRefundOutRefundNoUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeRefundTableHandle<'ctx> { + /// Get a handle on the `out_refund_no` unique index on the table `profile_recharge_refund`. + pub fn out_refund_no(&self) -> ProfileRechargeRefundOutRefundNoUnique<'ctx> { + ProfileRechargeRefundOutRefundNoUnique { + imp: self.imp.get_unique_constraint::("out_refund_no"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeRefundOutRefundNoUnique<'ctx> { + /// Find the subscribed row whose `out_refund_no` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +/// Access to the `provider_refund_id` unique index on the table `profile_recharge_refund`, +/// which allows point queries on the field of the same name +/// via the [`ProfileRechargeRefundProviderRefundIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_recharge_refund().provider_refund_id().find(...)`. +pub struct ProfileRechargeRefundProviderRefundIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileRechargeRefundTableHandle<'ctx> { + /// Get a handle on the `provider_refund_id` unique index on the table `profile_recharge_refund`. + pub fn provider_refund_id(&self) -> ProfileRechargeRefundProviderRefundIdUnique<'ctx> { + ProfileRechargeRefundProviderRefundIdUnique { + imp: self + .imp + .get_unique_constraint::("provider_refund_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileRechargeRefundProviderRefundIdUnique<'ctx> { + /// Find the subscribed row whose `provider_refund_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("profile_recharge_refund"); + _table.add_unique_constraint::("out_refund_no", |row| &row.out_refund_no); + _table.add_unique_constraint::("provider_refund_id", |row| &row.provider_refund_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileRechargeRefund`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_recharge_refundQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileRechargeRefund`. + fn profile_recharge_refund(&self) -> __sdk::__query_builder::Table; +} + +impl profile_recharge_refundQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_recharge_refund(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_recharge_refund") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs new file mode 100644 index 000000000..47ddcb858 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_recharge_refund_type.rs @@ -0,0 +1,146 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileRechargeRefund { + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub user_id: Option, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at: Option<__sdk::Timestamp>, + pub first_observed_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, + pub last_observation_source: RuntimeProfileRechargeRefundObservationSource, + pub last_observation_id: String, + pub order_settled_at: Option<__sdk::Timestamp>, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub last_recovery_ledger_id: Option, + pub last_error_code: Option, +} + +impl __sdk::InModule for ProfileRechargeRefund { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileRechargeRefund`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileRechargeRefundCols { + pub out_refund_no: __sdk::__query_builder::Col, + pub provider_refund_id: __sdk::__query_builder::Col, + pub order_id: __sdk::__query_builder::Col, + pub provider_transaction_id: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col>, + pub provider_status: + __sdk::__query_builder::Col, + pub total_cents: __sdk::__query_builder::Col, + pub refund_cents: __sdk::__query_builder::Col, + pub payer_total_cents: __sdk::__query_builder::Col, + pub payer_refund_cents: __sdk::__query_builder::Col, + pub success_at: __sdk::__query_builder::Col>, + pub first_observed_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, + pub last_observation_source: __sdk::__query_builder::Col< + ProfileRechargeRefund, + RuntimeProfileRechargeRefundObservationSource, + >, + pub last_observation_id: __sdk::__query_builder::Col, + pub order_settled_at: + __sdk::__query_builder::Col>, + pub target_recovery_points: __sdk::__query_builder::Col, + pub recovered_points: __sdk::__query_builder::Col, + pub unrecovered_points: __sdk::__query_builder::Col, + pub recovery_status: __sdk::__query_builder::Col< + ProfileRechargeRefund, + RuntimeProfileRechargeRefundRecoveryStatus, + >, + pub last_recovery_ledger_id: __sdk::__query_builder::Col>, + pub last_error_code: __sdk::__query_builder::Col>, +} + +impl __sdk::__query_builder::HasCols for ProfileRechargeRefund { + type Cols = ProfileRechargeRefundCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileRechargeRefundCols { + out_refund_no: __sdk::__query_builder::Col::new(table_name, "out_refund_no"), + provider_refund_id: __sdk::__query_builder::Col::new(table_name, "provider_refund_id"), + order_id: __sdk::__query_builder::Col::new(table_name, "order_id"), + provider_transaction_id: __sdk::__query_builder::Col::new( + table_name, + "provider_transaction_id", + ), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + provider_status: __sdk::__query_builder::Col::new(table_name, "provider_status"), + total_cents: __sdk::__query_builder::Col::new(table_name, "total_cents"), + refund_cents: __sdk::__query_builder::Col::new(table_name, "refund_cents"), + payer_total_cents: __sdk::__query_builder::Col::new(table_name, "payer_total_cents"), + payer_refund_cents: __sdk::__query_builder::Col::new(table_name, "payer_refund_cents"), + success_at: __sdk::__query_builder::Col::new(table_name, "success_at"), + first_observed_at: __sdk::__query_builder::Col::new(table_name, "first_observed_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + last_observation_source: __sdk::__query_builder::Col::new( + table_name, + "last_observation_source", + ), + last_observation_id: __sdk::__query_builder::Col::new( + table_name, + "last_observation_id", + ), + order_settled_at: __sdk::__query_builder::Col::new(table_name, "order_settled_at"), + target_recovery_points: __sdk::__query_builder::Col::new( + table_name, + "target_recovery_points", + ), + recovered_points: __sdk::__query_builder::Col::new(table_name, "recovered_points"), + unrecovered_points: __sdk::__query_builder::Col::new(table_name, "unrecovered_points"), + recovery_status: __sdk::__query_builder::Col::new(table_name, "recovery_status"), + last_recovery_ledger_id: __sdk::__query_builder::Col::new( + table_name, + "last_recovery_ledger_id", + ), + last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileRechargeRefund`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileRechargeRefundIxCols { + pub order_id: __sdk::__query_builder::IxCol, + pub out_refund_no: __sdk::__query_builder::IxCol, + pub provider_refund_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileRechargeRefund { + type IxCols = ProfileRechargeRefundIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileRechargeRefundIxCols { + order_id: __sdk::__query_builder::IxCol::new(table_name, "order_id"), + out_refund_no: __sdk::__query_builder::IxCol::new(table_name, "out_refund_no"), + provider_refund_id: __sdk::__query_builder::IxCol::new( + table_name, + "provider_refund_id", + ), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileRechargeRefund {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_table.rs new file mode 100644 index 000000000..1de6884d0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_table.rs @@ -0,0 +1,169 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::profile_wallet_manual_restriction_type::ProfileWalletManualRestriction; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `profile_wallet_manual_restriction`. +/// +/// Obtain a handle from the [`ProfileWalletManualRestrictionTableAccess::profile_wallet_manual_restriction`] method on [`super::RemoteTables`], +/// like `ctx.db.profile_wallet_manual_restriction()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_manual_restriction().on_insert(...)`. +pub struct ProfileWalletManualRestrictionTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `profile_wallet_manual_restriction`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait ProfileWalletManualRestrictionTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`ProfileWalletManualRestrictionTableHandle`], which mediates access to the table `profile_wallet_manual_restriction`. + fn profile_wallet_manual_restriction(&self) -> ProfileWalletManualRestrictionTableHandle<'_>; +} + +impl ProfileWalletManualRestrictionTableAccess for super::RemoteTables { + fn profile_wallet_manual_restriction(&self) -> ProfileWalletManualRestrictionTableHandle<'_> { + ProfileWalletManualRestrictionTableHandle { + imp: self + .imp + .get_table::("profile_wallet_manual_restriction"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct ProfileWalletManualRestrictionInsertCallbackId(__sdk::CallbackId); +pub struct ProfileWalletManualRestrictionDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for ProfileWalletManualRestrictionTableHandle<'ctx> { + type Row = ProfileWalletManualRestriction; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = ProfileWalletManualRestrictionInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletManualRestrictionInsertCallbackId { + ProfileWalletManualRestrictionInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: ProfileWalletManualRestrictionInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = ProfileWalletManualRestrictionDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> ProfileWalletManualRestrictionDeleteCallbackId { + ProfileWalletManualRestrictionDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: ProfileWalletManualRestrictionDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct ProfileWalletManualRestrictionUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for ProfileWalletManualRestrictionTableHandle<'ctx> { + type UpdateCallbackId = ProfileWalletManualRestrictionUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> ProfileWalletManualRestrictionUpdateCallbackId { + ProfileWalletManualRestrictionUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: ProfileWalletManualRestrictionUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `user_id` unique index on the table `profile_wallet_manual_restriction`, +/// which allows point queries on the field of the same name +/// via the [`ProfileWalletManualRestrictionUserIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.profile_wallet_manual_restriction().user_id().find(...)`. +pub struct ProfileWalletManualRestrictionUserIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> ProfileWalletManualRestrictionTableHandle<'ctx> { + /// Get a handle on the `user_id` unique index on the table `profile_wallet_manual_restriction`. + pub fn user_id(&self) -> ProfileWalletManualRestrictionUserIdUnique<'ctx> { + ProfileWalletManualRestrictionUserIdUnique { + imp: self.imp.get_unique_constraint::("user_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> ProfileWalletManualRestrictionUserIdUnique<'ctx> { + /// Find the subscribed row whose `user_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("profile_wallet_manual_restriction"); + _table.add_unique_constraint::("user_id", |row| &row.user_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `ProfileWalletManualRestriction`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait profile_wallet_manual_restrictionQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `ProfileWalletManualRestriction`. + fn profile_wallet_manual_restriction( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl profile_wallet_manual_restrictionQueryTableAccess for __sdk::QueryTableAccessor { + fn profile_wallet_manual_restriction( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("profile_wallet_manual_restriction") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_type.rs new file mode 100644 index 000000000..fc49cc42d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_wallet_manual_restriction_type.rs @@ -0,0 +1,75 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ProfileWalletManualRestriction { + pub user_id: String, + pub frozen: bool, + pub reason: String, + pub created_by_admin_user_id: String, + pub created_at: __sdk::Timestamp, + pub updated_by_admin_user_id: String, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for ProfileWalletManualRestriction { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `ProfileWalletManualRestriction`. +/// +/// Provides typed access to columns for query building. +pub struct ProfileWalletManualRestrictionCols { + pub user_id: __sdk::__query_builder::Col, + pub frozen: __sdk::__query_builder::Col, + pub reason: __sdk::__query_builder::Col, + pub created_by_admin_user_id: + __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_by_admin_user_id: + __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for ProfileWalletManualRestriction { + type Cols = ProfileWalletManualRestrictionCols; + fn cols(table_name: &'static str) -> Self::Cols { + ProfileWalletManualRestrictionCols { + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + frozen: __sdk::__query_builder::Col::new(table_name, "frozen"), + reason: __sdk::__query_builder::Col::new(table_name, "reason"), + created_by_admin_user_id: __sdk::__query_builder::Col::new( + table_name, + "created_by_admin_user_id", + ), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_by_admin_user_id: __sdk::__query_builder::Col::new( + table_name, + "updated_by_admin_user_id", + ), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `ProfileWalletManualRestriction`. +/// +/// Provides typed access to indexed columns for query building. +pub struct ProfileWalletManualRestrictionIxCols { + pub user_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for ProfileWalletManualRestriction { + type IxCols = ProfileWalletManualRestrictionIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + ProfileWalletManualRestrictionIxCols { + user_id: __sdk::__query_builder::IxCol::new(table_name, "user_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for ProfileWalletManualRestriction {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs new file mode 100644 index 000000000..2ee1c67a6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_profile_recharge_refund_observation_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_observation_input_type::RuntimeProfileRechargeRefundObservationInput; +use super::runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RecordProfileRechargeRefundObservationAndReturnArgs { + pub input: RuntimeProfileRechargeRefundObservationInput, +} + +impl __sdk::InModule for RecordProfileRechargeRefundObservationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `record_profile_recharge_refund_observation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait record_profile_recharge_refund_observation_and_return { + fn record_profile_recharge_refund_observation_and_return( + &self, + input: RuntimeProfileRechargeRefundObservationInput, + ) { + self.record_profile_recharge_refund_observation_and_return_then(input, |_, _| {}); + } + + fn record_profile_recharge_refund_observation_and_return_then( + &self, + input: RuntimeProfileRechargeRefundObservationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl record_profile_recharge_refund_observation_and_return for super::RemoteProcedures { + fn record_profile_recharge_refund_observation_and_return_then( + &self, + input: RuntimeProfileRechargeRefundObservationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>( + "record_profile_recharge_refund_observation_and_return", + RecordProfileRechargeRefundObservationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs new file mode 100644 index 000000000..8697385ea --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/release_profile_recharge_refund_hold_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_procedure_result_type::RuntimeProfileRechargeRefundHoldProcedureResult; +use super::runtime_profile_recharge_refund_hold_release_input_type::RuntimeProfileRechargeRefundHoldReleaseInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ReleaseProfileRechargeRefundHoldAndReturnArgs { + pub input: RuntimeProfileRechargeRefundHoldReleaseInput, +} + +impl __sdk::InModule for ReleaseProfileRechargeRefundHoldAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `release_profile_recharge_refund_hold_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait release_profile_recharge_refund_hold_and_return { + fn release_profile_recharge_refund_hold_and_return( + &self, + input: RuntimeProfileRechargeRefundHoldReleaseInput, + ) { + self.release_profile_recharge_refund_hold_and_return_then(input, |_, _| {}); + } + + fn release_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl release_profile_recharge_refund_hold_and_return for super::RemoteProcedures { + fn release_profile_recharge_refund_hold_and_return_then( + &self, + input: RuntimeProfileRechargeRefundHoldReleaseInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundHoldProcedureResult>( + "release_profile_recharge_refund_hold_and_return", + ReleaseProfileRechargeRefundHoldAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_get_input_type.rs new file mode 100644 index 000000000..a98fb2560 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAdminWalletGetInput { + pub user_id: String, +} + +impl __sdk::InModule for RuntimeProfileAdminWalletGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_procedure_result_type.rs new file mode 100644 index 000000000..ec9ca7e29 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAdminWalletProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileAdminWalletProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_snapshot_type.rs new file mode 100644 index 000000000..5fa550841 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_admin_wallet_snapshot_type.rs @@ -0,0 +1,28 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_wallet_manual_restriction_snapshot_type::RuntimeProfileWalletManualRestrictionSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileAdminWalletSnapshot { + pub user_id: String, + pub total_balance: u64, + pub spendable_balance: u64, + pub daily_free_points: u64, + pub membership_limited_points: u64, + pub permanent_points: u64, + pub held_points: u64, + pub refund_debt_points: u64, + pub manual_frozen: bool, + pub refund_debt_frozen: bool, + pub wallet_frozen: bool, + pub manual_restriction: Option, +} + +impl __sdk::InModule for RuntimeProfileAdminWalletSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs new file mode 100644 index 000000000..dcc036214 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_entry_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +use super::runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; +use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; +use super::runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; +use super::runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot { + pub order: RuntimeProfileRechargeOrderSnapshot, + pub settlement: Option, + pub refunds: Vec, + pub active_hold: Option, + pub wallet: RuntimeProfileAdminWalletSnapshot, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderAdminEntrySnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_input_type.rs new file mode 100644 index 000000000..4f2a05731 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_input_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_order_status_type::RuntimeProfileRechargeOrderStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeOrderAdminListInput { + pub order_id: Option, + pub user_id: Option, + pub provider_transaction_id: Option, + pub payment_channel: Option, + pub status: Option, + pub created_after_micros: Option, + pub created_before_micros: Option, + pub limit: u32, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderAdminListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_procedure_result_type.rs new file mode 100644 index 000000000..382d841c6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_admin_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_order_admin_entry_snapshot_type::RuntimeProfileRechargeOrderAdminEntrySnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeOrderAdminListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderAdminListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_refund_settlement_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_refund_settlement_snapshot_type.rs new file mode 100644 index 000000000..20456c72d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_order_refund_settlement_snapshot_type.rs @@ -0,0 +1,26 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeOrderRefundSettlementSnapshot { + pub order_id: String, + pub user_id: String, + pub successful_refund_count: u32, + pub cumulative_success_refund_cents: u64, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub wallet_frozen: bool, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeOrderRefundSettlementSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_advance_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_advance_input_type.rs new file mode 100644 index 000000000..d50136ca6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_advance_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundBillCheckpointAdvanceInput { + pub checkpoint_id: String, + pub bill_date: String, + pub bill_hash: String, + pub processed_refund_count: u32, + pub completed_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundBillCheckpointAdvanceInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_get_input_type.rs new file mode 100644 index 000000000..03a9d65e9 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundBillCheckpointGetInput { + pub checkpoint_id: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundBillCheckpointGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type.rs new file mode 100644 index 000000000..5cdc77585 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_bill_checkpoint_snapshot_type::RuntimeProfileRechargeRefundBillCheckpointSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + pub ok: bool, + pub record: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_snapshot_type.rs new file mode 100644 index 000000000..3fdb9670d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_bill_checkpoint_snapshot_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundBillCheckpointSnapshot { + pub checkpoint_id: String, + pub bill_date: String, + pub bill_hash: String, + pub processed_refund_count: u32, + pub completed_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundBillCheckpointSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_get_input_type.rs new file mode 100644 index 000000000..cd62e2906 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_get_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundGetInput { + pub out_refund_no: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_input_type.rs new file mode 100644 index 000000000..b1161cce1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldListInput { + pub limit: u32, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_procedure_result_type.rs new file mode 100644 index 000000000..94386d62e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_prepare_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_prepare_input_type.rs new file mode 100644 index 000000000..1c3f5634e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_prepare_input_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldPrepareInput { + pub order_id: String, + pub out_refund_no: String, + pub refund_cents: u64, + pub admin_user_id: String, + pub reason: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldPrepareInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_preview_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_preview_input_type.rs new file mode 100644 index 000000000..a6ed386f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_preview_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldPreviewInput { + pub order_id: String, + pub refund_cents: u64, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldPreviewInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_procedure_result_type.rs new file mode 100644 index 000000000..f49581c66 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_procedure_result_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_admin_wallet_snapshot_type::RuntimeProfileAdminWalletSnapshot; +use super::runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; +use super::runtime_profile_recharge_order_snapshot_type::RuntimeProfileRechargeOrderSnapshot; +use super::runtime_profile_recharge_refund_hold_snapshot_type::RuntimeProfileRechargeRefundHoldSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldProcedureResult { + pub ok: bool, + pub record: Option, + pub order: Option, + pub settlement: Option, + pub wallet: Option, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_release_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_release_input_type.rs new file mode 100644 index 000000000..6b694f9f4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_release_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldReleaseInput { + pub out_refund_no: String, + pub admin_user_id: String, + pub release_reason: String, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldReleaseInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_snapshot_type.rs new file mode 100644 index 000000000..0e045057d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_snapshot_type.rs @@ -0,0 +1,30 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_hold_status_type::RuntimeProfileRechargeRefundHoldStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundHoldSnapshot { + pub out_refund_no: String, + pub order_id: String, + pub user_id: String, + pub refund_cents: u64, + pub held_points: u64, + pub status: RuntimeProfileRechargeRefundHoldStatus, + pub admin_user_id: String, + pub reason: String, + pub created_at_micros: i64, + pub updated_at_micros: i64, + pub settled_at_micros: Option, + pub released_at_micros: Option, + pub released_by_admin_user_id: Option, + pub release_reason: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_status_type.rs new file mode 100644 index 000000000..a40c16867 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_hold_status_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRechargeRefundHoldStatus { + Active, + + Settled, + + Released, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundHoldStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_list_procedure_result_type.rs new file mode 100644 index 000000000..b1b55aef3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundListProcedureResult { + pub ok: bool, + pub entries: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_input_type.rs new file mode 100644 index 000000000..8760484fd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_input_type.rs @@ -0,0 +1,32 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundObservationInput { + pub observation_id: String, + pub source: RuntimeProfileRechargeRefundObservationSource, + pub notification_ref: Option, + pub payload_fingerprint: String, + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub observed_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundObservationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_source_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_source_type.rs new file mode 100644 index 000000000..b7680c63e --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_observation_source_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRechargeRefundObservationSource { + ApiRequest, + + Callback, + + Query, + + TradeBill, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundObservationSource { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_procedure_result_type.rs new file mode 100644 index 000000000..7c91f96f5 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_procedure_result_type.rs @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_order_refund_settlement_snapshot_type::RuntimeProfileRechargeOrderRefundSettlementSnapshot; +use super::runtime_profile_recharge_refund_snapshot_type::RuntimeProfileRechargeRefundSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundProcedureResult { + pub ok: bool, + pub record: Option, + pub settlement: Option, + pub duplicate: bool, + pub resolution_code: String, + pub error_message: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_reconciliation_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_reconciliation_list_input_type.rs new file mode 100644 index 000000000..535ebe61d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_reconciliation_list_input_type.rs @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundReconciliationListInput { + pub limit: u32, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundReconciliationListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_recovery_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_recovery_status_type.rs new file mode 100644 index 000000000..1b4623bca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_recovery_status_type.rs @@ -0,0 +1,24 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRechargeRefundRecoveryStatus { + Pending, + + Applied, + + Shortfall, + + ManualReview, + + NotApplicable, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundRecoveryStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_snapshot_type.rs new file mode 100644 index 000000000..a3de129e3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_snapshot_type.rs @@ -0,0 +1,40 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::runtime_profile_recharge_refund_observation_source_type::RuntimeProfileRechargeRefundObservationSource; +use super::runtime_profile_recharge_refund_recovery_status_type::RuntimeProfileRechargeRefundRecoveryStatus; +use super::runtime_profile_recharge_refund_status_type::RuntimeProfileRechargeRefundStatus; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileRechargeRefundSnapshot { + pub out_refund_no: String, + pub provider_refund_id: String, + pub order_id: String, + pub provider_transaction_id: String, + pub user_id: Option, + pub provider_status: RuntimeProfileRechargeRefundStatus, + pub total_cents: u64, + pub refund_cents: u64, + pub payer_total_cents: u64, + pub payer_refund_cents: u64, + pub success_at_micros: Option, + pub first_observed_at_micros: i64, + pub updated_at_micros: i64, + pub last_observation_source: RuntimeProfileRechargeRefundObservationSource, + pub last_observation_id: String, + pub order_settled_at_micros: Option, + pub target_recovery_points: u64, + pub recovered_points: u64, + pub unrecovered_points: u64, + pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub last_recovery_ledger_id: Option, + pub last_error_code: Option, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_status_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_status_type.rs new file mode 100644 index 000000000..0321fc5ff --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_status_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +#[derive(Copy, Eq, Hash)] +pub enum RuntimeProfileRechargeRefundStatus { + Processing, + + Success, + + Abnormal, + + Closed, +} + +impl __sdk::InModule for RuntimeProfileRechargeRefundStatus { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs index fa1040d4c..d2ccdcf38 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_ledger_source_type_type.rs @@ -35,6 +35,8 @@ pub enum RuntimeProfileWalletLedgerSourceType { DailyFreeGrant, DailyFreeReset, + + RechargeRefundRecovery, } impl __sdk::InModule for RuntimeProfileWalletLedgerSourceType { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_snapshot_type.rs new file mode 100644 index 000000000..2ac14d763 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletManualRestrictionSnapshot { + pub user_id: String, + pub frozen: bool, + pub reason: String, + pub created_by_admin_user_id: String, + pub created_at_micros: i64, + pub updated_by_admin_user_id: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for RuntimeProfileWalletManualRestrictionSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_upsert_input_type.rs new file mode 100644 index 000000000..b66a66ab8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_wallet_manual_restriction_upsert_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct RuntimeProfileWalletManualRestrictionUpsertInput { + pub user_id: String, + pub frozen: bool, + pub reason: String, + pub admin_user_id: String, +} + +impl __sdk::InModule for RuntimeProfileWalletManualRestrictionUpsertInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/runtime.rs b/server-rs/crates/spacetime-client/src/runtime.rs index 561bc8f31..3ef95a401 100644 --- a/server-rs/crates/spacetime-client/src/runtime.rs +++ b/server-rs/crates/spacetime-client/src/runtime.rs @@ -633,6 +633,381 @@ impl SpacetimeClient { .await } + pub async fn record_profile_recharge_refund_observation( + &self, + input: module_runtime::RuntimeProfileRechargeRefundObservationInput, + ) -> Result< + ( + module_runtime::RuntimeProfileRechargeRefundSnapshot, + Option, + bool, + String, + ), + SpacetimeClientError, + > { + let procedure_input = + module_runtime::build_runtime_profile_recharge_refund_observation_input( + input.observation_id, + input.source, + input.notification_ref, + input.payload_fingerprint, + input.out_refund_no, + input.provider_refund_id, + input.order_id, + input.provider_transaction_id, + input.provider_status, + input.total_cents, + input.refund_cents, + input.payer_total_cents, + input.payer_refund_cents, + input.success_at_micros, + input.observed_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "record_profile_recharge_refund_observation_and_return", + move |connection, sender| { + connection + .procedures() + .record_profile_recharge_refund_observation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_refund_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_profile_recharge_refund( + &self, + out_refund_no: String, + ) -> Result< + ( + module_runtime::RuntimeProfileRechargeRefundSnapshot, + Option, + bool, + String, + ), + SpacetimeClientError, + > { + let procedure_input = + module_runtime::build_runtime_profile_recharge_refund_get_input(out_refund_no) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + + self.call_after_connect( + "get_profile_recharge_refund_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_recharge_refund_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_recharge_refund_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_profile_recharge_refunds_for_reconciliation( + &self, + limit: u32, + ) -> Result, SpacetimeClientError> + { + let procedure_input = + module_runtime::build_runtime_profile_recharge_refund_reconciliation_list_input(limit) + .into(); + self.call_after_connect( + "list_profile_recharge_refunds_for_reconciliation", + move |connection, sender| { + connection + .procedures() + .list_profile_recharge_refunds_for_reconciliation_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_list_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_profile_recharge_refund_bill_checkpoint( + &self, + checkpoint_id: String, + ) -> Result< + Option, + SpacetimeClientError, + > { + let procedure_input = + module_runtime::build_runtime_profile_recharge_refund_bill_checkpoint_get_input( + checkpoint_id, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + self.call_after_connect( + "get_profile_recharge_refund_bill_checkpoint_and_return", + move |connection, sender| { + connection + .procedures() + .get_profile_recharge_refund_bill_checkpoint_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_bill_checkpoint_optional_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn advance_profile_recharge_refund_bill_checkpoint( + &self, + checkpoint_id: String, + bill_date: String, + bill_hash: String, + processed_refund_count: u32, + completed_at_micros: i64, + ) -> Result< + module_runtime::RuntimeProfileRechargeRefundBillCheckpointSnapshot, + SpacetimeClientError, + > { + let procedure_input = + module_runtime::build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( + checkpoint_id, + bill_date, + bill_hash, + processed_refund_count, + completed_at_micros, + ) + .map_err(SpacetimeClientError::validation_failed)? + .into(); + self.call_after_connect( + "advance_profile_recharge_refund_bill_checkpoint_and_return", + move |connection, sender| { + connection + .procedures() + .advance_profile_recharge_refund_bill_checkpoint_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_bill_checkpoint_required_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn admin_list_profile_recharge_orders( + &self, + input: module_runtime::RuntimeProfileRechargeOrderAdminListInput, + ) -> Result< + Vec, + SpacetimeClientError, + > { + let procedure_input: RuntimeProfileRechargeOrderAdminListInput = input.into(); + self.call_after_connect( + "admin_list_profile_recharge_orders_and_return", + move |connection, sender| { + connection + .procedures() + .admin_list_profile_recharge_orders_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_order_admin_list_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn preview_profile_recharge_refund_hold( + &self, + input: module_runtime::RuntimeProfileRechargeRefundHoldPreviewInput, + ) -> Result + { + let procedure_input: RuntimeProfileRechargeRefundHoldPreviewInput = input.into(); + self.call_after_connect( + "preview_profile_recharge_refund_hold_and_return", + move |connection, sender| { + connection + .procedures() + .preview_profile_recharge_refund_hold_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_hold_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn prepare_profile_recharge_refund_hold( + &self, + input: module_runtime::RuntimeProfileRechargeRefundHoldPrepareInput, + ) -> Result + { + let procedure_input: RuntimeProfileRechargeRefundHoldPrepareInput = input.into(); + self.call_after_connect( + "prepare_profile_recharge_refund_hold_and_return", + move |connection, sender| { + connection + .procedures() + .prepare_profile_recharge_refund_hold_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_hold_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn release_profile_recharge_refund_hold( + &self, + input: module_runtime::RuntimeProfileRechargeRefundHoldReleaseInput, + ) -> Result + { + let procedure_input: RuntimeProfileRechargeRefundHoldReleaseInput = input.into(); + self.call_after_connect( + "release_profile_recharge_refund_hold_and_return", + move |connection, sender| { + connection + .procedures() + .release_profile_recharge_refund_hold_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_hold_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_profile_recharge_refund_holds_for_reconciliation( + &self, + input: module_runtime::RuntimeProfileRechargeRefundHoldListInput, + ) -> Result, SpacetimeClientError> + { + let procedure_input: RuntimeProfileRechargeRefundHoldListInput = input.into(); + self.call_after_connect( + "list_profile_recharge_refund_holds_for_reconciliation", + move |connection, sender| { + connection + .procedures() + .list_profile_recharge_refund_holds_for_reconciliation_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then( + map_runtime_profile_recharge_refund_hold_list_procedure_result, + ); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn admin_get_profile_wallet( + &self, + input: module_runtime::RuntimeProfileAdminWalletGetInput, + ) -> Result { + let procedure_input: RuntimeProfileAdminWalletGetInput = input.into(); + self.call_after_connect( + "admin_get_profile_wallet_and_return", + move |connection, sender| { + connection + .procedures() + .admin_get_profile_wallet_and_return_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_admin_wallet_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + pub async fn admin_upsert_profile_wallet_manual_restriction( + &self, + input: module_runtime::RuntimeProfileWalletManualRestrictionUpsertInput, + ) -> Result { + let procedure_input: RuntimeProfileWalletManualRestrictionUpsertInput = input.into(); + self.call_after_connect( + "admin_upsert_profile_wallet_manual_restriction_and_return", + move |connection, sender| { + connection + .procedures() + .admin_upsert_profile_wallet_manual_restriction_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_runtime_profile_admin_wallet_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn claim_profile_recharge_order_expiration_schedules( &self, worker_id: String, diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 9ecee3e12..c67a80267 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -216,6 +216,12 @@ macro_rules! migration_tables { profile_membership, profile_recharge_product_config, profile_recharge_order, + profile_recharge_refund, + profile_recharge_refund_observation, + profile_recharge_order_refund_settlement, + profile_recharge_refund_hold, + profile_wallet_manual_restriction, + profile_recharge_refund_bill_checkpoint, profile_recharge_order_expiration_schedule, profile_recharge_order_expiration_timer, profile_feedback_submission, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index 4146a9c44..9d0b2225f 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -472,6 +472,142 @@ pub struct ProfileRechargeOrder { pub(crate) expiration_last_error: Option, } +#[spacetimedb::table( + accessor = profile_recharge_refund, + index(accessor = by_profile_recharge_refund_order_id, btree(columns = [order_id])), + index( + accessor = by_profile_recharge_refund_status_updated_at, + btree(columns = [provider_status, updated_at]) + ) +)] +#[derive(Clone)] +pub struct ProfileRechargeRefund { + #[primary_key] + pub(crate) out_refund_no: String, + #[unique] + pub(crate) provider_refund_id: String, + pub(crate) order_id: String, + pub(crate) provider_transaction_id: String, + pub(crate) user_id: Option, + pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, + pub(crate) total_cents: u64, + pub(crate) refund_cents: u64, + pub(crate) payer_total_cents: u64, + pub(crate) payer_refund_cents: u64, + pub(crate) success_at: Option, + pub(crate) first_observed_at: Timestamp, + pub(crate) updated_at: Timestamp, + pub(crate) last_observation_source: RuntimeProfileRechargeRefundObservationSource, + pub(crate) last_observation_id: String, + pub(crate) order_settled_at: Option, + pub(crate) target_recovery_points: u64, + pub(crate) recovered_points: u64, + pub(crate) unrecovered_points: u64, + pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub(crate) last_recovery_ledger_id: Option, + pub(crate) last_error_code: Option, +} + +#[spacetimedb::table( + accessor = profile_recharge_refund_observation, + index( + accessor = by_profile_recharge_refund_observation_refund, + btree(columns = [out_refund_no, observed_at]) + ) +)] +#[derive(Clone)] +pub struct ProfileRechargeRefundObservation { + #[primary_key] + pub(crate) observation_id: String, + pub(crate) out_refund_no: String, + pub(crate) provider_refund_id: String, + pub(crate) order_id: String, + pub(crate) provider_transaction_id: String, + pub(crate) source: RuntimeProfileRechargeRefundObservationSource, + pub(crate) provider_status: RuntimeProfileRechargeRefundStatus, + pub(crate) total_cents: u64, + pub(crate) refund_cents: u64, + pub(crate) payer_total_cents: u64, + pub(crate) payer_refund_cents: u64, + pub(crate) success_at: Option, + pub(crate) notification_ref: Option, + pub(crate) payload_fingerprint: String, + pub(crate) resolution_code: String, + pub(crate) observed_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_recharge_order_refund_settlement, + index( + accessor = by_profile_recharge_order_refund_settlement_user_id, + btree(columns = [user_id]) + ) +)] +#[derive(Clone)] +pub struct ProfileRechargeOrderRefundSettlement { + #[primary_key] + pub(crate) order_id: String, + pub(crate) user_id: String, + pub(crate) successful_refund_count: u32, + pub(crate) cumulative_success_refund_cents: u64, + pub(crate) target_recovery_points: u64, + pub(crate) recovered_points: u64, + pub(crate) unrecovered_points: u64, + pub(crate) recovery_status: RuntimeProfileRechargeRefundRecoveryStatus, + pub(crate) wallet_frozen: bool, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = profile_recharge_refund_hold, + index(accessor = by_profile_recharge_refund_hold_order_id, btree(columns = [order_id])), + index(accessor = by_profile_recharge_refund_hold_user_id, btree(columns = [user_id])), + index(accessor = by_profile_recharge_refund_hold_status, btree(columns = [status])) +)] +#[derive(Clone)] +pub struct ProfileRechargeRefundHold { + #[primary_key] + pub(crate) out_refund_no: String, + pub(crate) order_id: String, + pub(crate) user_id: String, + pub(crate) refund_cents: u64, + pub(crate) held_points: u64, + pub(crate) status: RuntimeProfileRechargeRefundHoldStatus, + pub(crate) admin_user_id: String, + pub(crate) reason: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_at: Timestamp, + pub(crate) settled_at: Option, + pub(crate) released_at: Option, + pub(crate) released_by_admin_user_id: Option, + pub(crate) release_reason: Option, +} + +#[spacetimedb::table(accessor = profile_wallet_manual_restriction)] +#[derive(Clone)] +pub struct ProfileWalletManualRestriction { + #[primary_key] + pub(crate) user_id: String, + pub(crate) frozen: bool, + pub(crate) reason: String, + pub(crate) created_by_admin_user_id: String, + pub(crate) created_at: Timestamp, + pub(crate) updated_by_admin_user_id: String, + pub(crate) updated_at: Timestamp, +} + +#[spacetimedb::table(accessor = profile_recharge_refund_bill_checkpoint)] +#[derive(Clone)] +pub struct ProfileRechargeRefundBillCheckpoint { + #[primary_key] + pub(crate) checkpoint_id: String, + pub(crate) bill_date: String, + pub(crate) bill_hash: String, + pub(crate) processed_refund_count: u32, + pub(crate) completed_at: Timestamp, + pub(crate) updated_at: Timestamp, +} + #[spacetimedb::table( accessor = profile_recharge_order_expiration_schedule, index( @@ -1082,7 +1218,13 @@ pub fn mark_profile_recharge_order_paid_and_return( ctx: &mut ProcedureContext, input: RuntimeProfileRechargeOrderPaidInput, ) -> RuntimeProfileRechargeCenterProcedureResult { - match ctx.try_with_tx(|tx| mark_profile_recharge_order_paid_record(tx, input.clone())) { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + mark_profile_recharge_order_paid_record(tx, input.clone()) + }) { Ok((record, order)) => RuntimeProfileRechargeCenterProcedureResult { ok: true, record: Some(record), @@ -1098,6 +1240,405 @@ pub fn mark_profile_recharge_order_paid_and_return( } } +#[spacetimedb::procedure] +pub fn record_profile_recharge_refund_observation_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundObservationInput, +) -> RuntimeProfileRechargeRefundProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + record_profile_recharge_refund_observation(tx, input.clone()) + }) { + Ok((record, settlement, duplicate, resolution_code)) => { + RuntimeProfileRechargeRefundProcedureResult { + ok: true, + record: Some(record), + settlement, + duplicate, + resolution_code, + error_message: None, + } + } + Err(message) => RuntimeProfileRechargeRefundProcedureResult { + ok: false, + record: None, + settlement: None, + duplicate: false, + resolution_code: "failed".to_string(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_profile_recharge_refund_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundGetInput, +) -> RuntimeProfileRechargeRefundProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = + build_runtime_profile_recharge_refund_get_input(input.out_refund_no.clone())?; + let row = tx + .db + .profile_recharge_refund() + .out_refund_no() + .find(&validated.out_refund_no) + .ok_or_else(|| "profile_recharge_refund 不存在".to_string())?; + let settlement = tx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&row.order_id) + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); + Ok(( + build_profile_recharge_refund_snapshot_from_row(&row), + settlement, + )) + }) { + Ok((record, settlement)) => RuntimeProfileRechargeRefundProcedureResult { + ok: true, + record: Some(record), + settlement, + duplicate: false, + resolution_code: "loaded".to_string(), + error_message: None, + }, + Err(message) => RuntimeProfileRechargeRefundProcedureResult { + ok: false, + record: None, + settlement: None, + duplicate: false, + resolution_code: "failed".to_string(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_profile_recharge_refunds_for_reconciliation( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundReconciliationListInput, +) -> RuntimeProfileRechargeRefundListProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + Ok(list_profile_recharge_refund_reconciliation_rows( + tx, + input.clone(), + )) + }) { + Ok(entries) => RuntimeProfileRechargeRefundListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileRechargeRefundListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_list_profile_recharge_orders_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeOrderAdminListInput, +) -> RuntimeProfileRechargeOrderAdminListProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + admin_list_profile_recharge_order_entries(tx, input.clone()) + }) { + Ok(entries) => RuntimeProfileRechargeOrderAdminListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileRechargeOrderAdminListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn preview_profile_recharge_refund_hold_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundHoldPreviewInput, +) -> RuntimeProfileRechargeRefundHoldProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + preview_profile_recharge_refund_hold(tx, input.clone()) + }) { + Ok((record, order, settlement, wallet)) => { + RuntimeProfileRechargeRefundHoldProcedureResult { + ok: true, + record: Some(record), + order: Some(order), + settlement, + wallet: Some(wallet), + error_message: None, + } + } + Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { + ok: false, + record: None, + order: None, + settlement: None, + wallet: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn prepare_profile_recharge_refund_hold_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundHoldPrepareInput, +) -> RuntimeProfileRechargeRefundHoldProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + prepare_profile_recharge_refund_hold(tx, input.clone()) + }) { + Ok((record, order, settlement, wallet)) => { + RuntimeProfileRechargeRefundHoldProcedureResult { + ok: true, + record: Some(record), + order: Some(order), + settlement, + wallet: Some(wallet), + error_message: None, + } + } + Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { + ok: false, + record: None, + order: None, + settlement: None, + wallet: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn release_profile_recharge_refund_hold_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundHoldReleaseInput, +) -> RuntimeProfileRechargeRefundHoldProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + release_profile_recharge_refund_hold(tx, input.clone()) + }) { + Ok((record, order, settlement, wallet)) => { + RuntimeProfileRechargeRefundHoldProcedureResult { + ok: true, + record: Some(record), + order: Some(order), + settlement, + wallet: Some(wallet), + error_message: None, + } + } + Err(message) => RuntimeProfileRechargeRefundHoldProcedureResult { + ok: false, + record: None, + order: None, + settlement: None, + wallet: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn list_profile_recharge_refund_holds_for_reconciliation( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundHoldListInput, +) -> RuntimeProfileRechargeRefundHoldListProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_recharge_refund_hold_list_input(input.limit); + let mut rows = tx + .db + .profile_recharge_refund_hold() + .by_profile_recharge_refund_hold_status() + .filter(RuntimeProfileRechargeRefundHoldStatus::Active) + .collect::>(); + rows.sort_by(|left, right| { + left.updated_at + .to_micros_since_unix_epoch() + .cmp(&right.updated_at.to_micros_since_unix_epoch()) + .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) + }); + let rotation_slot = tx + .timestamp + .to_micros_since_unix_epoch() + .div_euclid(60 * 1_000_000) + .unsigned_abs(); + Ok(select_profile_recharge_refund_hold_reconciliation_page( + rows, + validated.limit as usize, + rotation_slot, + ) + .into_iter() + .map(|row| build_profile_recharge_refund_hold_snapshot_from_row(&row)) + .collect()) + }) { + Ok(entries) => RuntimeProfileRechargeRefundHoldListProcedureResult { + ok: true, + entries, + error_message: None, + }, + Err(message) => RuntimeProfileRechargeRefundHoldListProcedureResult { + ok: false, + entries: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_get_profile_wallet_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileAdminWalletGetInput, +) -> RuntimeProfileAdminWalletProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_admin_wallet_get_input(input.user_id.clone())?; + Ok(build_profile_admin_wallet_snapshot(tx, &validated.user_id)) + }) { + Ok(record) => RuntimeProfileAdminWalletProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileAdminWalletProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn admin_upsert_profile_wallet_manual_restriction_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileWalletManualRestrictionUpsertInput, +) -> RuntimeProfileAdminWalletProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_wallet_manual_restriction_upsert_input( + input.user_id.clone(), + input.frozen, + input.reason.clone(), + input.admin_user_id.clone(), + )?; + upsert_profile_wallet_manual_restriction(tx, validated); + Ok(build_profile_admin_wallet_snapshot(tx, &input.user_id)) + }) { + Ok(record) => RuntimeProfileAdminWalletProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileAdminWalletProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_profile_recharge_refund_bill_checkpoint_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundBillCheckpointGetInput, +) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let validated = build_runtime_profile_recharge_refund_bill_checkpoint_get_input( + input.checkpoint_id.clone(), + )?; + Ok(tx + .db + .profile_recharge_refund_bill_checkpoint() + .checkpoint_id() + .find(&validated.checkpoint_id) + .map(|row| build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row))) + }) { + Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + ok: true, + record, + error_message: None, + }, + Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn advance_profile_recharge_refund_bill_checkpoint_and_return( + ctx: &mut ProcedureContext, + input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, +) -> RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + advance_profile_recharge_refund_bill_checkpoint(tx, input.clone()) + }) { + Ok(record) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + ok: true, + record: Some(record), + error_message: None, + }, + Err(message) => RuntimeProfileRechargeRefundBillCheckpointProcedureResult { + ok: false, + record: None, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn claim_profile_recharge_order_expiration_schedule_and_return( ctx: &mut ProcedureContext, @@ -2096,6 +2637,208 @@ mod tests { ); } + fn recharge_order_for_refund( + status: RuntimeProfileRechargeOrderStatus, + ) -> ProfileRechargeOrder { + ProfileRechargeOrder { + order_id: "rcg-test".to_string(), + user_id: "user-1".to_string(), + product_id: "points_60".to_string(), + product_title: "60泥点".to_string(), + kind: RuntimeProfileRechargeProductKind::Points, + amount_cents: 600, + status, + payment_channel: PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE.to_string(), + paid_at: Some(Timestamp::from_micros_since_unix_epoch(100)), + provider_transaction_id: Some("wx-transaction-1".to_string()), + created_at: Timestamp::from_micros_since_unix_epoch(1), + points_delta: 60, + membership_expires_at: None, + expired_at: None, + expiration_checked_at: None, + expiration_provider_state: None, + expiration_last_error: None, + } + } + + fn recharge_refund_for_order(order: &ProfileRechargeOrder) -> ProfileRechargeRefund { + ProfileRechargeRefund { + out_refund_no: "refund-test".to_string(), + provider_refund_id: "wx-refund-1".to_string(), + order_id: order.order_id.clone(), + provider_transaction_id: order.provider_transaction_id.clone().unwrap(), + user_id: Some(order.user_id.clone()), + provider_status: RuntimeProfileRechargeRefundStatus::Success, + total_cents: order.amount_cents, + refund_cents: order.amount_cents, + payer_total_cents: order.amount_cents, + payer_refund_cents: order.amount_cents, + success_at: Some(Timestamp::from_micros_since_unix_epoch(200)), + first_observed_at: Timestamp::from_micros_since_unix_epoch(200), + updated_at: Timestamp::from_micros_since_unix_epoch(200), + last_observation_source: RuntimeProfileRechargeRefundObservationSource::Callback, + last_observation_id: "observation-1".to_string(), + order_settled_at: None, + target_recovery_points: 0, + recovered_points: 0, + unrecovered_points: 0, + recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, + last_recovery_ledger_id: None, + last_error_code: None, + } + } + + #[test] + fn refunded_order_accepts_only_exact_payment_transaction_replay() { + let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); + assert!( + validate_profile_recharge_order_paid_replay_transaction_id( + &order, + &Some("wx-transaction-1".to_string()), + ) + .is_ok() + ); + assert!( + validate_profile_recharge_order_paid_replay_transaction_id( + &order, + &Some("wx-transaction-other".to_string()), + ) + .is_err() + ); + assert!(validate_profile_recharge_order_paid_replay_transaction_id(&order, &None).is_err()); + } + + #[test] + fn refunded_order_keeps_first_recharge_qualification_consumed() { + let refunded = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Refunded); + assert!(profile_recharge_order_counts_as_paid_purchase(&refunded)); + + let mut unpaid = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Pending); + unpaid.paid_at = None; + assert!(!profile_recharge_order_counts_as_paid_purchase(&unpaid)); + } + + #[test] + fn v3_refund_order_match_rejects_virtual_channel_transaction_and_total_mismatch() { + let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); + let refund = recharge_refund_for_order(&order); + assert!(validate_profile_recharge_refund_order_match(&order, &refund).is_ok()); + + let mut wrong_channel = order.clone(); + wrong_channel.payment_channel = + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL.to_string(); + assert_eq!( + validate_profile_recharge_refund_order_match(&wrong_channel, &refund), + Err("payment_channel_not_v3".to_string()) + ); + + let mut wrong_transaction = refund.clone(); + wrong_transaction.provider_transaction_id = "wx-transaction-other".to_string(); + assert_eq!( + validate_profile_recharge_refund_order_match(&order, &wrong_transaction), + Err("provider_transaction_id_mismatch".to_string()) + ); + + let mut wrong_total = refund; + wrong_total.total_cents = 599; + assert_eq!( + validate_profile_recharge_refund_order_match(&order, &wrong_total), + Err("order_total_mismatch".to_string()) + ); + } + + #[test] + fn refund_observation_replay_requires_identical_financial_facts() { + let input = build_runtime_profile_recharge_refund_observation_input( + "observation-1".to_string(), + RuntimeProfileRechargeRefundObservationSource::Callback, + Some("notification-ref".to_string()), + "payload-ref".to_string(), + "refund-test".to_string(), + "wx-refund-1".to_string(), + "rcg-test".to_string(), + "wx-transaction-1".to_string(), + RuntimeProfileRechargeRefundStatus::Success, + 600, + 600, + 600, + 600, + Some(200), + 300, + ) + .unwrap(); + let row = ProfileRechargeRefundObservation { + observation_id: input.observation_id.clone(), + out_refund_no: input.out_refund_no.clone(), + provider_refund_id: input.provider_refund_id.clone(), + order_id: input.order_id.clone(), + provider_transaction_id: input.provider_transaction_id.clone(), + source: input.source, + provider_status: input.provider_status, + total_cents: input.total_cents, + refund_cents: input.refund_cents, + payer_total_cents: input.payer_total_cents, + payer_refund_cents: input.payer_refund_cents, + success_at: input + .success_at_micros + .map(Timestamp::from_micros_since_unix_epoch), + notification_ref: input.notification_ref.clone(), + payload_fingerprint: input.payload_fingerprint.clone(), + resolution_code: "settled".to_string(), + observed_at: Timestamp::from_micros_since_unix_epoch(input.observed_at_micros), + }; + assert!(validate_profile_recharge_refund_observation_replay(&row, &input).is_ok()); + + let mut conflicting = input; + conflicting.refund_cents = 599; + assert!(validate_profile_recharge_refund_observation_replay(&row, &conflicting).is_err()); + let snapshot = build_profile_recharge_refund_observation_snapshot_from_row(&row); + assert_eq!(snapshot.resolution_code, "settled"); + } + + #[test] + fn refund_reconciliation_retries_only_late_order_manual_review() { + let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); + let mut refund = recharge_refund_for_order(&order); + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + + refund.last_error_code = Some("order_missing".to_string()); + assert!(profile_recharge_refund_needs_reconciliation(&refund)); + refund.last_error_code = Some("order_not_paid".to_string()); + assert!(profile_recharge_refund_needs_reconciliation(&refund)); + + refund.last_error_code = Some("membership_manual_review".to_string()); + assert!(!profile_recharge_refund_needs_reconciliation(&refund)); + refund.last_error_code = Some("order_total_mismatch".to_string()); + assert!(!profile_recharge_refund_needs_reconciliation(&refund)); + } + + #[test] + fn refund_reconciliation_rotates_across_more_than_one_batch() { + let order = recharge_order_for_refund(RuntimeProfileRechargeOrderStatus::Paid); + let rows = (0..250) + .map(|index| { + let mut refund = recharge_refund_for_order(&order); + refund.out_refund_no = format!("refund-{index:03}"); + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; + refund + }) + .collect::>(); + + let first = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 0); + let second = select_profile_recharge_refund_reconciliation_page(rows.clone(), 100, 1); + let third = select_profile_recharge_refund_reconciliation_page(rows, 100, 2); + assert_eq!((first.len(), second.len(), third.len()), (100, 100, 50)); + + let observed = first + .into_iter() + .chain(second) + .chain(third) + .map(|row| row.out_refund_no) + .collect::>(); + assert_eq!(observed.len(), 250); + } + #[test] fn point_recharge_display_is_resolved_per_product() { let products = runtime_profile_recharge_point_products(); @@ -3271,7 +4014,14 @@ fn mark_profile_recharge_order_paid_record( .find(&validated_input.order_id) .ok_or_else(|| "profile_recharge_order 不存在".to_string())?; - if order.status == RuntimeProfileRechargeOrderStatus::Paid { + if matches!( + order.status, + RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded + ) { + validate_profile_recharge_order_paid_replay_transaction_id( + &order, + &validated_input.provider_transaction_id, + )?; delete_profile_recharge_order_expiration_task(ctx, &order.order_id); return Ok(( build_profile_recharge_center_snapshot(ctx, &order.user_id), @@ -3314,6 +4064,1403 @@ fn mark_profile_recharge_order_paid_record( )) } +fn validate_profile_recharge_order_paid_replay_transaction_id( + order: &ProfileRechargeOrder, + provider_transaction_id: &Option, +) -> Result<(), String> { + if &order.provider_transaction_id != provider_transaction_id { + return Err("profile_recharge_order provider_transaction_id 重放不匹配".to_string()); + } + Ok(()) +} + +fn admin_list_profile_recharge_order_entries( + ctx: &ReducerContext, + input: RuntimeProfileRechargeOrderAdminListInput, +) -> Result, String> { + let validated = build_runtime_profile_recharge_order_admin_list_input( + input.order_id, + input.user_id, + input.provider_transaction_id, + input.payment_channel, + input.status, + input.created_after_micros, + input.created_before_micros, + input.limit, + )?; + let mut rows = ctx + .db + .profile_recharge_order() + .iter() + .filter(|row| profile_recharge_order_matches_admin_query(row, &validated)) + .collect::>(); + rows.sort_by(|left, right| { + right + .created_at + .to_micros_since_unix_epoch() + .cmp(&left.created_at.to_micros_since_unix_epoch()) + .then_with(|| right.order_id.cmp(&left.order_id)) + }); + Ok(rows + .into_iter() + .take(validated.limit as usize) + .map(|row| build_profile_recharge_order_admin_entry_snapshot(ctx, &row)) + .collect()) +} + +fn profile_recharge_order_matches_admin_query( + row: &ProfileRechargeOrder, + query: &RuntimeProfileRechargeOrderAdminListInput, +) -> bool { + query + .order_id + .as_deref() + .is_none_or(|value| row.order_id == value) + && query + .user_id + .as_deref() + .is_none_or(|value| row.user_id == value) + && query + .provider_transaction_id + .as_deref() + .is_none_or(|value| row.provider_transaction_id.as_deref() == Some(value)) + && query + .payment_channel + .as_deref() + .is_none_or(|value| row.payment_channel == value) + && query.status.is_none_or(|value| row.status == value) + && query + .created_after_micros + .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() >= value) + && query + .created_before_micros + .is_none_or(|value| row.created_at.to_micros_since_unix_epoch() <= value) +} + +fn build_profile_recharge_order_admin_entry_snapshot( + ctx: &ReducerContext, + row: &ProfileRechargeOrder, +) -> RuntimeProfileRechargeOrderAdminEntrySnapshot { + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&row.order_id) + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); + let mut refunds = ctx + .db + .profile_recharge_refund() + .by_profile_recharge_refund_order_id() + .filter(&row.order_id) + .map(|value| build_profile_recharge_refund_snapshot_from_row(&value)) + .collect::>(); + refunds.sort_by(|left, right| { + right + .first_observed_at_micros + .cmp(&left.first_observed_at_micros) + .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) + }); + RuntimeProfileRechargeOrderAdminEntrySnapshot { + order: build_profile_recharge_order_snapshot_from_row(row), + settlement, + refunds, + active_hold: active_profile_recharge_refund_hold_for_order(ctx, &row.order_id) + .map(|value| build_profile_recharge_refund_hold_snapshot_from_row(&value)), + wallet: build_profile_admin_wallet_snapshot(ctx, &row.user_id), + } +} + +fn preview_profile_recharge_refund_hold( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundHoldPreviewInput, +) -> Result< + ( + RuntimeProfileRechargeRefundHoldSnapshot, + RuntimeProfileRechargeOrderSnapshot, + Option, + RuntimeProfileAdminWalletSnapshot, + ), + String, +> { + let validated = build_runtime_profile_recharge_refund_hold_preview_input( + input.order_id, + input.refund_cents, + )?; + let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( + ctx, + &validated.order_id, + validated.refund_cents, + )?; + let now_micros = ctx.timestamp.to_micros_since_unix_epoch(); + Ok(( + RuntimeProfileRechargeRefundHoldSnapshot { + out_refund_no: String::new(), + order_id: order.order_id.clone(), + user_id: order.user_id.clone(), + refund_cents: validated.refund_cents, + held_points, + status: RuntimeProfileRechargeRefundHoldStatus::Active, + admin_user_id: String::new(), + reason: String::new(), + created_at_micros: now_micros, + updated_at_micros: now_micros, + settled_at_micros: None, + released_at_micros: None, + released_by_admin_user_id: None, + release_reason: None, + }, + build_profile_recharge_order_snapshot_from_row(&order), + settlement + .as_ref() + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), + build_profile_admin_wallet_snapshot(ctx, &order.user_id), + )) +} + +fn prepare_profile_recharge_refund_hold( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundHoldPrepareInput, +) -> Result< + ( + RuntimeProfileRechargeRefundHoldSnapshot, + RuntimeProfileRechargeOrderSnapshot, + Option, + RuntimeProfileAdminWalletSnapshot, + ), + String, +> { + let validated = build_runtime_profile_recharge_refund_hold_prepare_input( + input.order_id, + input.out_refund_no, + input.refund_cents, + input.admin_user_id, + input.reason, + )?; + if let Some(existing) = ctx + .db + .profile_recharge_refund_hold() + .out_refund_no() + .find(&validated.out_refund_no) + { + if existing.order_id != validated.order_id + || existing.refund_cents != validated.refund_cents + || existing.admin_user_id != validated.admin_user_id + || existing.reason != validated.reason + { + return Err("refund_hold 幂等重放内容冲突".to_string()); + } + let order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&existing.order_id) + .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&order.order_id) + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); + return Ok(( + build_profile_recharge_refund_hold_snapshot_from_row(&existing), + build_profile_recharge_order_snapshot_from_row(&order), + settlement, + build_profile_admin_wallet_snapshot(ctx, &order.user_id), + )); + } + if ctx + .db + .profile_recharge_refund() + .out_refund_no() + .find(&validated.out_refund_no) + .is_some() + { + return Err("refund_hold 对应退款单已经存在".to_string()); + } + let (order, settlement, held_points) = validate_profile_recharge_refund_hold_eligibility( + ctx, + &validated.order_id, + validated.refund_cents, + )?; + let row = ProfileRechargeRefundHold { + out_refund_no: validated.out_refund_no, + order_id: order.order_id.clone(), + user_id: order.user_id.clone(), + refund_cents: validated.refund_cents, + held_points, + status: RuntimeProfileRechargeRefundHoldStatus::Active, + admin_user_id: validated.admin_user_id, + reason: validated.reason, + created_at: ctx.timestamp, + updated_at: ctx.timestamp, + settled_at: None, + released_at: None, + released_by_admin_user_id: None, + release_reason: None, + }; + ctx.db.profile_recharge_refund_hold().insert(row.clone()); + Ok(( + build_profile_recharge_refund_hold_snapshot_from_row(&row), + build_profile_recharge_order_snapshot_from_row(&order), + settlement + .as_ref() + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(value)), + build_profile_admin_wallet_snapshot(ctx, &order.user_id), + )) +} + +fn validate_profile_recharge_refund_hold_eligibility( + ctx: &ReducerContext, + order_id: &str, + refund_cents: u64, +) -> Result< + ( + ProfileRechargeOrder, + Option, + u64, + ), + String, +> { + let order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&order_id.to_string()) + .ok_or_else(|| "充值订单不存在".to_string())?; + if order.kind != RuntimeProfileRechargeProductKind::Points { + return Err("首期后台退款只支持泥点充值订单".to_string()); + } + if order.status != RuntimeProfileRechargeOrderStatus::Paid || order.paid_at.is_none() { + return Err("充值订单不是可退款的已支付状态".to_string()); + } + if !matches!( + order.payment_channel.as_str(), + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + ) { + return Err("首期后台退款只支持普通微信 V3 订单".to_string()); + } + if order.provider_transaction_id.is_none() || order.points_delta <= 0 { + return Err("充值订单缺少可退款的支付或泥点结算事实".to_string()); + } + if active_profile_recharge_refund_hold_for_order(ctx, &order.order_id).is_some() { + return Err("充值订单已有退款占用,需先完成对账".to_string()); + } + if ctx + .db + .profile_recharge_refund() + .by_profile_recharge_refund_order_id() + .filter(&order.order_id) + .any(|row| { + matches!( + row.provider_status, + RuntimeProfileRechargeRefundStatus::Processing + | RuntimeProfileRechargeRefundStatus::Abnormal + ) + }) + { + return Err("充值订单存在未完成退款,需先完成对账".to_string()); + } + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&order.order_id); + if settlement + .as_ref() + .is_some_and(|value| value.unrecovered_points > 0) + { + return Err("账户存在退款异常欠账,不能继续发起退款".to_string()); + } + if has_profile_wallet_manual_restriction(ctx, &order.user_id) { + return Err("账户已被人工冻结,不能继续发起退款".to_string()); + } + let plan = build_runtime_profile_recharge_refund_settlement_plan( + settlement + .as_ref() + .map(|value| value.successful_refund_count) + .unwrap_or(0), + settlement + .as_ref() + .map(|value| value.cumulative_success_refund_cents) + .unwrap_or(0), + settlement + .as_ref() + .map(|value| value.target_recovery_points) + .unwrap_or(0), + refund_cents, + order.amount_cents, + order.points_delta, + )?; + let remaining_refundable_cents = order.amount_cents.saturating_sub( + settlement + .as_ref() + .map(|value| value.cumulative_success_refund_cents) + .unwrap_or(0), + ); + let held_points = resolve_runtime_profile_recharge_refund_hold_points( + plan.incremental_target_recovery_points, + refund_cents, + remaining_refundable_cents, + ); + let wallet = build_profile_admin_wallet_snapshot(ctx, &order.user_id); + validate_runtime_profile_recharge_refund_hold_capacity( + held_points, + wallet.permanent_points, + wallet.held_points, + )?; + Ok((order, settlement, held_points)) +} + +fn release_profile_recharge_refund_hold( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundHoldReleaseInput, +) -> Result< + ( + RuntimeProfileRechargeRefundHoldSnapshot, + RuntimeProfileRechargeOrderSnapshot, + Option, + RuntimeProfileAdminWalletSnapshot, + ), + String, +> { + let validated = build_runtime_profile_recharge_refund_hold_release_input( + input.out_refund_no, + input.admin_user_id, + input.release_reason, + )?; + let mut row = ctx + .db + .profile_recharge_refund_hold() + .out_refund_no() + .find(&validated.out_refund_no) + .ok_or_else(|| "refund_hold 不存在".to_string())?; + if row.status == RuntimeProfileRechargeRefundHoldStatus::Settled { + return Err("已结算退款占用不能释放".to_string()); + } + if row.status == RuntimeProfileRechargeRefundHoldStatus::Active { + row.status = RuntimeProfileRechargeRefundHoldStatus::Released; + row.updated_at = ctx.timestamp; + row.released_at = Some(ctx.timestamp); + row.released_by_admin_user_id = Some(validated.admin_user_id); + row.release_reason = Some(validated.release_reason); + upsert_profile_recharge_refund_hold_row(ctx, row.clone()); + } + let order = ctx + .db + .profile_recharge_order() + .order_id() + .find(&row.order_id) + .ok_or_else(|| "refund_hold 对应充值订单不存在".to_string())?; + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&order.order_id) + .map(|value| build_profile_recharge_order_refund_settlement_snapshot_from_row(&value)); + Ok(( + build_profile_recharge_refund_hold_snapshot_from_row(&row), + build_profile_recharge_order_snapshot_from_row(&order), + settlement, + build_profile_admin_wallet_snapshot(ctx, &order.user_id), + )) +} + +fn build_profile_admin_wallet_snapshot( + ctx: &ReducerContext, + user_id: &str, +) -> RuntimeProfileAdminWalletSnapshot { + refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); + let total_balance = profile_wallet_balance(ctx, user_id); + let daily_free_points = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()) + .map(|row| row.remaining_points) + .unwrap_or(0); + let membership_limited_points = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + .map(|row| row.cycle_remaining_points) + .unwrap_or(0); + let permanent_points = total_balance + .saturating_sub(daily_free_points) + .saturating_sub(membership_limited_points); + let held_points = active_profile_recharge_refund_hold_points(ctx, user_id); + let refund_debt_points = ctx + .db + .profile_recharge_order_refund_settlement() + .by_profile_recharge_order_refund_settlement_user_id() + .filter(user_id) + .fold(0_u64, |total, row| { + total.saturating_add(row.unrecovered_points) + }); + let manual_restriction = ctx + .db + .profile_wallet_manual_restriction() + .user_id() + .find(&user_id.to_string()); + let manual_frozen = manual_restriction.as_ref().is_some_and(|row| row.frozen); + let refund_debt_frozen = refund_debt_points > 0; + let wallet_frozen = manual_frozen || refund_debt_frozen; + RuntimeProfileAdminWalletSnapshot { + user_id: user_id.to_string(), + total_balance, + spendable_balance: if wallet_frozen { + 0 + } else { + total_balance.saturating_sub(held_points) + }, + daily_free_points, + membership_limited_points, + permanent_points, + held_points, + refund_debt_points, + manual_frozen, + refund_debt_frozen, + wallet_frozen, + manual_restriction: manual_restriction + .as_ref() + .map(build_profile_wallet_manual_restriction_snapshot_from_row), + } +} + +fn upsert_profile_wallet_manual_restriction( + ctx: &ReducerContext, + input: RuntimeProfileWalletManualRestrictionUpsertInput, +) { + let existing = ctx + .db + .profile_wallet_manual_restriction() + .user_id() + .find(&input.user_id); + let row = ProfileWalletManualRestriction { + user_id: input.user_id, + frozen: input.frozen, + reason: input.reason, + created_by_admin_user_id: existing + .as_ref() + .map(|value| value.created_by_admin_user_id.clone()) + .unwrap_or_else(|| input.admin_user_id.clone()), + created_at: existing + .as_ref() + .map(|value| value.created_at) + .unwrap_or(ctx.timestamp), + updated_by_admin_user_id: input.admin_user_id, + updated_at: ctx.timestamp, + }; + ctx.db + .profile_wallet_manual_restriction() + .user_id() + .delete(&row.user_id); + ctx.db.profile_wallet_manual_restriction().insert(row); +} + +fn active_profile_recharge_refund_hold_for_order( + ctx: &ReducerContext, + order_id: &str, +) -> Option { + ctx.db + .profile_recharge_refund_hold() + .by_profile_recharge_refund_hold_order_id() + .filter(order_id) + .find(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) +} + +fn active_profile_recharge_refund_hold_points(ctx: &ReducerContext, user_id: &str) -> u64 { + ctx.db + .profile_recharge_refund_hold() + .by_profile_recharge_refund_hold_user_id() + .filter(user_id) + .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) + .fold(0_u64, |total, row| total.saturating_add(row.held_points)) +} + +fn active_profile_recharge_refund_unrelated_hold_points( + ctx: &ReducerContext, + user_id: &str, + refund: &ProfileRechargeRefund, +) -> u64 { + ctx.db + .profile_recharge_refund_hold() + .by_profile_recharge_refund_hold_user_id() + .filter(user_id) + .filter(|row| row.status == RuntimeProfileRechargeRefundHoldStatus::Active) + .filter(|row| { + row.out_refund_no != refund.out_refund_no + || row.order_id != refund.order_id + || row.refund_cents != refund.refund_cents + }) + .fold(0_u64, |total, row| total.saturating_add(row.held_points)) +} + +fn has_profile_wallet_manual_restriction(ctx: &ReducerContext, user_id: &str) -> bool { + ctx.db + .profile_wallet_manual_restriction() + .user_id() + .find(&user_id.to_string()) + .is_some_and(|row| row.frozen) +} + +fn upsert_profile_recharge_refund_hold_row(ctx: &ReducerContext, row: ProfileRechargeRefundHold) { + ctx.db + .profile_recharge_refund_hold() + .out_refund_no() + .delete(&row.out_refund_no); + ctx.db.profile_recharge_refund_hold().insert(row); +} + +fn sync_profile_recharge_refund_hold_with_provider_status( + ctx: &ReducerContext, + refund: &ProfileRechargeRefund, +) { + let Some(mut hold) = ctx + .db + .profile_recharge_refund_hold() + .out_refund_no() + .find(&refund.out_refund_no) + else { + return; + }; + if hold.order_id != refund.order_id || hold.refund_cents != refund.refund_cents { + return; + } + + let next_status = + resolve_runtime_profile_recharge_refund_hold_status(hold.status, refund.provider_status); + if next_status == hold.status { + return; + } + hold.status = next_status; + hold.updated_at = ctx.timestamp; + match next_status { + RuntimeProfileRechargeRefundHoldStatus::Settled => { + hold.settled_at = Some(ctx.timestamp); + } + RuntimeProfileRechargeRefundHoldStatus::Released => { + hold.released_at = Some(ctx.timestamp); + hold.released_by_admin_user_id = None; + hold.release_reason = Some("provider_closed".to_string()); + } + RuntimeProfileRechargeRefundHoldStatus::Active => {} + } + upsert_profile_recharge_refund_hold_row(ctx, hold); +} + +fn record_profile_recharge_refund_observation( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundObservationInput, +) -> Result< + ( + RuntimeProfileRechargeRefundSnapshot, + Option, + bool, + String, + ), + String, +> { + let validated = build_runtime_profile_recharge_refund_observation_input( + input.observation_id, + input.source, + input.notification_ref, + input.payload_fingerprint, + input.out_refund_no, + input.provider_refund_id, + input.order_id, + input.provider_transaction_id, + input.provider_status, + input.total_cents, + input.refund_cents, + input.payer_total_cents, + input.payer_refund_cents, + input.success_at_micros, + input.observed_at_micros, + )?; + let observed_at = Timestamp::from_micros_since_unix_epoch(validated.observed_at_micros); + let success_at = validated + .success_at_micros + .map(Timestamp::from_micros_since_unix_epoch); + + if let Some(existing_observation) = ctx + .db + .profile_recharge_refund_observation() + .observation_id() + .find(&validated.observation_id) + { + validate_profile_recharge_refund_observation_replay(&existing_observation, &validated)?; + let mut refund = ctx + .db + .profile_recharge_refund() + .out_refund_no() + .find(&validated.out_refund_no) + .ok_or_else(|| "退款 observation 已存在但退款单缺失".to_string())?; + if observed_at.to_micros_since_unix_epoch() > refund.updated_at.to_micros_since_unix_epoch() + { + refund.updated_at = observed_at; + refund.last_observation_source = validated.source; + refund.last_observation_id = validated.observation_id.clone(); + } + let (settlement, resolution_code) = + reconcile_profile_recharge_refund_success(ctx, &mut refund); + sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); + upsert_profile_recharge_refund_row(ctx, refund.clone()); + return Ok(( + build_profile_recharge_refund_snapshot_from_row(&refund), + settlement + .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), + true, + resolution_code, + )); + } + + if let Some(provider_collision) = ctx + .db + .profile_recharge_refund() + .provider_refund_id() + .find(&validated.provider_refund_id) + .filter(|row| row.out_refund_no != validated.out_refund_no) + { + insert_profile_recharge_refund_observation( + ctx, + &validated, + observed_at, + success_at, + "provider_refund_id_conflict", + ); + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&provider_collision.order_id) + .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)); + return Ok(( + build_profile_recharge_refund_snapshot_from_row(&provider_collision), + settlement, + false, + "provider_refund_id_conflict".to_string(), + )); + } + + let existing = ctx + .db + .profile_recharge_refund() + .out_refund_no() + .find(&validated.out_refund_no); + let mut refund = match existing { + Some(mut row) => { + if !profile_recharge_refund_matches_observation(&row, &validated) { + insert_profile_recharge_refund_observation( + ctx, + &validated, + observed_at, + success_at, + "immutable_conflict", + ); + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&row.order_id) + .map(|value| { + build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) + }); + return Ok(( + build_profile_recharge_refund_snapshot_from_row(&row), + settlement, + false, + "immutable_conflict".to_string(), + )); + } + + let transition = resolve_runtime_profile_recharge_refund_status_transition( + row.provider_status, + validated.provider_status, + ); + if transition == RuntimeProfileRechargeRefundStatusTransition::Conflict { + insert_profile_recharge_refund_observation( + ctx, + &validated, + observed_at, + success_at, + "status_conflict", + ); + let settlement = ctx + .db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&row.order_id) + .map(|value| { + build_profile_recharge_order_refund_settlement_snapshot_from_row(&value) + }); + return Ok(( + build_profile_recharge_refund_snapshot_from_row(&row), + settlement, + false, + "status_conflict".to_string(), + )); + } + if transition == RuntimeProfileRechargeRefundStatusTransition::Advance { + row.provider_status = validated.provider_status; + row.success_at = success_at; + } + row.updated_at = latest_profile_recharge_refund_timestamp(row.updated_at, observed_at); + row.last_observation_source = validated.source; + row.last_observation_id = validated.observation_id.clone(); + row + } + None => ProfileRechargeRefund { + out_refund_no: validated.out_refund_no.clone(), + provider_refund_id: validated.provider_refund_id.clone(), + order_id: validated.order_id.clone(), + provider_transaction_id: validated.provider_transaction_id.clone(), + user_id: None, + provider_status: validated.provider_status, + total_cents: validated.total_cents, + refund_cents: validated.refund_cents, + payer_total_cents: validated.payer_total_cents, + payer_refund_cents: validated.payer_refund_cents, + success_at, + first_observed_at: observed_at, + updated_at: observed_at, + last_observation_source: validated.source, + last_observation_id: validated.observation_id.clone(), + order_settled_at: None, + target_recovery_points: 0, + recovered_points: 0, + unrecovered_points: 0, + recovery_status: if validated.provider_status + == RuntimeProfileRechargeRefundStatus::Success + { + RuntimeProfileRechargeRefundRecoveryStatus::Pending + } else { + RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable + }, + last_recovery_ledger_id: None, + last_error_code: None, + }, + }; + + let (settlement, resolution_code) = reconcile_profile_recharge_refund_success(ctx, &mut refund); + sync_profile_recharge_refund_hold_with_provider_status(ctx, &refund); + upsert_profile_recharge_refund_row(ctx, refund.clone()); + insert_profile_recharge_refund_observation( + ctx, + &validated, + observed_at, + success_at, + &resolution_code, + ); + + Ok(( + build_profile_recharge_refund_snapshot_from_row(&refund), + settlement + .map(|row| build_profile_recharge_order_refund_settlement_snapshot_from_row(&row)), + false, + resolution_code, + )) +} + +fn reconcile_profile_recharge_refund_success( + ctx: &ReducerContext, + refund: &mut ProfileRechargeRefund, +) -> (Option, String) { + if refund.provider_status != RuntimeProfileRechargeRefundStatus::Success { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable; + refund.last_error_code = None; + return (None, "recorded_non_success".to_string()); + } + + let Some(mut order) = ctx + .db + .profile_recharge_order() + .order_id() + .find(&refund.order_id) + else { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + refund.last_error_code = Some("order_missing".to_string()); + return (None, "order_missing".to_string()); + }; + refund.user_id = Some(order.user_id.clone()); + + let mismatch_code = validate_profile_recharge_refund_order_match(&order, refund).err(); + if let Some(code) = mismatch_code { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + refund.last_error_code = Some(code.clone()); + let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); + settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + settlement.updated_at = ctx.timestamp; + upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); + return (Some(settlement), code); + } + + let mut settlement = profile_recharge_order_refund_settlement_row(ctx, &order); + if refund.order_settled_at.is_none() { + let order_points_delta = if order.kind == RuntimeProfileRechargeProductKind::Points { + order.points_delta + } else { + 0 + }; + let plan = match build_runtime_profile_recharge_refund_settlement_plan( + settlement.successful_refund_count, + settlement.cumulative_success_refund_cents, + settlement.target_recovery_points, + refund.refund_cents, + order.amount_cents, + order_points_delta, + ) { + Ok(plan) => plan, + Err(_) => { + return mark_profile_recharge_refund_manual_review( + ctx, + refund, + settlement, + "refund_settlement_plan_invalid", + ); + } + }; + + settlement.successful_refund_count = plan.successful_refund_count; + settlement.cumulative_success_refund_cents = plan.cumulative_success_refund_cents; + refund.order_settled_at = Some(ctx.timestamp); + if plan.order_fully_refunded { + order.status = RuntimeProfileRechargeOrderStatus::Refunded; + upsert_profile_recharge_order_row(ctx, order.clone()); + } + match order.kind { + RuntimeProfileRechargeProductKind::Points => { + refund.target_recovery_points = plan.incremental_target_recovery_points; + refund.unrecovered_points = refund + .target_recovery_points + .saturating_sub(refund.recovered_points); + settlement.target_recovery_points = plan.target_recovery_points; + settlement.unrecovered_points = settlement + .target_recovery_points + .saturating_sub(settlement.recovered_points); + } + RuntimeProfileRechargeProductKind::Membership => { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + refund.last_error_code = Some("membership_manual_review".to_string()); + settlement.recovery_status = + RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + } + } + } + + if order.kind == RuntimeProfileRechargeProductKind::Membership { + settlement.updated_at = ctx.timestamp; + upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); + return (Some(settlement), "membership_manual_review".to_string()); + } + + let outstanding = refund + .target_recovery_points + .saturating_sub(refund.recovered_points); + if outstanding > 0 { + match apply_profile_recharge_refund_permanent_points_recovery(ctx, refund, outstanding) { + Ok((recovered, ledger_id)) => { + refund.recovered_points = refund.recovered_points.saturating_add(recovered); + refund.unrecovered_points = refund + .target_recovery_points + .saturating_sub(refund.recovered_points); + refund.last_recovery_ledger_id = ledger_id; + settlement.recovered_points = settlement.recovered_points.saturating_add(recovered); + settlement.unrecovered_points = settlement + .target_recovery_points + .saturating_sub(settlement.recovered_points); + } + Err(code) => { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + refund.last_error_code = Some(code.clone()); + settlement.recovery_status = + RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + settlement.updated_at = ctx.timestamp; + upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); + return (Some(settlement), code); + } + } + } + + if refund.unrecovered_points == 0 { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Applied; + refund.last_error_code = None; + } else { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::Shortfall; + refund.last_error_code = Some("permanent_points_shortfall".to_string()); + } + settlement.recovery_status = if settlement.unrecovered_points == 0 { + RuntimeProfileRechargeRefundRecoveryStatus::Applied + } else { + RuntimeProfileRechargeRefundRecoveryStatus::Shortfall + }; + settlement.wallet_frozen = settlement.unrecovered_points > 0; + settlement.updated_at = ctx.timestamp; + upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); + + let resolution_code = if refund.unrecovered_points == 0 { + "settled" + } else { + "settled_shortfall" + }; + (Some(settlement), resolution_code.to_string()) +} + +fn validate_profile_recharge_refund_order_match( + order: &ProfileRechargeOrder, + refund: &ProfileRechargeRefund, +) -> Result<(), String> { + if !matches!( + order.payment_channel.as_str(), + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_JSAPI + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5 + | PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE + ) { + return Err("payment_channel_not_v3".to_string()); + } + if order.paid_at.is_none() + || !matches!( + order.status, + RuntimeProfileRechargeOrderStatus::Paid | RuntimeProfileRechargeOrderStatus::Refunded + ) + { + return Err("order_not_paid".to_string()); + } + if order.provider_transaction_id.as_deref() != Some(refund.provider_transaction_id.as_str()) { + return Err("provider_transaction_id_mismatch".to_string()); + } + if order.amount_cents != refund.total_cents { + return Err("order_total_mismatch".to_string()); + } + Ok(()) +} + +fn mark_profile_recharge_refund_manual_review( + ctx: &ReducerContext, + refund: &mut ProfileRechargeRefund, + mut settlement: ProfileRechargeOrderRefundSettlement, + code: &str, +) -> (Option, String) { + refund.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + refund.last_error_code = Some(code.to_string()); + settlement.recovery_status = RuntimeProfileRechargeRefundRecoveryStatus::ManualReview; + settlement.updated_at = ctx.timestamp; + upsert_profile_recharge_order_refund_settlement_row(ctx, settlement.clone()); + (Some(settlement), code.to_string()) +} + +fn apply_profile_recharge_refund_permanent_points_recovery( + ctx: &ReducerContext, + refund: &ProfileRechargeRefund, + outstanding_points: u64, +) -> Result<(u64, Option), String> { + let user_id = refund + .user_id + .as_deref() + .ok_or_else(|| "refund_user_missing".to_string())?; + refresh_profile_wallet_expiring_points(ctx, user_id, ctx.timestamp); + let current = ctx + .db + .profile_dashboard_state() + .user_id() + .find(&user_id.to_string()); + let wallet_total = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + let daily_free = ctx + .db + .profile_daily_free_points() + .user_id() + .find(&user_id.to_string()) + .map(|row| row.remaining_points) + .unwrap_or(0); + let membership_limited = ctx + .db + .profile_membership() + .user_id() + .find(&user_id.to_string()) + .filter(|row| active_membership_row_at(row, ctx.timestamp)) + .map(|row| row.cycle_remaining_points) + .unwrap_or(0); + let unrelated_held_points = + active_profile_recharge_refund_unrelated_hold_points(ctx, user_id, refund); + let (recoverable, _) = resolve_runtime_profile_recharge_refund_recovery_with_holds( + outstanding_points, + wallet_total, + daily_free, + membership_limited, + unrelated_held_points, + ); + if recoverable == 0 { + return Ok((0, None)); + } + + let amount_delta = + -i64::try_from(recoverable).map_err(|_| "refund_recovery_points_overflow".to_string())?; + let next_recovered = refund.recovered_points.saturating_add(recoverable); + let ledger_id = format!( + "recharge-refund-recovery:{}:{}", + refund.out_refund_no, next_recovered + ); + if ctx + .db + .profile_wallet_ledger() + .wallet_ledger_id() + .find(&ledger_id) + .is_some() + { + return Err("refund_recovery_ledger_conflict".to_string()); + } + let Some(existing) = current else { + return Err("refund_wallet_state_missing".to_string()); + }; + let next_balance = existing + .wallet_balance + .checked_sub(recoverable) + .ok_or_else(|| "refund_wallet_balance_underflow".to_string())?; + ctx.db + .profile_dashboard_state() + .user_id() + .delete(&existing.user_id); + ctx.db + .profile_dashboard_state() + .insert(ProfileDashboardState { + user_id: existing.user_id.clone(), + wallet_balance: next_balance, + total_play_time_ms: existing.total_play_time_ms, + created_at: existing.created_at, + updated_at: ctx.timestamp, + }); + let metadata_json = metadata_with_profile_wallet_delta_split( + &json!({ + "rechargeOrderId": refund.order_id, + "outRefundNo": refund.out_refund_no, + "providerRefundId": refund.provider_refund_id, + }) + .to_string(), + 0, + 0, + amount_delta, + None, + None, + ); + ctx.db.profile_wallet_ledger().insert(ProfileWalletLedger { + wallet_ledger_id: ledger_id.clone(), + user_id: existing.user_id, + amount_delta, + balance_after: next_balance, + source_type: RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery, + created_at: ctx.timestamp, + metadata_json: Some(metadata_json), + }); + Ok((recoverable, Some(ledger_id))) +} + +fn repay_profile_recharge_refund_debt_from_permanent_points(ctx: &ReducerContext, user_id: &str) { + let mut candidates = ctx + .db + .profile_recharge_refund() + .iter() + .filter(|row| { + row.user_id.as_deref() == Some(user_id) + && row.provider_status == RuntimeProfileRechargeRefundStatus::Success + && row.unrecovered_points > 0 + && matches!( + row.recovery_status, + RuntimeProfileRechargeRefundRecoveryStatus::Pending + | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall + ) + }) + .collect::>(); + candidates.sort_by(|left, right| { + left.first_observed_at + .to_micros_since_unix_epoch() + .cmp(&right.first_observed_at.to_micros_since_unix_epoch()) + .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) + }); + + for mut refund in candidates { + reconcile_profile_recharge_refund_success(ctx, &mut refund); + upsert_profile_recharge_refund_row(ctx, refund); + } +} + +fn profile_recharge_order_refund_settlement_row( + ctx: &ReducerContext, + order: &ProfileRechargeOrder, +) -> ProfileRechargeOrderRefundSettlement { + ctx.db + .profile_recharge_order_refund_settlement() + .order_id() + .find(&order.order_id) + .unwrap_or_else(|| ProfileRechargeOrderRefundSettlement { + order_id: order.order_id.clone(), + user_id: order.user_id.clone(), + successful_refund_count: 0, + cumulative_success_refund_cents: 0, + target_recovery_points: 0, + recovered_points: 0, + unrecovered_points: 0, + recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::Pending, + wallet_frozen: false, + updated_at: ctx.timestamp, + }) +} + +fn profile_recharge_refund_matches_observation( + row: &ProfileRechargeRefund, + input: &RuntimeProfileRechargeRefundObservationInput, +) -> bool { + row.provider_refund_id == input.provider_refund_id + && row.order_id == input.order_id + && row.provider_transaction_id == input.provider_transaction_id + && row.total_cents == input.total_cents + && row.refund_cents == input.refund_cents + && row.payer_total_cents == input.payer_total_cents + && row.payer_refund_cents == input.payer_refund_cents + && (row.provider_status != RuntimeProfileRechargeRefundStatus::Success + || row + .success_at + .map(|value| value.to_micros_since_unix_epoch()) + == input.success_at_micros) +} + +fn validate_profile_recharge_refund_observation_replay( + row: &ProfileRechargeRefundObservation, + input: &RuntimeProfileRechargeRefundObservationInput, +) -> Result<(), String> { + let success_at_micros = row + .success_at + .map(|value| value.to_micros_since_unix_epoch()); + if row.out_refund_no != input.out_refund_no + || row.provider_refund_id != input.provider_refund_id + || row.order_id != input.order_id + || row.provider_transaction_id != input.provider_transaction_id + || row.source != input.source + || row.provider_status != input.provider_status + || row.total_cents != input.total_cents + || row.refund_cents != input.refund_cents + || row.payer_total_cents != input.payer_total_cents + || row.payer_refund_cents != input.payer_refund_cents + || success_at_micros != input.success_at_micros + || row.notification_ref != input.notification_ref + || row.payload_fingerprint != input.payload_fingerprint + { + return Err("退款 observation_id 重放内容冲突".to_string()); + } + Ok(()) +} + +fn insert_profile_recharge_refund_observation( + ctx: &ReducerContext, + input: &RuntimeProfileRechargeRefundObservationInput, + observed_at: Timestamp, + success_at: Option, + resolution_code: &str, +) { + ctx.db + .profile_recharge_refund_observation() + .insert(ProfileRechargeRefundObservation { + observation_id: input.observation_id.clone(), + out_refund_no: input.out_refund_no.clone(), + provider_refund_id: input.provider_refund_id.clone(), + order_id: input.order_id.clone(), + provider_transaction_id: input.provider_transaction_id.clone(), + source: input.source, + provider_status: input.provider_status, + total_cents: input.total_cents, + refund_cents: input.refund_cents, + payer_total_cents: input.payer_total_cents, + payer_refund_cents: input.payer_refund_cents, + success_at, + notification_ref: input.notification_ref.clone(), + payload_fingerprint: input.payload_fingerprint.clone(), + resolution_code: resolution_code.to_string(), + observed_at, + }); +} + +fn upsert_profile_recharge_refund_row(ctx: &ReducerContext, row: ProfileRechargeRefund) { + ctx.db + .profile_recharge_refund() + .out_refund_no() + .delete(&row.out_refund_no); + ctx.db.profile_recharge_refund().insert(row); +} + +fn upsert_profile_recharge_order_refund_settlement_row( + ctx: &ReducerContext, + row: ProfileRechargeOrderRefundSettlement, +) { + ctx.db + .profile_recharge_order_refund_settlement() + .order_id() + .delete(&row.order_id); + ctx.db + .profile_recharge_order_refund_settlement() + .insert(row); +} + +fn upsert_profile_recharge_order_row(ctx: &ReducerContext, row: ProfileRechargeOrder) { + ctx.db + .profile_recharge_order() + .order_id() + .delete(&row.order_id); + ctx.db.profile_recharge_order().insert(row); +} + +fn latest_profile_recharge_refund_timestamp(current: Timestamp, observed: Timestamp) -> Timestamp { + if observed.to_micros_since_unix_epoch() > current.to_micros_since_unix_epoch() { + observed + } else { + current + } +} + +fn list_profile_recharge_refund_reconciliation_rows( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundReconciliationListInput, +) -> Vec { + let validated = build_runtime_profile_recharge_refund_reconciliation_list_input(input.limit); + let mut candidates = HashMap::::new(); + for status in [ + RuntimeProfileRechargeRefundStatus::Processing, + RuntimeProfileRechargeRefundStatus::Abnormal, + RuntimeProfileRechargeRefundStatus::Success, + ] { + for row in ctx + .db + .profile_recharge_refund() + .by_profile_recharge_refund_status_updated_at() + .filter(status) + { + let needs_reconciliation = profile_recharge_refund_needs_reconciliation(&row); + if needs_reconciliation { + candidates.insert(row.out_refund_no.clone(), row); + } + } + } + let mut rows = candidates.into_values().collect::>(); + rows.sort_by(|left, right| { + left.updated_at + .to_micros_since_unix_epoch() + .cmp(&right.updated_at.to_micros_since_unix_epoch()) + .then_with(|| left.out_refund_no.cmp(&right.out_refund_no)) + }); + let rotation_slot = ctx + .timestamp + .to_micros_since_unix_epoch() + .div_euclid(60 * 1_000_000) + .unsigned_abs(); + select_profile_recharge_refund_reconciliation_page( + rows, + validated.limit as usize, + rotation_slot, + ) + .into_iter() + .map(|row| build_profile_recharge_refund_snapshot_from_row(&row)) + .collect() +} + +fn profile_recharge_refund_needs_reconciliation(row: &ProfileRechargeRefund) -> bool { + if row.provider_status != RuntimeProfileRechargeRefundStatus::Success { + return row.provider_status != RuntimeProfileRechargeRefundStatus::Closed; + } + match row.recovery_status { + RuntimeProfileRechargeRefundRecoveryStatus::Pending + | RuntimeProfileRechargeRefundRecoveryStatus::Shortfall => true, + RuntimeProfileRechargeRefundRecoveryStatus::ManualReview => matches!( + row.last_error_code.as_deref(), + Some("order_missing" | "order_not_paid") + ), + RuntimeProfileRechargeRefundRecoveryStatus::Applied + | RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable => false, + } +} + +fn select_profile_recharge_refund_reconciliation_page( + rows: Vec, + limit: usize, + rotation_slot: u64, +) -> Vec { + select_rotating_reconciliation_page(rows, limit, rotation_slot) +} + +fn select_profile_recharge_refund_hold_reconciliation_page( + rows: Vec, + limit: usize, + rotation_slot: u64, +) -> Vec { + select_rotating_reconciliation_page(rows, limit, rotation_slot) +} + +fn select_rotating_reconciliation_page( + rows: Vec, + limit: usize, + rotation_slot: u64, +) -> Vec { + if rows.len() <= limit || limit == 0 { + return rows; + } + let page_count = rows.len().div_ceil(limit); + let page_index = usize::try_from(rotation_slot % page_count as u64).unwrap_or(0); + rows.into_iter() + .skip(page_index.saturating_mul(limit)) + .take(limit) + .collect() +} + +fn advance_profile_recharge_refund_bill_checkpoint( + ctx: &ReducerContext, + input: RuntimeProfileRechargeRefundBillCheckpointAdvanceInput, +) -> Result { + let validated = build_runtime_profile_recharge_refund_bill_checkpoint_advance_input( + input.checkpoint_id, + input.bill_date, + input.bill_hash, + input.processed_refund_count, + input.completed_at_micros, + )?; + let completed_at = Timestamp::from_micros_since_unix_epoch(validated.completed_at_micros); + if let Some(existing) = ctx + .db + .profile_recharge_refund_bill_checkpoint() + .checkpoint_id() + .find(&validated.checkpoint_id) + { + if validated.bill_date < existing.bill_date { + return Err("退款账单 checkpoint 不能回退".to_string()); + } + if validated.bill_date == existing.bill_date { + if validated.bill_hash != existing.bill_hash + || validated.processed_refund_count != existing.processed_refund_count + { + return Err("同一退款账单日期的 hash 或退款条数冲突".to_string()); + } + return Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&existing)); + } + ctx.db + .profile_recharge_refund_bill_checkpoint() + .checkpoint_id() + .delete(&existing.checkpoint_id); + } + let row = ProfileRechargeRefundBillCheckpoint { + checkpoint_id: validated.checkpoint_id, + bill_date: validated.bill_date, + bill_hash: validated.bill_hash, + processed_refund_count: validated.processed_refund_count, + completed_at, + updated_at: ctx.timestamp, + }; + ctx.db + .profile_recharge_refund_bill_checkpoint() + .insert(row.clone()); + Ok(build_profile_recharge_refund_bill_checkpoint_snapshot_from_row(&row)) +} + fn claim_profile_recharge_order_expiration_schedules( ctx: &ReducerContext, input: RuntimeProfileRechargeOrderExpirationClaimInput, @@ -6932,6 +9079,14 @@ fn apply_profile_wallet_signed_delta( return Ok(profile_wallet_balance(ctx, user_id)); } } + if amount_delta < 0 { + validate_runtime_profile_wallet_debit_restrictions( + amount_delta, + source_type, + has_profile_wallet_manual_restriction(ctx, user_id), + has_profile_recharge_refund_wallet_freeze(ctx, user_id), + )?; + } let current = ctx .db @@ -6939,6 +9094,15 @@ fn apply_profile_wallet_signed_delta( .user_id() .find(&user_id.to_string()); let previous_balance = current.as_ref().map(|row| row.wallet_balance).unwrap_or(0); + if amount_delta < 0 + && source_type != RuntimeProfileWalletLedgerSourceType::RechargeRefundRecovery + { + validate_runtime_profile_wallet_debit_availability( + previous_balance, + active_profile_recharge_refund_hold_points(ctx, user_id), + amount_delta.unsigned_abs(), + )?; + } let next_balance = calculate_runtime_profile_wallet_balance(previous_balance, amount_delta) .map_err(|error| error.to_string())?; let created_state_at = current @@ -7052,7 +9216,10 @@ fn apply_profile_wallet_signed_delta( metadata_json: Some(ledger_metadata_json), }); - Ok(next_balance) + if amount_delta > 0 { + repay_profile_recharge_refund_debt_from_permanent_points(ctx, user_id); + } + Ok(profile_wallet_balance(ctx, user_id)) } fn profile_wallet_ledger_recorded_at( @@ -7088,7 +9255,7 @@ fn has_profile_points_recharged(ctx: &ReducerContext, user_id: &str) -> bool { .any(|row| { row.user_id == user_id && row.kind == RuntimeProfileRechargeProductKind::Points - && row.status == RuntimeProfileRechargeOrderStatus::Paid + && profile_recharge_order_counts_as_paid_purchase(&row) }) } @@ -7101,10 +9268,22 @@ fn has_profile_product_recharged(ctx: &ReducerContext, user_id: &str, product_id row.user_id == user_id && row.product_id == product_id && row.kind == RuntimeProfileRechargeProductKind::Points - && row.status == RuntimeProfileRechargeOrderStatus::Paid + && profile_recharge_order_counts_as_paid_purchase(&row) }) } +fn profile_recharge_order_counts_as_paid_purchase(order: &ProfileRechargeOrder) -> bool { + order.paid_at.is_some() +} + +fn has_profile_recharge_refund_wallet_freeze(ctx: &ReducerContext, user_id: &str) -> bool { + ctx.db + .profile_recharge_order_refund_settlement() + .by_profile_recharge_order_refund_settlement_user_id() + .filter(user_id) + .any(|row| row.unrecovered_points > 0) +} + fn has_profile_business_wallet_ledger(ctx: &ReducerContext, user_id: &str) -> bool { ctx.db .profile_wallet_ledger() @@ -7375,6 +9554,134 @@ fn build_profile_recharge_order_snapshot_from_row( } } +fn build_profile_recharge_refund_snapshot_from_row( + row: &ProfileRechargeRefund, +) -> RuntimeProfileRechargeRefundSnapshot { + RuntimeProfileRechargeRefundSnapshot { + out_refund_no: row.out_refund_no.clone(), + provider_refund_id: row.provider_refund_id.clone(), + order_id: row.order_id.clone(), + provider_transaction_id: row.provider_transaction_id.clone(), + user_id: row.user_id.clone(), + provider_status: row.provider_status, + total_cents: row.total_cents, + refund_cents: row.refund_cents, + payer_total_cents: row.payer_total_cents, + payer_refund_cents: row.payer_refund_cents, + success_at_micros: row + .success_at + .map(|value| value.to_micros_since_unix_epoch()), + first_observed_at_micros: row.first_observed_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + last_observation_source: row.last_observation_source, + last_observation_id: row.last_observation_id.clone(), + order_settled_at_micros: row + .order_settled_at + .map(|value| value.to_micros_since_unix_epoch()), + target_recovery_points: row.target_recovery_points, + recovered_points: row.recovered_points, + unrecovered_points: row.unrecovered_points, + recovery_status: row.recovery_status, + last_recovery_ledger_id: row.last_recovery_ledger_id.clone(), + last_error_code: row.last_error_code.clone(), + } +} + +fn build_profile_recharge_refund_hold_snapshot_from_row( + row: &ProfileRechargeRefundHold, +) -> RuntimeProfileRechargeRefundHoldSnapshot { + RuntimeProfileRechargeRefundHoldSnapshot { + out_refund_no: row.out_refund_no.clone(), + order_id: row.order_id.clone(), + user_id: row.user_id.clone(), + refund_cents: row.refund_cents, + held_points: row.held_points, + status: row.status, + admin_user_id: row.admin_user_id.clone(), + reason: row.reason.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + settled_at_micros: row + .settled_at + .map(|value| value.to_micros_since_unix_epoch()), + released_at_micros: row + .released_at + .map(|value| value.to_micros_since_unix_epoch()), + released_by_admin_user_id: row.released_by_admin_user_id.clone(), + release_reason: row.release_reason.clone(), + } +} + +fn build_profile_wallet_manual_restriction_snapshot_from_row( + row: &ProfileWalletManualRestriction, +) -> RuntimeProfileWalletManualRestrictionSnapshot { + RuntimeProfileWalletManualRestrictionSnapshot { + user_id: row.user_id.clone(), + frozen: row.frozen, + reason: row.reason.clone(), + created_by_admin_user_id: row.created_by_admin_user_id.clone(), + created_at_micros: row.created_at.to_micros_since_unix_epoch(), + updated_by_admin_user_id: row.updated_by_admin_user_id.clone(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +#[cfg(test)] +fn build_profile_recharge_refund_observation_snapshot_from_row( + row: &ProfileRechargeRefundObservation, +) -> RuntimeProfileRechargeRefundObservationSnapshot { + RuntimeProfileRechargeRefundObservationSnapshot { + observation_id: row.observation_id.clone(), + out_refund_no: row.out_refund_no.clone(), + provider_refund_id: row.provider_refund_id.clone(), + order_id: row.order_id.clone(), + provider_transaction_id: row.provider_transaction_id.clone(), + source: row.source, + provider_status: row.provider_status, + total_cents: row.total_cents, + refund_cents: row.refund_cents, + payer_total_cents: row.payer_total_cents, + payer_refund_cents: row.payer_refund_cents, + success_at_micros: row + .success_at + .map(|value| value.to_micros_since_unix_epoch()), + notification_ref: row.notification_ref.clone(), + payload_fingerprint: row.payload_fingerprint.clone(), + resolution_code: row.resolution_code.clone(), + observed_at_micros: row.observed_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_recharge_order_refund_settlement_snapshot_from_row( + row: &ProfileRechargeOrderRefundSettlement, +) -> RuntimeProfileRechargeOrderRefundSettlementSnapshot { + RuntimeProfileRechargeOrderRefundSettlementSnapshot { + order_id: row.order_id.clone(), + user_id: row.user_id.clone(), + successful_refund_count: row.successful_refund_count, + cumulative_success_refund_cents: row.cumulative_success_refund_cents, + target_recovery_points: row.target_recovery_points, + recovered_points: row.recovered_points, + unrecovered_points: row.unrecovered_points, + recovery_status: row.recovery_status, + wallet_frozen: row.wallet_frozen, + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + +fn build_profile_recharge_refund_bill_checkpoint_snapshot_from_row( + row: &ProfileRechargeRefundBillCheckpoint, +) -> RuntimeProfileRechargeRefundBillCheckpointSnapshot { + RuntimeProfileRechargeRefundBillCheckpointSnapshot { + checkpoint_id: row.checkpoint_id.clone(), + bill_date: row.bill_date.clone(), + bill_hash: row.bill_hash.clone(), + processed_refund_count: row.processed_refund_count, + completed_at_micros: row.completed_at.to_micros_since_unix_epoch(), + updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), + } +} + fn build_profile_recharge_order_expiration_schedule_snapshot_from_row( row: &ProfileRechargeOrderExpirationSchedule, ) -> RuntimeProfileRechargeOrderExpirationScheduleSnapshot { diff --git a/src/components/platform-entry/usePlatformProfileCenterController.ts b/src/components/platform-entry/usePlatformProfileCenterController.ts index de10fea72..dafbd71c9 100644 --- a/src/components/platform-entry/usePlatformProfileCenterController.ts +++ b/src/components/platform-entry/usePlatformProfileCenterController.ts @@ -15,7 +15,7 @@ import { clearStoredAccessToken, refreshStoredAccessToken, } from '../../services/apiClient'; -import { type AuthUser,startWechatBind } from '../../services/authService'; +import { type AuthUser, startWechatBind } from '../../services/authService'; import { getHostRuntime, requestHostLogin, @@ -52,6 +52,7 @@ const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000; const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000; const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const; const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const; +const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000; const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const; const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250; const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000; @@ -68,7 +69,11 @@ type WechatPayResult = { }; type RechargePaymentResultKind = - 'success' | 'pending' | 'cancel' | 'failed' | 'expired'; + | 'success' + | 'pending' + | 'cancel' + | 'failed' + | 'expired'; export type RechargePaymentResult = { kind: RechargePaymentResultKind; @@ -774,6 +779,12 @@ export function usePlatformProfileCenterController({ ); void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId) .then((response) => { + if ( + pendingWechatRechargeOrderIdRef.current !== + nativeWechatPayment.orderId + ) { + return; + } const result = buildRechargePaymentResultForOrder(response.order); const isPaid = result.kind === 'success'; setRechargeCenter(response.center); @@ -810,6 +821,70 @@ export function usePlatformProfileCenterController({ .finally(() => setSubmittingRechargeProductId(null)); }, [nativeWechatPayment, onRechargeSuccess]); + useEffect(() => { + const orderId = nativeWechatPayment?.orderId; + const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? ''); + if (!orderId || !Number.isFinite(expiresAtMs)) { + return undefined; + } + + let cancelled = false; + const abortController = new AbortController(); + const watchUntilSettled = async () => { + while (!cancelled && Date.now() < expiresAtMs) { + try { + const response = await watchWechatRpgProfileRechargeOrder(orderId, { + signal: abortController.signal, + }); + if ( + cancelled || + !response || + pendingWechatRechargeOrderIdRef.current !== orderId + ) { + return; + } + + const result = buildRechargePaymentResultForOrder(response.order); + setRechargeCenter(response.center); + if (result.kind === 'pending') { + await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + continue; + } + + pendingWechatRechargeOrderIdRef.current = null; + if (confirmingWechatRechargeOrderIdRef.current === orderId) { + confirmingWechatRechargeOrderIdRef.current = null; + } + setNativeWechatPayment((current) => + current?.orderId === orderId ? null : current, + ); + setSubmittingRechargeProductId(null); + setRechargePaymentResult(result); + if (result.kind === 'success') { + void onRechargeSuccess?.(); + } + return; + } catch { + if (cancelled || abortController.signal.aborted) { + return; + } + } + + await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS); + } + }; + + void watchUntilSettled(); + return () => { + cancelled = true; + abortController.abort(); + }; + }, [ + nativeWechatPayment?.expiresAt, + nativeWechatPayment?.orderId, + onRechargeSuccess, + ]); + // 中文注释:H5 / 小程序支付返回页、页面恢复和 hash 轮询都统一走同一套到账确认逻辑, // 避免页面组件自己感知微信支付细节。 useEffect(() => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index dbdbf58db..336dded05 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -386,7 +386,10 @@ const { updatedAt: '2026-06-19T10:00:00Z', }, })), - mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null), + mockWatchWechatRpgProfileRechargeOrder: vi.fn( + () => + new Promise(() => undefined), + ), }; }); @@ -2649,6 +2652,99 @@ test('profile native qr confirmation refreshes only after server reports paid', expect(onRechargeSuccess).toHaveBeenCalledTimes(1); }); +test('profile native qr closes automatically when the order stream reports paid', async () => { + const user = userEvent.setup(); + const onRechargeSuccess = vi.fn(); + let resolveOrderWatch!: ( + response: ConfirmWechatProfileRechargeOrderResponse, + ) => void; + mockDesktopLayout(); + mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({ + order: { + orderId: 'order-native-auto-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'pending' as const, + paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', + paidAt: null, + providerTransactionId: null, + pointsDelta: 0, + membershipExpiresAt: null, + }, + center: { + walletBalance: 0, + membership: buildNormalMembership(), + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: false, + }, + wechatNativePayment: { + codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-auto-paid', + expiresAt: '2099-01-01T00:05:00Z', + }, + }); + mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce( + new Promise((resolve) => { + resolveOrderWatch = resolve; + }), + ); + + renderProfileView(onRechargeSuccess); + const shortcutRegion = screen.getByRole('region', { name: '常用功能' }); + await user.click( + within(shortcutRegion).getByRole('button', { name: /充值/u }), + ); + await user.click(await screen.findByRole('button', { name: /60泥点/u })); + + expect( + await screen.findByRole('dialog', { name: '微信扫码支付' }), + ).toBeTruthy(); + await waitFor(() => { + expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-native-auto-paid', + { signal: expect.any(AbortSignal) }, + ); + }); + + act(() => { + resolveOrderWatch({ + order: { + orderId: 'order-native-auto-paid', + productId: 'points_60', + productTitle: '60泥点', + kind: 'points', + amountCents: 600, + status: 'paid', + paymentChannel: 'wechat_native', + createdAt: '2026-04-25T10:00:00Z', + paidAt: '2026-04-25T10:01:00Z', + providerTransactionId: 'wx-native-auto-1', + pointsDelta: 60, + membershipExpiresAt: null, + }, + center: { + walletBalance: 60, + membership: buildNormalMembership(), + pointProducts: [], + membershipProducts: [], + benefits: [], + latestOrder: null, + hasPointsRecharged: true, + }, + }); + }); + + expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull(); + expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); + expect(onRechargeSuccess).toHaveBeenCalledTimes(1); +}); + test('profile native qr confirmation closes qr dialog when order is expired', async () => { const user = userEvent.setup(); const onRechargeSuccess = vi.fn(); @@ -2907,11 +3003,12 @@ test('profile native qr confirmation keeps qr dialog when payment is still pendi }, ), ).toBeTruthy(); - expect( - screen.getByRole('dialog', { name: '微信扫码支付' }), - ).toBeTruthy(); + expect(screen.getByRole('dialog', { name: '微信扫码支付' })).toBeTruthy(); expect(screen.queryByRole('dialog', { name: '支付处理中' })).toBeNull(); - expect(mockWatchWechatRpgProfileRechargeOrder).not.toHaveBeenCalled(); + expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith( + 'order-native-pending', + { signal: expect.any(AbortSignal) }, + ); expect(onRechargeSuccess).not.toHaveBeenCalled(); }); From 3861c9c99ac3985827f87b0c8283095f3b88ee98 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 13:39:20 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E9=A2=84=E8=A7=88=E8=BF=9E=E7=BB=AD=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按视口懒加载并错峰执行素材换签请求 增加限流退避与卸载清理,避免后续素材永久占位 补充滚动、分页、大批量排队和缩略图路径回归测试 记录后台素材换签限流排障与验证口径 --- .../pages/AdminEditorAssetQueryPage.test.tsx | 441 ++++++++++++++++-- .../src/pages/AdminEditorAssetQueryPage.tsx | 168 ++++++- docs/project-memory/shared-memory/pitfalls.md | 8 + 3 files changed, 562 insertions(+), 55 deletions(-) diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx index 5786bc43c..ccabfe74d 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx @@ -1,6 +1,7 @@ /* @vitest-environment jsdom */ import { + act, fireEvent, render, screen, @@ -8,7 +9,7 @@ import { within, } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, expect, test, vi } from 'vitest'; import { getAdminAssetReadUrl, @@ -19,9 +20,92 @@ import { AdminEditorAssetQueryPage } from './AdminEditorAssetQueryPage'; vi.mock('../api/adminApiClient', () => ({ getAdminAssetReadUrl: vi.fn(), + isAdminApiError: vi.fn( + (error: unknown) => + typeof error === 'object' && + error !== null && + 'status' in error && + typeof error.status === 'number', + ), listAdminEditorAssets: vi.fn(), })); +interface MockIntersectionObserverController { + enter: (target: Element) => void; + isObserved: (target: Element) => boolean; +} + +function installIntersectionObserverMock(): MockIntersectionObserverController { + const observed = new Map< + Element, + { + callback: IntersectionObserverCallback; + observer: IntersectionObserver; + } + >(); + + class MockIntersectionObserver implements IntersectionObserver { + readonly root = null; + readonly rootMargin: string; + readonly thresholds = [0]; + private readonly targets = new Set(); + + constructor( + private readonly callback: IntersectionObserverCallback, + options: IntersectionObserverInit = {}, + ) { + this.rootMargin = options.rootMargin ?? '0px'; + } + + observe(target: Element) { + this.targets.add(target); + observed.set(target, { + callback: this.callback, + observer: this as unknown as IntersectionObserver, + }); + } + + unobserve(target: Element) { + this.targets.delete(target); + observed.delete(target); + } + + disconnect() { + this.targets.forEach((target) => observed.delete(target)); + this.targets.clear(); + } + + takeRecords() { + return []; + } + } + + vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); + + return { + enter(target) { + const record = observed.get(target); + if (!record) { + throw new Error('目标缩略图尚未进入 IntersectionObserver'); + } + act(() => { + record.callback( + [ + { + isIntersecting: true, + target, + } as IntersectionObserverEntry, + ], + record.observer, + ); + }); + }, + isObserved(target) { + return observed.has(target); + }, + }; +} + const generatedAsset: AdminEditorAssetPayload = { assetId: 'asset-1', ownerUserId: 'user-1', @@ -65,6 +149,31 @@ beforeEach(() => { }); }); +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +function generatedAssetAt(index: number): AdminEditorAssetPayload { + const objectKey = `generated-character-drafts/editor/spec-${index}.png`; + return { + ...generatedAsset, + assetId: `asset-${index}`, + label: `角色形象 ${index}`, + imageSrc: `/${objectKey}`, + objectKey, + }; +} + +function thumbnailElementForLabel(label: string) { + const row = screen.getByText(label).closest('tr'); + const thumbnail = row?.querySelector('.admin-asset-query-thumb'); + if (!thumbnail) { + throw new Error(`未找到素材缩略图:${label}`); + } + return thumbnail; +} + test('后台素材查询展示作者昵称和陶泥号', async () => { render( , @@ -134,6 +243,236 @@ test('后台素材查询缩略图使用 objectKey 换签后展示', async () => }); }); +test('后台素材查询只为进入可视区域的缩略图换签并能展示后续行', async () => { + const observer = installIntersectionObserverMock(); + const entries = [ + generatedAssetAt(1), + generatedAssetAt(2), + generatedAssetAt(3), + ]; + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries, + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + + expect(await screen.findByText('角色形象 3')).toBeTruthy(); + const firstThumbnail = thumbnailElementForLabel('角色形象 1'); + const laterThumbnail = thumbnailElementForLabel('角色形象 3'); + await waitFor(() => { + expect(observer.isObserved(firstThumbnail)).toBe(true); + expect(observer.isObserved(laterThumbnail)).toBe(true); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + observer.enter(laterThumbnail); + + const laterImage = await screen.findByRole('img', { + name: '素材:角色形象 3', + }); + expect(laterImage.getAttribute('src')).toBe( + 'https://signed.example.com/generated-character-drafts/editor/spec-3.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); + expect(getAdminAssetReadUrl).toHaveBeenLastCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor/spec-3.png', + expireSeconds: 300, + }); + + observer.enter(firstThumbnail); + expect( + await screen.findByRole('img', { name: '素材:角色形象 1' }), + ).toBeTruthy(); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2); +}); + +test('后台素材查询为大量同时可见的缩略图持续错峰换签', async () => { + vi.useFakeTimers(); + const observer = installIntersectionObserverMock(); + const entries = Array.from({ length: 105 }, (_, index) => + generatedAssetAt(index + 1), + ); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries, + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + + const thumbnails = entries.map((entry) => + thumbnailElementForLabel(entry.label), + ); + thumbnails.forEach((thumbnail) => { + expect(observer.isObserved(thumbnail)).toBe(true); + observer.enter(thumbnail); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(39); + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(3_961); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(100); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(105); +}); + +test('后台素材查询读取更多后为新进入可视区域的素材换签', async () => { + const observer = installIntersectionObserverMock(); + const firstEntry = generatedAssetAt(1); + const nextEntry = generatedAssetAt(81); + vi.mocked(listAdminEditorAssets) + .mockResolvedValueOnce({ + entries: [firstEntry], + nextCursor: 'cursor-1', + }) + .mockResolvedValueOnce({ + entries: [nextEntry], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => ({ + read: { + objectKey: request.objectKey ?? '', + signedUrl: `https://signed.example.com/${request.objectKey}`, + expiresAt: '2026-07-04T11:00:00Z', + }, + }), + ); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '读取更多' })); + expect(await screen.findByText('角色形象 81')).toBeTruthy(); + const nextThumbnail = thumbnailElementForLabel('角色形象 81'); + await waitFor(() => expect(observer.isObserved(nextThumbnail)).toBe(true)); + expect(getAdminAssetReadUrl).not.toHaveBeenCalled(); + + observer.enter(nextThumbnail); + + expect( + await screen.findByRole('img', { name: '素材:角色形象 81' }), + ).toBeTruthy(); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + objectKey: 'generated-character-drafts/editor/spec-81.png', + expireSeconds: 300, + }); + expect(listAdminEditorAssets).toHaveBeenLastCalledWith('admin-token', { + ownerUserId: null, + keyword: null, + createdAfter: null, + createdBefore: null, + limit: 80, + cursor: 'cursor-1', + }); +}); + +test('后台素材查询缩略图换签被限流后有限重试并恢复', async () => { + const observer = installIntersectionObserverMock(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl) + .mockRejectedValueOnce({ status: 429 }) + .mockResolvedValueOnce({ + read: { + objectKey: generatedAsset.objectKey ?? '', + signedUrl: + 'https://signed.example.com/generated-character-drafts/editor/spec.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + expect(await screen.findByText('角色形象 1')).toBeTruthy(); + const thumbnail = thumbnailElementForLabel('角色形象 1'); + await waitFor(() => expect(observer.isObserved(thumbnail)).toBe(true)); + observer.enter(thumbnail); + + const image = await screen.findByRole( + 'img', + { name: '素材:角色形象 1' }, + { timeout: 2_000 }, + ); + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/generated-character-drafts/editor/spec.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(2); +}); + +test('后台素材查询缩略图卸载后停止限流重试', async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date()); + const observer = installIntersectionObserverMock(); + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [generatedAsset], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockRejectedValue({ status: 429 }); + + const { unmount } = render( + , + ); + await act(async () => { + await Promise.resolve(); + }); + const thumbnail = thumbnailElementForLabel('角色形象 1'); + expect(observer.isObserved(thumbnail)).toBe(true); + observer.enter(thumbnail); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); + + unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(5_000); + }); + expect(getAdminAssetReadUrl).toHaveBeenCalledTimes(1); +}); + test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展示', async () => { vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ entries: [ @@ -170,6 +509,42 @@ test('后台素材查询将无 objectKey 的绝对 OSS 图片地址换签后展 }); }); +test('后台素材查询为独立缩略图路径换签而不误用原图 objectKey', async () => { + vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({ + entries: [ + { + ...generatedAsset, + thumbnailSrc: '/generated-character-drafts/editor/spec-thumbnail.png', + }, + ], + nextCursor: null, + }); + vi.mocked(getAdminAssetReadUrl).mockResolvedValue({ + read: { + objectKey: 'generated-character-drafts/editor/spec-thumbnail.png', + signedUrl: 'https://signed.example.com/spec-thumbnail.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }); + + render( + , + ); + + const image = await screen.findByRole('img', { name: '素材:角色形象 1' }); + expect(image.getAttribute('src')).toBe( + 'https://signed.example.com/spec-thumbnail.png', + ); + expect(getAdminAssetReadUrl).toHaveBeenCalledWith('admin-token', { + legacyPublicPath: '/generated-character-drafts/editor/spec-thumbnail.png', + expireSeconds: 300, + }); + expect(getAdminAssetReadUrl).not.toHaveBeenCalledWith('admin-token', { + objectKey: generatedAsset.objectKey, + expireSeconds: 300, + }); +}); + test('后台素材查询点击图片缩略图可打开放大预览', async () => { const user = userEvent.setup(); @@ -182,7 +557,7 @@ test('后台素材查询点击图片缩略图可打开放大预览', async () => ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = within(dialog).getByRole('img', { + const image = await within(dialog).findByRole('img', { name: '图片预览:角色形象 1', }); await waitFor(() => { @@ -226,7 +601,7 @@ test('后台素材查询将角色动画首帧 PNG 作为图片预览', async () ); const dialog = await screen.findByRole('dialog', { name: '素材预览' }); - const image = within(dialog).getByRole('img', { + const image = await within(dialog).findByRole('img', { name: '图片预览:角色动作首帧', }); await waitFor(() => { @@ -325,27 +700,29 @@ test('后台素材查询视频缩略图使用封面并在预览中播放视频', ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { - if ('legacyPublicPath' in request) { + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + if ('legacyPublicPath' in request) { + return { + read: { + objectKey: 'generated-editor-videos/task-1/cover.png', + signedUrl: 'https://signed.example.com/video-cover.png', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + } + const objectKey = request.objectKey ?? ''; return { read: { - objectKey: 'generated-editor-videos/task-1/cover.png', - signedUrl: 'https://signed.example.com/video-cover.png', + objectKey, + signedUrl: objectKey.endsWith('.mp4') + ? 'https://signed.example.com/video-preview.mp4' + : 'https://signed.example.com/video-cover-by-key.png', expiresAt: '2026-07-04T11:00:00Z', }, }; - } - const objectKey = request.objectKey ?? ''; - return { - read: { - objectKey, - signedUrl: objectKey.endsWith('.mp4') - ? 'https://signed.example.com/video-preview.mp4' - : 'https://signed.example.com/video-cover-by-key.png', - expiresAt: '2026-07-04T11:00:00Z', - }, - }; - }); + }, + ); render( , @@ -399,18 +776,20 @@ test('后台素材查询将无 objectKey 的绝对 OSS 视频和封面分别换 ], nextCursor: null, }); - vi.mocked(getAdminAssetReadUrl).mockImplementation(async (_token, request) => { - const legacyPublicPath = request.legacyPublicPath ?? ''; - return { - read: { - objectKey: legacyPublicPath.replace(/^\//u, ''), - signedUrl: legacyPublicPath.endsWith('/cover.png') - ? 'https://signed.example.com/video-2-cover.png' - : 'https://signed.example.com/video-2-preview.mp4', - expiresAt: '2026-07-04T11:00:00Z', - }, - }; - }); + vi.mocked(getAdminAssetReadUrl).mockImplementation( + async (_token, request) => { + const legacyPublicPath = request.legacyPublicPath ?? ''; + return { + read: { + objectKey: legacyPublicPath.replace(/^\//u, ''), + signedUrl: legacyPublicPath.endsWith('/cover.png') + ? 'https://signed.example.com/video-2-cover.png' + : 'https://signed.example.com/video-2-preview.mp4', + expiresAt: '2026-07-04T11:00:00Z', + }, + }; + }, + ); render( , diff --git a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx index 25b9e17bc..f012d54fe 100644 --- a/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx +++ b/apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx @@ -1,9 +1,10 @@ import { Eye, FileText, RefreshCcw, X } from 'lucide-react'; import type { ReactNode } from 'react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { getAdminAssetReadUrl, + isAdminApiError, listAdminEditorAssets, } from '../api/adminApiClient'; import type { AdminAssetReadUrlResponse } from '../api/adminApiClient'; @@ -19,7 +20,11 @@ interface AdminEditorAssetQueryPageProps { } const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300; +const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40; +const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const; +const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px'; const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`; +let adminAssetReadDispatchTail = Promise.resolve(); export function AdminEditorAssetQueryPage({ token, @@ -295,17 +300,27 @@ function AdminAssetThumbnail({ token: string; }) { const thumbnailSource = resolveAdminAssetThumbnailSource(entry); + const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility(); const imageSrc = useAdminResolvedAssetUrl( token, thumbnailSource.src, thumbnailSource.objectKey, + shouldLoad, ); const alt = `素材:${entry.label || entry.assetId}`; return imageSrc ? ( - {alt} + {alt} ) : ( -
+
); } @@ -317,12 +332,52 @@ function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) { if (mediaKind === 'video') { return { src: entry.thumbnailSrc || '', objectKey: null }; } + if (entry.thumbnailSrc?.trim()) { + return { + src: entry.thumbnailSrc, + objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc) + ? entry.objectKey + : null, + }; + } return { - src: entry.thumbnailSrc || entry.imageSrc, + src: entry.imageSrc, objectKey: entry.objectKey, }; } +function useAdminAssetThumbnailVisibility() { + const [element, setElement] = useState(null); + const [shouldLoad, setShouldLoad] = useState(false); + const observeElement = useCallback((nextElement: HTMLElement | null) => { + setElement(nextElement); + }, []); + + useEffect(() => { + if (shouldLoad || !element) { + return; + } + if (typeof IntersectionObserver === 'undefined') { + setShouldLoad(true); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setShouldLoad(true); + observer.disconnect(); + } + }, + { rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN }, + ); + observer.observe(element); + return () => observer.disconnect(); + }, [element, shouldLoad]); + + return { observeElement, shouldLoad }; +} + type AdminAssetMediaKind = 'image' | 'audio' | 'video'; function resolveAdminAssetMediaKind( @@ -604,6 +659,7 @@ function useAdminResolvedAssetUrl( token: string, imageSrc: string | null | undefined, objectKey: string | null | undefined, + enabled = true, ) { const normalizedImageSrc = imageSrc?.trim() ?? ''; const normalizedObjectKey = normalizeAdminObjectKey(objectKey); @@ -625,38 +681,67 @@ function useAdminResolvedAssetUrl( setResolvedImageSrc(normalizedImageSrc); return; } + if (!enabled) { + setResolvedImageSrc(''); + return; + } let cancelled = false; + let retryTimer: ReturnType | null = null; + let retryIndex = 0; + const dispatchController = new AbortController(); setResolvedImageSrc(''); - void getAdminAssetReadUrl( - token, - normalizedObjectKey - ? { - objectKey: normalizedObjectKey, - expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, - } - : { - legacyPublicPath: normalizedLegacyPublicPath, - expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, - }, - ) - .then(resolveAdminAssetReadSignedUrl) - .then((signedUrl) => { - if (!cancelled) { - setResolvedImageSrc(signedUrl); + const resolveReadUrl = async () => { + try { + await waitForAdminAssetReadDispatch(dispatchController.signal); + if (cancelled) { + return; } - }) - .catch(() => { + const response = await getAdminAssetReadUrl( + token, + normalizedObjectKey + ? { + objectKey: normalizedObjectKey, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + } + : { + legacyPublicPath: normalizedLegacyPublicPath, + expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS, + }, + ); if (!cancelled) { - setResolvedImageSrc(''); + setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response)); } - }); + } catch (error: unknown) { + if (cancelled) { + return; + } + const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex]; + if ( + isAdminApiError(error) && + error.status === 429 && + typeof retryDelay === 'number' + ) { + retryIndex += 1; + retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay); + return; + } + setResolvedImageSrc(''); + } + }; + + void resolveReadUrl(); return () => { cancelled = true; + dispatchController.abort(); + if (retryTimer !== null) { + clearTimeout(retryTimer); + } }; }, [ + enabled, normalizedImageSrc, normalizedLegacyPublicPath, normalizedObjectKey, @@ -667,10 +752,45 @@ function useAdminResolvedAssetUrl( return resolvedImageSrc; } +async function waitForAdminAssetReadDispatch(signal: AbortSignal) { + const dispatch = adminAssetReadDispatchTail.then( + () => waitForAdminAssetReadDispatchSpacing(signal), + () => waitForAdminAssetReadDispatchSpacing(signal), + ); + adminAssetReadDispatchTail = dispatch.catch(() => undefined); + await dispatch; +} + +async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) { + if (signal.aborted) { + throw new DOMException('The operation was aborted.', 'AbortError'); + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', handleAbort); + resolve(); + }, ADMIN_ASSET_READ_DISPATCH_SPACING_MS); + + function handleAbort() { + clearTimeout(timer); + reject(new DOMException('The operation was aborted.', 'AbortError')); + } + + signal.addEventListener('abort', handleAbort, { once: true }); + }); +} + function normalizeAdminObjectKey(value: string | null | undefined) { return value?.trim().replace(/^\/+/u, '') ?? ''; } +function adminAssetPathsMatch(left: string, right: string) { + return ( + left.trim().replace(/^\/+|[?#].*$/gu, '') === + right.trim().replace(/^\/+|[?#].*$/gu, '') + ); +} + function isGeneratedLegacyPath(value: string) { return /^\/?generated-[^/?#]+\/.+/u.test(value.trim()); } diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 7b76e0806..7b3ed4179 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -47,6 +47,14 @@ - 验证:`cargo check -p spacetime-client --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:spacetime-schema`。 - 关联:`server-rs/crates/spacetime-module/src/editor_project_storage.rs`、`server-rs/crates/spacetime-client/src/editor_project.rs`、`server-rs/crates/api-server/src/admin.rs`。 +## 后台素材缩略图不要在首次挂载时全量换签 + +- 现象:后台“素材查询”首批缩略图正常,继续向下滚动或读取更多后长期显示占位图;api-server journald 中已到达的 `/admin/api/assets/read-url` 可能全部是 `200`。 +- 原因:列表一次挂载 80 条私有素材时,每个缩略图同时换签,会在同秒突发请求。production Nginx 的 `genarrative_admin_rps` 为 `30r/s burst=16`,超出部分在进入 api-server 前已返回 `429`,因此仅查 api-server 日志会漏掉失败请求。 +- 处理:缩略图使用 `IntersectionObserver` 在进入视口附近时再调用管理端换签;对 `429` 使用有上限的退避重试,并在条目卸载后停止更新状态和安排重试。不得为单页突发放大 Nginx 通用管理端限流,也不得在单次限流失败后永久保留无图占位。 +- 验证:前端定向测试覆盖首屏外的后续行进入可见区后才换签、“读取更多”追加行可继续显示缩略图、`429` 后有限重试恢复、卸载后不再重试;真实浏览器滚动验收时同时核对 Nginx access/error log、api-server journald 和 Network 面板,不以单一日志面判定成功。 +- 关联:`apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`、`apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx`。 + ## 陶泥儿精选重复先查同源同媒体画布副本 - 现象:每次从项目素材中把同一个生成素材拖到画布上,`陶泥儿精选` 都多出一张看起来相同的素材。 From aa56c117ece666a4068fe146433c1cd6bab731f2 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 15:48:30 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E5=8A=A8=E4=BD=9C=E7=B4=A0=E6=9D=90=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将最终角色动作帧序列保存为单条素材并保留完整元数据 登记全部动作帧的私有资产对象并以首帧作为预览 补充动作素材持久化测试和后端数据契约 --- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- .../src/character_animation_assets.rs | 248 +++++++++++++++++- 2 files changed, 237 insertions(+), 13 deletions(-) diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 594149724..bf01b3157 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -543,7 +543,7 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorAsset` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 +- 说明:图片画布账号级素材表,保存用户上传 / 生成素材的名称、文件夹、图片读取地址、可选封面 `thumbnail_src`、OSS 引用、尺寸、来源类型、prompt、provider、task、`asset_kind`、`generation_inputs_json`、可选 `source_resource_id` 和 `generation_cost_mud_points`。素材在同一账号的所有项目中可见;图片 / 图标 / UI 提取等生成 BFF 在请求携带 `asset_folder_id` 时负责创建账号级生成素材并返回 asset 快照,若同次生成也创建了 `editor_project_resource`,则把该 `resource_id` 写入 `source_resource_id`。角色动作生成保留原始绿幕视频中间素材,同时把最终帧序列作为一条 `asset_kind = character-animation` 素材入库:首帧写入 `image_src` / `thumbnail_src`,完整帧列表、FPS、时长和预览视频写入 `generation_inputs_json.characterAnimation`,不把每帧拆成独立素材。生成视频会抽取首帧封面写入 `thumbnail_src`,素材库和再次放入画布时用它作为 video poster。素材库快照通过 `asset_id` 回查对应 `editor_showcase_asset`,供左侧素材菜单展示 `pending` / `approved` / `rejected` 审核状态;公开事实不落在账号素材表,素材库只发起提交审核。素材放入画布时复制为 `editor_project_resource` 并由图层引用 resourceId,画布从 resource / asset 级元数据恢复素材类别和用户可见生成输入快照。 - 索引:`by_editor_asset_owner_user_id`、`by_editor_asset_folder_id`。 ### `editor_showcase_asset` diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 391e33d95..f4c28f170 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -64,16 +64,16 @@ use crate::{ EditorScreenBackgroundColor, editor_green_screen_character_prompt_clause, remove_editor_generated_green_screen_background, }, - editor_screen_background_decision::{ - EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, - resolve_editor_screen_background_color, - }, editor_project::{ EditorCanvasGeneratedLayerInput, PersistEditorGeneratedAssetRequest, apply_editor_screen_background_decision_to_generation_inputs, build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items, persist_editor_generated_media_asset, }, + editor_screen_background_decision::{ + EditorScreenBackgroundDecisionInput, EditorScreenBackgroundDecisionKind, + resolve_editor_screen_background_color, + }, http_error::AppError, openai_image_generation::DownloadedOpenAiImage, platform_errors::map_oss_error, @@ -763,7 +763,7 @@ pub(crate) async fn generate_editor_character_animation_for_owner( }, ) .await?; - let frames = extract_and_persist_editor_character_animation_frames( + let persisted_frames = extract_and_persist_editor_character_animation_frames( &state, owner_user_id.as_str(), normalized.source_layer_id.as_str(), @@ -774,6 +774,47 @@ pub(crate) async fn generate_editor_character_animation_for_owner( &matting_audit, ) .await?; + let frames = persisted_frames.frames; + let generation_inputs = attach_editor_character_animation_result_metadata( + generation_inputs, + generated.preview_video_path.as_str(), + frames.as_slice(), + normalized.fps, + normalized.duration_seconds, + ); + let first_frame = frames.first().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有可保存的序列帧。", + })) + })?; + persist_editor_generated_media_asset( + &state, + PersistEditorGeneratedAssetRequest { + project_id: None, + owner_user_id: owner_user_id.clone(), + folder_id: asset_folder_id + .clone() + .or_else(|| Some("project".to_string())), + label: asset_label.clone(), + image_src: first_frame.image_src.clone(), + object_key: Some(persisted_frames.first_frame_object_key), + asset_object_id: Some(persisted_frames.first_frame_asset_object_id), + width: first_frame.width, + height: first_frame.height, + prompt: normalized.prompt.clone(), + actual_prompt: Some(generated.submitted_prompt.clone()), + model: EDITOR_CHARACTER_ANIMATION_MODEL.to_string(), + provider: "Ark".to_string(), + task_id: task_id.clone(), + source_resource_id: None, + asset_kind: Some("character-animation".to_string()), + generation_inputs: generation_inputs.clone(), + thumbnail_src: Some(first_frame.image_src.clone()), + generation_cost_mud_points: u64::from(normalized.price_mud_points), + }, + ) + .await?; Ok::<_, AppError>((generated, frames, normalized, generation_inputs)) }, @@ -2214,7 +2255,7 @@ async fn extract_and_persist_editor_character_animation_frames( request: &NormalizedEditorCharacterAnimationRequest, extraction_settings: &BackendFrameExtractionSettings, audit: &crate::external_api_audit::ExternalApiAuditContext, -) -> Result, AppError> { +) -> Result { let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, apply_chroma_key: false, @@ -2250,7 +2291,9 @@ async fn extract_and_persist_editor_character_animation_frames( .await?; let mut frame_payloads = Vec::with_capacity(finalized_frames.len()); + let mut first_frame_object = None; for (index, frame) in finalized_frames.into_iter().enumerate() { + let content_type = frame.mime_type.clone(); let put_result = put_character_animation_object( state, LegacyAssetPrefix::Animations, @@ -2272,6 +2315,21 @@ async fn extract_and_persist_editor_character_animation_frames( ), ) .await?; + let confirmed = confirm_editor_character_animation_frame_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + put_result.object_key.clone(), + content_type, + ) + .await?; + if index == 0 { + first_frame_object = Some(( + put_result.object_key.clone(), + confirmed.record.asset_object_id, + )); + } frame_payloads.push(EditorCharacterAnimationFramePayload { frame_index: index as u32 + 1, image_src: put_result.legacy_public_path, @@ -2280,7 +2338,18 @@ async fn extract_and_persist_editor_character_animation_frames( }); } - Ok(frame_payloads) + let (first_frame_object_key, first_frame_asset_object_id) = + first_frame_object.ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-character-animation", + "message": "角色动作抽帧完成但没有生成首帧对象。", + })) + })?; + Ok(PersistedEditorCharacterAnimationFrameSet { + frames: frame_payloads, + first_frame_object_key, + first_frame_asset_object_id, + }) } async fn persist_editor_character_animation_green_screen_source_frames( @@ -2742,6 +2811,47 @@ async fn confirm_editor_character_animation_source_asset_object( task_id: &str, object_key: String, content_type: String, +) -> Result { + confirm_editor_character_animation_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + object_key, + content_type, + EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND, + ) + .await +} + +async fn confirm_editor_character_animation_frame_asset_object( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + object_key: String, + content_type: String, +) -> Result { + confirm_editor_character_animation_asset_object( + state, + owner_user_id, + source_layer_id, + task_id, + object_key, + content_type, + EDITOR_CHARACTER_ANIMATION_ASSET_KIND, + ) + .await +} + +async fn confirm_editor_character_animation_asset_object( + state: &AppState, + owner_user_id: &str, + source_layer_id: &str, + task_id: &str, + object_key: String, + content_type: String, + asset_kind: &str, ) -> Result { let oss_client = require_oss_client(state)?; let head = oss_client @@ -2760,7 +2870,7 @@ async fn confirm_editor_character_animation_source_asset_object( head.content_type.or(Some(content_type)), head.content_length, head.etag, - EDITOR_GREEN_SCREEN_SOURCE_ASSET_KIND.to_string(), + asset_kind.to_string(), Some(task_id.to_string()), Some(owner_user_id.to_string()), None, @@ -4704,6 +4814,31 @@ fn editor_character_animation_source_asset_label(asset_label: &str) -> String { format!("{base}{suffix}") } +fn attach_editor_character_animation_result_metadata( + generation_inputs: Option, + preview_video_path: &str, + frames: &[EditorCharacterAnimationFramePayload], + fps: u32, + duration_seconds: u32, +) -> Option { + let mut object = match generation_inputs { + Some(Value::Object(object)) => object, + Some(value) => serde_json::Map::from_iter([("sourceGenerationInputs".to_string(), value)]), + None => serde_json::Map::new(), + }; + object.insert( + "characterAnimation".to_string(), + json!({ + "previewVideoPath": preview_video_path, + "frames": frames, + "frameCount": frames.len(), + "fps": fps, + "durationSeconds": duration_seconds, + }), + ); + Some(Value::Object(object)) +} + fn clamp_prompt_seed_text(value: Option<&str>) -> String { trim_optional_text(value) .unwrap_or_default() @@ -5484,6 +5619,12 @@ struct FinalizedAnimationFrame { extension: String, } +struct PersistedEditorCharacterAnimationFrameSet { + frames: Vec, + first_frame_object_key: String, + first_frame_asset_object_id: String, +} + // 统一收口动作生成阶段返回的草稿载荷,避免图片序列和视频预览分支在 handler 层分叉太散。 struct CharacterAnimationGeneratedDraft { image_sources: Vec, @@ -5547,9 +5688,11 @@ mod tests { image.put_pixel(4, 4, Rgba([10, 20, 30, 255])); let screen_color = crate::editor_green_screen::EDITOR_SCREEN_BACKGROUND_COLORS[0]; - let composited = - composite_source_image_onto_screen_color(&encode_rgba_png_data_url(&image), screen_color) - .expect("透明源图应被合成"); + let composited = composite_source_image_onto_screen_color( + &encode_rgba_png_data_url(&image), + screen_color, + ) + .expect("透明源图应被合成"); let payload = parse_media_data_url(&composited).expect("合成结果应是图片 data URL"); let output = image::load_from_memory(payload.bytes.as_slice()) .expect("合成结果应可解码") @@ -5560,7 +5703,11 @@ mod tests { [screen_color.red, screen_color.green, screen_color.blue, 255], "透明像素应填成背景色" ); - assert_eq!(output.get_pixel(4, 4).0, [10, 20, 30, 255], "不透明像素应保持原色"); + assert_eq!( + output.get_pixel(4, 4).0, + [10, 20, 30, 255], + "不透明像素应保持原色" + ); } #[test] @@ -5932,6 +6079,83 @@ mod tests { ); } + #[test] + fn editor_character_animation_result_metadata_keeps_the_whole_frame_set() { + let frames = vec![ + EditorCharacterAnimationFramePayload { + frame_index: 1, + image_src: "/generated-animations/editor/layer/task/frame01.png".to_string(), + width: 192, + height: 256, + }, + EditorCharacterAnimationFramePayload { + frame_index: 2, + image_src: "/generated-animations/editor/layer/task/frame02.png".to_string(), + width: 192, + height: 256, + }, + ]; + + let metadata = attach_editor_character_animation_result_metadata( + Some(json!({ + "fields": [{ "title": "动作", "value": "待机" }], + "references": [], + })), + "/generated-character-drafts/editor/layer/task/preview.mp4", + frames.as_slice(), + 8, + 4, + ) + .expect("character animation metadata should exist"); + + assert_eq!(metadata["fields"][0]["value"], "待机"); + assert_eq!(metadata["characterAnimation"]["frameCount"], 2); + assert_eq!(metadata["characterAnimation"]["fps"], 8); + assert_eq!(metadata["characterAnimation"]["durationSeconds"], 4); + assert_eq!( + metadata["characterAnimation"]["previewVideoPath"], + "/generated-character-drafts/editor/layer/task/preview.mp4" + ); + assert_eq!( + metadata["characterAnimation"]["frames"][1]["imageSrc"], + "/generated-animations/editor/layer/task/frame02.png" + ); + } + + #[test] + fn editor_character_animation_persists_one_final_asset_after_frame_extraction() { + let source = include_str!("character_animation_assets.rs"); + assert_function_contains_in_order( + source, + "pub(crate) async fn generate_editor_character_animation_for_owner", + "pub async fn generate_editor_video", + &[ + "let persisted_frames = extract_and_persist_editor_character_animation_frames", + "attach_editor_character_animation_result_metadata", + "project_id: None", + "label: asset_label.clone()", + "asset_kind: Some(\"character-animation\".to_string())", + "generation_cost_mud_points: u64::from(normalized.price_mud_points)", + ], + ); + assert_function_contains( + source, + "async fn extract_and_persist_editor_character_animation_frames", + "async fn persist_editor_character_animation_green_screen_source_frames", + &[ + "confirm_editor_character_animation_frame_asset_object", + "first_frame_object_key", + "first_frame_asset_object_id", + ], + ); + assert_function_contains( + source, + "async fn confirm_editor_character_animation_frame_asset_object", + "async fn bind_character_animation_asset", + &["EDITOR_CHARACTER_ANIMATION_ASSET_KIND"], + ); + } + #[test] fn editor_video_intermediate_outputs_are_registered_before_derivatives() { let source = include_str!("character_animation_assets.rs"); From 0baa8cf8ccc804e89945fa3816a0eac6ce79c510 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 14 Jul 2026 16:31:22 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=80=80=E6=AC=BE?= =?UTF-8?q?=E5=A4=8D=E6=A0=B8=E4=B8=8E=E5=8F=91=E5=B8=83=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐退款人工复核、刷新恢复、会话隔离和旧冷备导入兼容 强化真实微信退款对账开关、重复环境变量和生产发布门禁 优化公开作品资产授权查询并保持实时撤销语义 统一后台微信渠道、退款追回流水和画布正式资源测试契约 补齐生成与素材侧栏回归测试 --- apps/admin-web/src/api/adminApiClient.test.ts | 26 + apps/admin-web/src/api/adminApiClient.ts | 11 + apps/admin-web/src/api/adminApiTypes.ts | 8 + .../src/pages/AdminRechargeOrderPage.test.tsx | 323 ++++++++- .../src/pages/AdminRechargeOrderPage.tsx | 506 ++++++++++++-- deploy/env/api-server.env.example | 2 + .../shared-memory/decision-log.md | 5 +- docs/project-memory/shared-memory/pitfalls.md | 4 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 14 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + packages/shared/src/contracts/runtime.ts | 1 + scripts/check-production-api-deploy.mjs | 49 ++ scripts/check-production-ops-guardrails.mjs | 10 + scripts/deploy/production-api-deploy.sh | 22 +- .../crates/api-server/src/admin_recharge.rs | 50 +- .../crates/api-server/src/modules/admin.rs | 8 +- .../profile_recharge_refund_reconciliation.rs | 11 +- .../crates/module-runtime/src/commands.rs | 40 ++ server-rs/crates/module-runtime/src/domain.rs | 11 + .../crates/shared-contracts/src/admin.rs | 26 +- .../src/mapper/runtime_profile.rs | 15 + .../spacetime-client/src/module_bindings.rs | 4 + .../profile_recharge_refund_type.rs | 21 + ...fund_manual_review_and_return_procedure.rs | 62 ++ ...refund_manual_review_resolve_input_type.rs | 17 + ...e_profile_recharge_refund_snapshot_type.rs | 3 + .../crates/spacetime-client/src/runtime.rs | 32 + .../crates/spacetime-module/src/migration.rs | 64 ++ .../src/public_asset_access.rs | 622 ++++++++++++++---- .../spacetime-module/src/runtime/profile.rs | 172 ++++- ...mageCanvasEditorAssetsIntegration.test.tsx | 8 +- ...CanvasEditorGenerationIntegration.test.tsx | 80 ++- .../rpgEntryProfileFundsViewModel.test.ts | 22 +- .../rpgEntryProfileFundsViewModel.ts | 1 + 34 files changed, 2047 insertions(+), 205 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/resolve_profile_recharge_refund_manual_review_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_recharge_refund_manual_review_resolve_input_type.rs diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 9ec75345e..b2127d332 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -4,6 +4,7 @@ import { executeAdminRechargeRefund, getAdminUserDetail, listAdminRechargeOrders, + resolveAdminRechargeRefundManualReview, } from './adminApiClient'; afterEach(() => { @@ -89,3 +90,28 @@ test('退款执行使用独立 execute 管理员路由', async () => { }), ); }); + +test('退款人工复核使用独立 resolve 管理员路由', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({outRefundNo: 'refund-1'}), {status: 200}), + ); + vi.stubGlobal('fetch', fetchMock); + + await resolveAdminRechargeRefundManualReview('token-1', { + outRefundNo: 'refund-1', + reason: '已核对微信商户平台原始账单', + }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + '/admin/api/profile/recharge-refunds/manual-review/resolve', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + outRefundNo: 'refund-1', + reason: '已核对微信商户平台原始账单', + }), + }), + ); +}); diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 9f50ff9c0..89a9bf649 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -30,6 +30,7 @@ import type { AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest, + AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest, @@ -641,6 +642,16 @@ export function registerAdminRechargeRefund( ); } +export function resolveAdminRechargeRefundManualReview( + token: string, + payload: AdminRechargeRefundManualReviewResolveRequest, +) { + return request( + '/admin/api/profile/recharge-refunds/manual-review/resolve', + {method: 'POST', token, body: payload}, + ); +} + export function updateAdminWalletRestriction( token: string, payload: AdminWalletRestrictionRequest, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index b26fc064a..1afe7f04f 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -797,6 +797,9 @@ export interface AdminRechargeRefundPayload { unrecoveredPoints: number; recoveryStatus: string; lastErrorCode?: string | null; + manualReviewResolvedByAdminUserId?: string | null; + manualReviewResolutionReason?: string | null; + manualReviewResolvedAtMicros?: number | null; } export interface AdminRechargeRefundHoldPayload { @@ -876,6 +879,11 @@ export interface AdminRechargeRefundRegisterRequest { outRefundNo: string; } +export interface AdminRechargeRefundManualReviewResolveRequest { + outRefundNo: string; + reason: string; +} + export interface AdminWalletRestrictionRequest { userId: string; frozen: boolean; diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx index 83470581f..92224d082 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.test.tsx @@ -12,13 +12,16 @@ import { beforeEach, expect, test, vi } from 'vitest'; import { executeAdminRechargeRefund, + isAdminApiError, listAdminRechargeOrders, previewAdminRechargeRefund, registerAdminRechargeRefund, + resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { AdminRechargeOrderEntryPayload, AdminRechargeRefundActionResponse, + AdminRechargeRefundPayload, AdminRechargeRefundPreviewResponse, } from '../api/adminApiTypes'; import { AdminRechargeOrderPage } from './AdminRechargeOrderPage'; @@ -32,6 +35,7 @@ vi.mock('../api/adminApiClient', () => ({ listAdminRechargeOrders: vi.fn(), previewAdminRechargeRefund: vi.fn(), registerAdminRechargeRefund: vi.fn(), + resolveAdminRechargeRefundManualReview: vi.fn(), })); vi.mock('../components/AdminUserReferenceButton', () => ({ @@ -110,6 +114,8 @@ const allowedPreview: AdminRechargeRefundPreviewResponse = { beforeEach(() => { vi.clearAllMocks(); + sessionStorage.clear(); + vi.mocked(isAdminApiError).mockReturnValue(false); vi.mocked(listAdminRechargeOrders).mockResolvedValue({ entries: [baseOrder], }); @@ -118,6 +124,9 @@ beforeEach(() => { vi.mocked(registerAdminRechargeRefund).mockResolvedValue( actionFor(baseOrder), ); + vi.mocked(resolveAdminRechargeRefundManualReview).mockResolvedValue( + actionFor(baseOrder), + ); }); test('充值订单查询传递全部筛选字段', async () => { @@ -161,6 +170,46 @@ test('充值订单查询传递全部筛选字段', async () => { }); }); +test.each([ + ['wechat_mp', '微信小程序'], + ['wechat_mp_virtual', '微信小程序虚拟支付'], +])('充值订单可按真实微信渠道 %s 查询', async (paymentChannel) => { + const user = userEvent.setup(); + renderPage(); + await screen.findByText('order-1001'); + + await user.selectOptions(screen.getByLabelText('支付渠道'), paymentChannel); + await user.click(screen.getByRole('button', { name: '查询' })); + + await waitFor(() => { + expect(listAdminRechargeOrders).toHaveBeenLastCalledWith( + 'admin-token', + expect.objectContaining({ paymentChannel }), + ); + }); +}); + +test('充值订单展示两种微信小程序渠道中文名称', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { ...baseOrder, orderId: 'order-mp', paymentChannel: 'wechat_mp' }, + { + ...baseOrder, + orderId: 'order-mp-virtual', + paymentChannel: 'wechat_mp_virtual', + }, + ], + }); + renderPage(); + + const mpRow = (await screen.findByText('order-mp')).closest('tr'); + const virtualRow = screen.getByText('order-mp-virtual').closest('tr'); + expect(mpRow && within(mpRow).getByText('微信小程序')).toBeTruthy(); + expect( + virtualRow && within(virtualRow).getByText('微信小程序虚拟支付'), + ).toBeTruthy(); +}); + test('修改部分退款金额会作废已有预检', async () => { const user = userEvent.setup(); renderPage(); @@ -252,6 +301,233 @@ test('网络结果不明后重新预检会复用稳定 requestId 且双击只提 ).toBe(firstRequestId); }); +test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => { + const user = userEvent.setup(); + vi.mocked(executeAdminRechargeRefund) + .mockRejectedValueOnce(new Error('network disconnected')) + .mockResolvedValueOnce(actionFor(baseOrder)); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock + .calls[0]?.[1].requestId; + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByLabelText('退款金额(元)')).toHaveProperty( + 'value', + '3.00', + ); + expect(screen.getByLabelText('退款原因')).toHaveProperty('value', '用户申请'); + expect( + screen + .getByRole('button', { name: '核验支付账单' }) + .hasAttribute('disabled'), + ).toBe(true); + await user.click(screen.getByRole('button', { name: '继续核对退款' })); + + await waitFor(() => + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2), + ); + expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual( + expect.objectContaining({ + orderId: 'order-1001', + refundAmountCents: 300, + reason: '用户申请', + requestId: firstRequestId, + }), + ); + expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1); +}); + +test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => { + const user = userEvent.setup(); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('network disconnected'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + + const storageKey = sessionStorage.key(0); + expect(storageKey).toBeTruthy(); + const contexts = JSON.parse( + sessionStorage.getItem(storageKey ?? '') ?? '{}', + ) as Record; + const persistedContext = contexts['order-1001']; + expect(persistedContext).toBeTruthy(); + if (!persistedContext) { + throw new Error('退款请求上下文未写入 sessionStorage'); + } + persistedContext.createdAtMillis = Date.now() - 3 * 60 * 60 * 1_000; + sessionStorage.setItem(storageKey ?? '', JSON.stringify(contexts)); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); + expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull(); +}); + +test('切换管理员会话后不会复用上一会话的退款 requestId', async () => { + const user = userEvent.setup(); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('network disconnected'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy(); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage('other-admin-token'); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); + expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1); +}); + +test('服务端明确拒绝退款后清理恢复上下文', async () => { + const user = userEvent.setup(); + vi.mocked(isAdminApiError).mockReturnValue(true); + vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce( + new Error('退款前置条件不满足'), + ); + const firstRender = renderPage(); + await openPartialRefund(user); + await user.type(screen.getByLabelText('退款金额(元)'), '3.00'); + await user.type(screen.getByLabelText('退款原因'), '用户申请'); + await user.click(screen.getByRole('button', { name: '核验支付账单' })); + await user.click(screen.getByRole('button', { name: '确认退款' })); + expect(await screen.findByText('退款前置条件不满足')).toBeTruthy(); + firstRender.unmount(); + + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy(); +}); + +test('active hold 缺少原 requestId 时只开放预填退款单号的查单登记', async () => { + const user = userEvent.setup(); + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [orderWithActiveHold()], + }); + renderPage(); + + await user.click(await screen.findByRole('button', { name: '核对退款' })); + + const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' }); + expect( + within(dialog).getByRole('textbox', { + name: '商户退款单号 out_refund_no', + }), + ).toHaveProperty('value', 'refund-1001'); + expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull(); + expect(executeAdminRechargeRefund).not.toHaveBeenCalled(); + + await user.click(within(dialog).getByRole('button', { name: '查询并登记' })); + await waitFor(() => { + expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', { + outRefundNo: 'refund-1001', + }); + }); +}); + +test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])( + '允许处理可确认的退款人工复核错误 %s', + async (lastErrorCode) => { + const user = userEvent.setup(); + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + refunds: [manualReviewRefund({ lastErrorCode })], + }, + ], + }); + renderPage(); + + await user.click(await screen.findByRole('button', { name: '人工复核' })); + const dialog = screen.getByRole('dialog', { name: '确认退款人工复核' }); + const submitButton = within(dialog).getByRole('button', { + name: '确认并追回', + }); + expect(submitButton.hasAttribute('disabled')).toBe(true); + await user.type( + within(dialog).getByRole('textbox', { name: '人工复核原因' }), + '已核对微信商户平台原始账单', + ); + await user.click(submitButton); + + await waitFor(() => { + expect(resolveAdminRechargeRefundManualReview).toHaveBeenCalledWith( + 'admin-token', + { + outRefundNo: 'refund-manual-1', + reason: '已核对微信商户平台原始账单', + }, + ); + }); + }, +); + +test('非白名单、已处理和会员退款不显示人工复核按钮', async () => { + vi.mocked(listAdminRechargeOrders).mockResolvedValue({ + entries: [ + { + ...baseOrder, + orderId: 'order-non-whitelist', + refunds: [manualReviewRefund({ lastErrorCode: 'other_error' })], + }, + { + ...baseOrder, + orderId: 'order-resolved', + refunds: [ + manualReviewRefund({ manualReviewResolvedAtMicros: 1_720_000 }), + ], + }, + { + ...baseOrder, + orderId: 'order-membership', + productKind: 'membership', + refunds: [manualReviewRefund()], + }, + ], + }); + renderPage(); + + await screen.findByText('order-membership'); + expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull(); +}); + test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => { const user = userEvent.setup(); const debtOrder = { @@ -321,9 +597,9 @@ test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断 }); }); -function renderPage() { +function renderPage(token = 'admin-token') { return render( - , + , ); } @@ -344,3 +620,46 @@ function actionFor( order, }; } + +function orderWithActiveHold(): AdminRechargeOrderEntryPayload { + return { + ...baseOrder, + activeHold: { + outRefundNo: 'refund-1001', + refundCents: 300, + heldPoints: 30, + status: 'pending', + adminUserId: 'admin-1', + reason: '用户申请', + createdAtMicros: 1_720_000_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + }, + refundEligible: false, + refundBlockReasonCode: 'refund_in_progress', + }; +} + +function manualReviewRefund( + overrides: Partial = {}, +): AdminRechargeRefundPayload { + return { + outRefundNo: 'refund-manual-1', + providerRefundId: 'provider-refund-1', + providerStatus: 'SUCCESS', + refundCents: 300, + payerRefundCents: 300, + successAtMicros: 1_720_000_000_000_000, + firstObservedAtMicros: 1_720_000_000_000_000, + updatedAtMicros: 1_720_000_000_000_000, + observationSource: 'wechat_query', + targetRecoveryPoints: 30, + recoveredPoints: 0, + unrecoveredPoints: 0, + recoveryStatus: 'manual_review', + lastErrorCode: 'provider_transaction_id_mismatch', + manualReviewResolvedByAdminUserId: null, + manualReviewResolutionReason: null, + manualReviewResolvedAtMicros: null, + ...overrides, + }; +} diff --git a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx index 8d272b8a0..5ad5a1a25 100644 --- a/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx +++ b/apps/admin-web/src/pages/AdminRechargeOrderPage.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, + FileCheck2, FileInput, RefreshCcw, RotateCcw, @@ -16,11 +17,13 @@ import { listAdminRechargeOrders, previewAdminRechargeRefund, registerAdminRechargeRefund, + resolveAdminRechargeRefundManualReview, } from '../api/adminApiClient'; import type { AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeRefundActionResponse, + AdminRechargeRefundPayload, AdminRechargeRefundPreviewResponse, } from '../api/adminApiTypes'; import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton'; @@ -46,6 +49,24 @@ interface RechargeFilters { type RefundMode = 'remaining' | 'partial'; +interface PersistedRefundRequestContext { + orderId: string; + amountCents: number; + requestId: string; + reason: string; + outRefundNo?: string; + createdAtMillis: number; +} + +interface ManualReviewTarget { + orderId: string; + refund: AdminRechargeRefundPayload; +} + +const REFUND_REQUEST_CONTEXT_STORAGE_KEY = + 'genarrative.admin.recharge-refund-contexts.v2'; +const REFUND_REQUEST_CONTEXT_TTL_MILLIS = 2 * 60 * 60 * 1_000; + const defaultFilters: RechargeFilters = { orderId: '', providerTransactionId: '', @@ -83,15 +104,18 @@ export function AdminRechargeOrderPage({ const [outRefundNo, setOutRefundNo] = useState(''); const [registerError, setRegisterError] = useState(''); const [isRegistering, setIsRegistering] = useState(false); + const [refundRequestId, setRefundRequestId] = useState(''); + const [resumableRefundRequest, setResumableRefundRequest] = + useState(null); + const [manualReviewTarget, setManualReviewTarget] = + useState(null); + const [manualReviewReason, setManualReviewReason] = useState(''); + const [manualReviewError, setManualReviewError] = useState(''); + const [isResolvingManualReview, setIsResolvingManualReview] = useState(false); const lastQueryRef = useRef({ limit: 100 }); - const requestIdRef = useRef(''); - const uncertainRequestRef = useRef<{ - orderId: string; - amountCents: number; - requestId: string; - } | null>(null); const submitLockRef = useRef(false); const registerLockRef = useRef(false); + const manualReviewLockRef = useRef(false); const { confirmWrite, confirmDialog } = useAdminWriteConfirm(); const visibleDebtOrder = findDebtOrder(lastAction?.order, orders); @@ -148,23 +172,32 @@ export function AdminRechargeOrderPage({ } function openRefund(order: AdminRechargeOrderEntryPayload) { - const amountCents = order.remainingRefundableCents; - const uncertain = uncertainRequestRef.current; + const persistedContext = findPersistedRefundRequestContext(order, token); + if (order.activeHold && !persistedContext) { + setOutRefundNo(order.activeHold.outRefundNo); + setRegisterError(''); + setRegisterOpen(true); + return; + } + + const amountCents = + order.activeHold?.refundCents ?? + persistedContext?.amountCents ?? + order.remainingRefundableCents; + const hasUncertainRequest = Boolean(order.activeHold || persistedContext); setRefundOrder(order); - setRefundMode('remaining'); + setRefundMode( + amountCents === order.remainingRefundableCents ? 'remaining' : 'partial', + ); setRefundAmountYuan(formatCentsInput(amountCents)); - setRefundReason(''); + setRefundReason(order.activeHold?.reason ?? persistedContext?.reason ?? ''); setPreview(null); setRefundError(''); - setProviderStatusUnknown( - uncertain?.orderId === order.orderId && - uncertain.amountCents === amountCents, + setProviderStatusUnknown(hasUncertainRequest); + setRefundRequestId(persistedContext?.requestId ?? createRequestId()); + setResumableRefundRequest( + order.activeHold && persistedContext ? persistedContext : null, ); - requestIdRef.current = - uncertain?.orderId === order.orderId && - uncertain.amountCents === amountCents - ? uncertain.requestId - : createRequestId(); } function closeRefund() { @@ -174,14 +207,19 @@ export function AdminRechargeOrderPage({ setRefundOrder(null); setPreview(null); setRefundError(''); + setResumableRefundRequest(null); } function updateRefundAmount(nextValue: string) { + if (refundOrder) { + removePersistedRefundRequestContext(token, refundOrder.orderId); + } setRefundAmountYuan(nextValue); setPreview(null); setRefundError(''); setProviderStatusUnknown(false); - requestIdRef.current = createRequestId(); + setRefundRequestId(createRequestId()); + setResumableRefundRequest(null); } function changeRefundMode(nextMode: RefundMode) { @@ -233,7 +271,7 @@ export function AdminRechargeOrderPage({ async function handleExecuteRefund() { if ( !refundOrder || - !preview?.canSubmit || + (!preview?.canSubmit && !resumableRefundRequest) || submitLockRef.current || isSubmitting ) { @@ -244,9 +282,14 @@ export function AdminRechargeOrderPage({ setRefundError('请填写退款原因'); return; } - const amountCents = preview.refundAmountCents; - const requestId = requestIdRef.current || createRequestId(); - requestIdRef.current = requestId; + const amountCents = + resumableRefundRequest?.amountCents ?? preview?.refundAmountCents; + if (!amountCents) { + return; + } + const requestId = + resumableRefundRequest?.requestId || refundRequestId || createRequestId(); + setRefundRequestId(requestId); const confirmed = await confirmWrite({ action: '发起微信退款并追回泥点', target: `${refundOrder.orderId} / ${formatMoney(amountCents)}`, @@ -258,11 +301,15 @@ export function AdminRechargeOrderPage({ submitLockRef.current = true; setIsSubmitting(true); setRefundError(''); - uncertainRequestRef.current = { + const requestContext: PersistedRefundRequestContext = { orderId: refundOrder.orderId, amountCents, requestId, + reason, + outRefundNo: refundOrder.activeHold?.outRefundNo, + createdAtMillis: resumableRefundRequest?.createdAtMillis ?? Date.now(), }; + persistRefundRequestContext(token, requestContext); try { const response = await executeAdminRechargeRefund(token, { orderId: refundOrder.orderId, @@ -275,11 +322,24 @@ export function AdminRechargeOrderPage({ upsertOrder(response.order); setPreview(null); if (response.providerStatusUnknown) { + const pendingRequestContext = { + ...requestContext, + outRefundNo: response.outRefundNo, + }; + persistRefundRequestContext(token, pendingRequestContext); + setResumableRefundRequest( + response.order.activeHold ? pendingRequestContext : null, + ); setProviderStatusUnknown(true); setRefundError(unknownProviderStatusMessage); } else { - uncertainRequestRef.current = null; + removePersistedRefundRequestContext( + token, + refundOrder.orderId, + response.outRefundNo, + ); setProviderStatusUnknown(false); + setResumableRefundRequest(null); } } catch (error: unknown) { if (!isAdminApiError(error)) { @@ -287,6 +347,8 @@ export function AdminRechargeOrderPage({ setPreview(null); setRefundError(unknownProviderStatusMessage); } else { + removePersistedRefundRequestContext(token, refundOrder.orderId); + setResumableRefundRequest(null); handleDialogError(error, setRefundError); } } finally { @@ -321,6 +383,11 @@ export function AdminRechargeOrderPage({ }); setLastAction(response); upsertOrder(response.order); + removePersistedRefundRequestContext( + token, + response.order.orderId, + normalizedOutRefundNo, + ); setOutRefundNo(''); setRegisterOpen(false); } catch (error: unknown) { @@ -331,6 +398,52 @@ export function AdminRechargeOrderPage({ } } + function openManualReview( + order: AdminRechargeOrderEntryPayload, + refund: AdminRechargeRefundPayload, + ) { + setManualReviewTarget({ orderId: order.orderId, refund }); + setManualReviewReason(''); + setManualReviewError(''); + } + + async function handleResolveManualReview(event: FormEvent) { + event.preventDefault(); + const reason = manualReviewReason.trim(); + if (!manualReviewTarget || manualReviewLockRef.current || !reason) { + if (!reason) { + setManualReviewError('请填写人工复核原因'); + } + return; + } + const confirmed = await confirmWrite({ + action: '确认微信退款并追回泥点', + target: `${manualReviewTarget.orderId} / ${manualReviewTarget.refund.outRefundNo}`, + }); + if (!confirmed || manualReviewLockRef.current) { + return; + } + + manualReviewLockRef.current = true; + setIsResolvingManualReview(true); + setManualReviewError(''); + try { + const response = await resolveAdminRechargeRefundManualReview(token, { + outRefundNo: manualReviewTarget.refund.outRefundNo, + reason, + }); + setLastAction(response); + upsertOrder(response.order); + setManualReviewTarget(null); + setManualReviewReason(''); + } catch (error: unknown) { + handleDialogError(error, setManualReviewError); + } finally { + manualReviewLockRef.current = false; + setIsResolvingManualReview(false); + } + } + function handleDialogError( error: unknown, setMessage: (message: string) => void, @@ -446,7 +559,8 @@ export function AdminRechargeOrderPage({ - + +
) : null} + {manualReviewTarget ? ( +
{ + if ( + event.target === event.currentTarget && + !isResolvingManualReview + ) { + setManualReviewTarget(null); + } + }} + > +
+
+
+

确认退款人工复核

+ {manualReviewTarget.refund.outRefundNo} +
+ +
+