修复退款复核与发布安全边界
补齐退款人工复核、刷新恢复、会话隔离和旧冷备导入兼容 强化真实微信退款对账开关、重复环境变量和生产发布门禁 优化公开作品资产授权查询并保持实时撤销语义 统一后台微信渠道、退款追回流水和画布正式资源测试契约 补齐生成与素材侧栏回归测试
This commit is contained in:
@@ -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: '已核对微信商户平台原始账单',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<AdminRechargeRefundActionResponse>(
|
||||
'/admin/api/profile/recharge-refunds/manual-review/resolve',
|
||||
{method: 'POST', token, body: payload},
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminWalletRestriction(
|
||||
token: string,
|
||||
payload: AdminWalletRestrictionRequest,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, { createdAtMillis: number }>;
|
||||
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(
|
||||
<AdminRechargeOrderPage token="admin-token" onUnauthorized={vi.fn()} />,
|
||||
<AdminRechargeOrderPage token={token} onUnauthorized={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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> = {},
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+2
@@ -137,6 +137,8 @@ WECHAT_USER_INFO_ENDPOINT=https://api.weixin.qq.com/sns/userinfo
|
||||
WECHAT_STATE_TTL_MINUTES=15
|
||||
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
|
||||
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
|
||||
# 真实微信支付必须开启退款主动查单与账单对账;生产发布脚本会复核该值。
|
||||
WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true
|
||||
|
||||
ALIYUN_OSS_BUCKET=
|
||||
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
|
||||
@@ -4057,7 +4057,7 @@
|
||||
## 2026-07-13 公开作品资产使用派生精确读授权
|
||||
|
||||
- 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。
|
||||
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内组合 `asset_object` 与该 view,不从连接级长期订阅 cache 判断 ACL。
|
||||
- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内先取资产 owner,再使用各玩法 owner 索引定向计算该作者的 grant,不为每张图片执行全站 view,也不从连接级长期订阅 cache 判断 ACL。
|
||||
- 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。
|
||||
- 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。
|
||||
- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view 与权威读取 procedure、`spacetime-client` facade 和 `api-server` 资产读取 ACL。
|
||||
@@ -4071,7 +4071,7 @@
|
||||
- 退款事实:新增 `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 和十进制定点金额解析。
|
||||
- 查单与账单:`WECHAT_PAY_REFUND_RECONCILIATION_ENABLED` 代码默认关闭,只有具备真实商户凭据和 runtime service identity 的 HTTP 角色可开启;生产 env 示例与 deploy 会补 `true`,真实支付显式关闭时发布失败。非终态退款按 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。
|
||||
@@ -4081,6 +4081,7 @@
|
||||
- 背景:普通 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`。
|
||||
|
||||
@@ -3044,7 +3044,7 @@
|
||||
- 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`。
|
||||
- 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。
|
||||
- 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible`(`custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。
|
||||
- 权威查询边界:不能从 `asset_object` 或 `public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
|
||||
- 权威查询边界:不能从 `asset_object` 或 `public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;procedure 先按 asset owner 使用各玩法 owner 索引缩小到该作者作品,再匹配候选 `asset_object_id` / 精确 key,不能每张图都执行全站公开 view,也不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。
|
||||
- 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`。
|
||||
@@ -3077,6 +3077,6 @@
|
||||
|
||||
- 现象:后台调用微信退款超时或连接中断,页面提示状态未知;如果服务端立即释放泥点占用,用户可以继续消费,而微信稍后仍可能完成退款,最终形成可避免的退款欠账。反过来,永久保留占用又会让一次明确未创建的退款长期冻结余额。
|
||||
- 原因:HTTP 错误只能说明客户端没有拿到确定响应,不能证明微信没有受理;单次退款查单 `RESOURCE_NOT_EXISTS` 也可能处于短暂传播窗口。只有使用原 `out_refund_no` 主动查单才能继续判定。
|
||||
- 处理:网络结果未知时保留活动 hold,页面复用原 `requestId`;worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
|
||||
- 处理:网络结果未知时保留活动 hold,页面把原 `requestId`、订单、金额、原因和已知 `out_refund_no` 保存到当前后台标签页、当前管理员会话隔离的 `sessionStorage`,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 `out_refund_no` 的安全查单登记,不生成新 ID 硬撞活动 hold。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`。
|
||||
|
||||
@@ -205,14 +205,14 @@ npm run check:server-rs-ddd
|
||||
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 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费,直到人工复核收口。流水来源为 `recharge_refund_recovery`。会员充值没有可逆 grant 快照,退款统一标记 `manual_review`,不猜测回滚档位、有效期或周期泥点,也不复用泥点退款冻结语义。
|
||||
18. 泥点退款按累计成功退款比例计算目标回收量,全额退款强制精确回收原 `points_delta`。自动回收只扣普通永久泥点,每日免费和会员周期限时泥点保持不变;不足部分持久化为 `shortfall` 并冻结正式钱包消费,后续 worker 只重试本地回收。已成功退款的泥点订单若出现微信交易号、订单总额冲突或结算计划无效,必须将 settlement 标记为 `wallet_frozen` 并阻断普通消费。管理员只能对交易号或订单总额冲突执行“确认退款归属”:procedure 在退款行尾部不可变记录管理员、原因和时间后重新运行标准结算,能追回的永久泥点照常扣除,余额不足继续形成欠账;不能直接清空冻结或伪造已追回量。非法结算计划仍保持冻结等待数据/代码修复。流水来源为 `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 和十进制定点金额解析;发现手工退款后必须再查单取得当前状态。
|
||||
20. 主动查询与退款交易账单 worker 只由 HTTP 角色运行并由 `WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true` 显式开启。生产 env 示例和 API deploy 会为缺失配置补 `true`;当 `WECHAT_PAY_ENABLED=true + WECHAT_PAY_PROVIDER=real` 时显式关闭该开关必须阻断发布,启动日志也必须明确记录启用或异常关闭状态。非终态退款按 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 返回的正式状态。
|
||||
25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,并调用 runtime service identity 受限 procedure;前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
@@ -319,14 +319,14 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`AssetObject`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs`
|
||||
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
|
||||
- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。授权判断先取得资产 owner,再按各玩法现有 owner 索引定向扫描该作者公开作品,并仅对候选 `asset_object_id` / 精确 `object_key` grant ID 做匹配,不得为每张图片生成全站公开作品 grant 列表。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。
|
||||
|
||||
### SpacetimeDB view:`public_work_asset_read_grant`
|
||||
|
||||
- Rust view:`public_work_asset_read_grant`
|
||||
- 返回类型:`Vec<PublicWorkAssetReadGrant>`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs`
|
||||
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 在自己的事务快照中直接执行该 view,作品隐藏、删除或取消发布提交后不能再被不同连接的陈旧订阅 cache 继续授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
|
||||
- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 与 view 复用同一套玩法资产采集器,但安全判断按资产 owner 索引定向计算,不执行全站 view。作品隐藏、删除或取消发布提交后,后续事务立即拒绝授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。
|
||||
|
||||
### `auth_identity`
|
||||
|
||||
@@ -762,8 +762,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
- 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`。
|
||||
- 作用:普通微信支付 V3 退款单聚合。以 `out_refund_no` 为主键、`provider_refund_id` 唯一,保存原订单/微信支付单、四个分金额、微信状态、最近观察、权益目标/已回收/未回收量和人工处理错误码。交易号或订单总额冲突经管理员确认归属后,追加保存处理管理员、非空原因和处理时间;三字段只写一次,作为重新执行正式结算的审计事实。数据库迁移导入旧退款行时,缺失的三个审计字段必须补 `null`,保证升级前冷备可恢复。
|
||||
- 索引:`by_profile_recharge_refund_order_id`、`by_profile_recharge_refund_status_updated_at`。
|
||||
|
||||
### `profile_recharge_refund_observation`
|
||||
|
||||
|
||||
@@ -91,6 +91,8 @@ Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发
|
||||
WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true
|
||||
```
|
||||
|
||||
`deploy/env/api-server.env.example` 已把生产值固定为 `true`。`production-api-deploy.sh` 会为存量 env 缺失项补齐该值;若同时配置 `WECHAT_PAY_ENABLED=true`、`WECHAT_PAY_PROVIDER=real` 却显式关闭 reconciliation,部署在切换 `current` 前失败并保持 fail-closed。env 出现重复键时必须与 systemd `EnvironmentFile` 一致按最后一次赋值判断,不能让前面的 `true` 掩盖运行态最终生效的 `false`。发布后应在 API 启动日志确认 `wechat pay refund reconciliation worker is enabled`,不能只看 `/readyz`。
|
||||
|
||||
开启后,`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` 不能带查询参数;临时域名变化后,只影响之后新提交的退款请求,已经提交给微信的旧退款单仍绑定旧地址。无签名探测只能验证路由可达,不能伪造成功回调:
|
||||
|
||||
@@ -78,6 +78,7 @@ export type ProfileWalletLedgerEntry = {
|
||||
| 'daily_free_reset'
|
||||
| 'asset_operation_consume'
|
||||
| 'asset_operation_refund'
|
||||
| 'recharge_refund_recovery'
|
||||
| 'redeem_code_reward'
|
||||
| 'puzzle_author_incentive_claim'
|
||||
| 'daily_task_reward';
|
||||
|
||||
@@ -56,6 +56,8 @@ function main() {
|
||||
assertDeployRejectsRelativeReleaseRoot();
|
||||
assertDeployRejectsRelativeCurrentLink();
|
||||
assertDeployRejectsRelativeApiEnvFile();
|
||||
assertRealWechatPayRequiresRefundReconciliation();
|
||||
assertRealWechatPayUsesLastRefundReconciliationAssignment();
|
||||
assertDeployCleansStagingReleaseOnFailure();
|
||||
assertDeployRejectsFinalReleaseRaceAndCleansStaging();
|
||||
assertMissingBackupScriptFails();
|
||||
@@ -128,6 +130,11 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
||||
'[production-api-deploy] 完成:',
|
||||
'部署成功时必须输出完成信息。',
|
||||
);
|
||||
assertIncludes(
|
||||
readFileSync(fixture.apiEnvFile, 'utf8'),
|
||||
'WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true',
|
||||
'生产部署必须为存量 API env 补齐退款 reconciliation 开关。',
|
||||
);
|
||||
|
||||
const releaseDir = path.join(fixture.releaseRoot, fixture.version);
|
||||
const currentTarget = readlinkSync(fixture.currentLink);
|
||||
@@ -354,6 +361,48 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
|
||||
}
|
||||
}
|
||||
|
||||
function assertRealWechatPayRequiresRefundReconciliation() {
|
||||
const fixture = prepareFixture('wechat-refund-reconciliation-disabled');
|
||||
writeFileSync(
|
||||
fixture.apiEnvFile,
|
||||
`${readFileSync(fixture.apiEnvFile, 'utf8')}WECHAT_PAY_ENABLED=true\nWECHAT_PAY_PROVIDER=real\nWECHAT_PAY_REFUND_RECONCILIATION_ENABLED=false\n`,
|
||||
'utf8',
|
||||
);
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push('真实微信支付关闭退款 reconciliation 时部署必须失败。');
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'真实微信支付必须设置 WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true',
|
||||
'退款 reconciliation 门禁失败时必须给出明确错误。',
|
||||
);
|
||||
assertMaintenanceCleared(fixture, '退款 reconciliation 门禁失败');
|
||||
}
|
||||
|
||||
function assertRealWechatPayUsesLastRefundReconciliationAssignment() {
|
||||
const fixture = prepareFixture('wechat-refund-reconciliation-duplicate-disabled-last');
|
||||
writeFileSync(
|
||||
fixture.apiEnvFile,
|
||||
`${readFileSync(fixture.apiEnvFile, 'utf8')}WECHAT_PAY_ENABLED=true\nWECHAT_PAY_PROVIDER=real\nWECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true\nWECHAT_PAY_REFUND_RECONCILIATION_ENABLED=false\n`,
|
||||
'utf8',
|
||||
);
|
||||
const result = runDeploy(fixture);
|
||||
|
||||
if (result.status === 0) {
|
||||
failures.push(
|
||||
'退款 reconciliation 重复配置时必须按 systemd EnvironmentFile 的最后一次赋值拦截部署。',
|
||||
);
|
||||
}
|
||||
assertIncludes(
|
||||
result.stderr,
|
||||
'真实微信支付必须设置 WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true',
|
||||
'退款 reconciliation 重复配置的最后一项为 false 时必须给出明确错误。',
|
||||
);
|
||||
assertMaintenanceCleared(fixture, '退款 reconciliation 重复配置门禁失败');
|
||||
}
|
||||
|
||||
function assertDeployRestartsActivePingoraWhenArtifactIncluded() {
|
||||
const fixture = prepareFixture('with-active-pingora-artifact');
|
||||
addPingoraGatewayArtifact(fixture);
|
||||
|
||||
@@ -196,6 +196,16 @@ const checks = [
|
||||
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt',
|
||||
reason: '生产 API env 示例只能登记 bootstrap secret 的固定 FILE 路径。',
|
||||
},
|
||||
{
|
||||
file: 'deploy/env/api-server.env.example',
|
||||
includes: 'WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true',
|
||||
reason: '真实微信支付 release 必须在生产 env 示例中显式开启退款主动对账。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/deploy/production-api-deploy.sh',
|
||||
includes: 'validate_real_wechat_pay_refund_reconciliation',
|
||||
reason: 'API deploy 必须在 current 切换前复核真实微信支付退款 reconciliation。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
|
||||
|
||||
@@ -149,6 +149,7 @@ path = Path(sys.argv[1])
|
||||
key = sys.argv[2]
|
||||
if not path.exists():
|
||||
raise SystemExit(0)
|
||||
matched_value = None
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
@@ -158,8 +159,9 @@ for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("\"", "'\''"):
|
||||
value = value[1:-1]
|
||||
print(value)
|
||||
raise SystemExit(0)
|
||||
matched_value = value
|
||||
if matched_value is not None:
|
||||
print(matched_value)
|
||||
'
|
||||
|
||||
if [[ -r "${file_path}" ]]; then
|
||||
@@ -297,6 +299,7 @@ ensure_runtime_env_and_dirs() {
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE" "100"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES" "67108864"
|
||||
ensure_env_value "${api_env_file}" "WECHAT_PAY_REFUND_RECONCILIATION_ENABLED" "true"
|
||||
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
|
||||
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
|
||||
@@ -317,6 +320,20 @@ ensure_runtime_env_and_dirs() {
|
||||
fi
|
||||
}
|
||||
|
||||
validate_real_wechat_pay_refund_reconciliation() {
|
||||
local api_env_file="$1"
|
||||
local pay_enabled pay_provider reconciliation_enabled
|
||||
|
||||
pay_enabled="$(read_env_value "${api_env_file}" "WECHAT_PAY_ENABLED" | tr '[:upper:]' '[:lower:]')"
|
||||
pay_provider="$(read_env_value "${api_env_file}" "WECHAT_PAY_PROVIDER" | tr '[:upper:]' '[:lower:]')"
|
||||
reconciliation_enabled="$(read_env_value "${api_env_file}" "WECHAT_PAY_REFUND_RECONCILIATION_ENABLED" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "${pay_enabled}" == "true" && "${pay_provider}" == "real" && "${reconciliation_enabled}" != "true" ]]; then
|
||||
echo "[production-api-deploy] 真实微信支付必须设置 WECHAT_PAY_REFUND_RECONCILIATION_ENABLED=true: ${api_env_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[production-api-deploy] 微信退款 reconciliation 配置已复核: enabled=${reconciliation_enabled:-unset}, real_pay=$([[ "${pay_enabled}" == "true" && "${pay_provider}" == "real" ]] && printf true || printf false)"
|
||||
}
|
||||
|
||||
ensure_worker_runtime_env_defaults() {
|
||||
local worker_env_file="$1"
|
||||
|
||||
@@ -1110,6 +1127,7 @@ if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
|
||||
fi
|
||||
|
||||
ensure_runtime_env_and_dirs "${API_ENV_FILE}"
|
||||
validate_real_wechat_pay_refund_reconciliation "${API_ENV_FILE}"
|
||||
ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}"
|
||||
migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}"
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use module_runtime::{
|
||||
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_manual_review_resolve_input,
|
||||
build_runtime_profile_recharge_refund_settlement_plan,
|
||||
build_runtime_profile_wallet_manual_restriction_upsert_input,
|
||||
};
|
||||
@@ -27,7 +28,8 @@ use sha2::{Digest, Sha256};
|
||||
use shared_contracts::admin::{
|
||||
AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery,
|
||||
AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse,
|
||||
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload, AdminRechargeRefundPayload,
|
||||
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload,
|
||||
AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload,
|
||||
AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse,
|
||||
AdminRechargeRefundRegisterRequest, AdminUserDetailQuery, AdminUserDetailResponse,
|
||||
AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest,
|
||||
@@ -448,6 +450,49 @@ pub async fn admin_register_recharge_refund(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_resolve_recharge_refund_manual_review(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminRechargeRefundManualReviewResolveRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let input = build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
||||
payload.out_refund_no,
|
||||
admin.session().subject.clone(),
|
||||
payload.reason,
|
||||
)
|
||||
.map_err(|message| {
|
||||
error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message),
|
||||
)
|
||||
})?;
|
||||
let (refund, _, _, resolution_code) = state
|
||||
.spacetime_client()
|
||||
.resolve_profile_recharge_refund_manual_review(input)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::CONFLICT)
|
||||
.with_message("人工确认退款归属失败")
|
||||
.with_details(json!({"message": error.to_string()})),
|
||||
)
|
||||
})?;
|
||||
let entry = load_order(&state, &request_context, &refund.order_id).await?;
|
||||
let user = load_user_summary(&state, &entry.order.user_id);
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminRechargeRefundActionResponse {
|
||||
out_refund_no: refund.out_refund_no,
|
||||
provider_status: refund.provider_status.as_str().to_string(),
|
||||
result_code: resolution_code,
|
||||
provider_status_unknown: false,
|
||||
order: map_order_entry(entry, user),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_update_wallet_restriction(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -872,6 +917,9 @@ fn map_refund(refund: RuntimeProfileRechargeRefundSnapshot) -> AdminRechargeRefu
|
||||
unrecovered_points: refund.unrecovered_points,
|
||||
recovery_status: refund.recovery_status.as_str().to_string(),
|
||||
last_error_code: refund.last_error_code,
|
||||
manual_review_resolved_by_admin_user_id: refund.manual_review_resolved_by_admin_user_id,
|
||||
manual_review_resolution_reason: refund.manual_review_resolution_reason,
|
||||
manual_review_resolved_at_micros: refund.manual_review_resolved_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
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,
|
||||
admin_resolve_recharge_refund_manual_review, admin_update_wallet_restriction,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -275,6 +275,12 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/recharge-refunds/manual-review/resolve",
|
||||
post(admin_resolve_recharge_refund_manual_review).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(
|
||||
|
||||
@@ -54,7 +54,11 @@ pub(crate) struct PersistedWechatRefundObservation {
|
||||
|
||||
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");
|
||||
if state.wechat_pay_client().supports_refund_reconciliation() {
|
||||
warn!("real wechat pay is active but refund reconciliation worker is disabled");
|
||||
} else {
|
||||
debug!("wechat pay refund reconciliation worker is disabled");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !state.wechat_pay_client().supports_refund_reconciliation() {
|
||||
@@ -62,6 +66,8 @@ pub fn spawn_profile_recharge_refund_reconciliation_worker(state: AppState) {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("wechat pay refund reconciliation worker is enabled");
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut trade_bill_scan_slot = 0;
|
||||
let mut hold_not_found_counts = BTreeMap::<String, u8>::new();
|
||||
@@ -940,6 +946,9 @@ mod tests {
|
||||
recovery_status: RuntimeProfileRechargeRefundRecoveryStatus::NotApplicable,
|
||||
last_recovery_ledger_id: None,
|
||||
last_error_code: None,
|
||||
manual_review_resolved_by_admin_user_id: None,
|
||||
manual_review_resolution_reason: None,
|
||||
manual_review_resolved_at_micros: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -611,6 +611,24 @@ pub fn build_runtime_profile_recharge_refund_hold_list_input(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
||||
out_refund_no: String,
|
||||
admin_user_id: String,
|
||||
reason: String,
|
||||
) -> Result<RuntimeProfileRechargeRefundManualReviewResolveInput, String> {
|
||||
Ok(RuntimeProfileRechargeRefundManualReviewResolveInput {
|
||||
out_refund_no: normalize_profile_recharge_refund_identifier(
|
||||
out_refund_no,
|
||||
"refund_manual_review.out_refund_no",
|
||||
)?,
|
||||
admin_user_id: normalize_profile_recharge_refund_identifier(
|
||||
admin_user_id,
|
||||
"refund_manual_review.admin_user_id",
|
||||
)?,
|
||||
reason: normalize_profile_recharge_refund_reason(reason, "refund_manual_review.reason")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_admin_wallet_get_input(
|
||||
user_id: String,
|
||||
) -> Result<RuntimeProfileAdminWalletGetInput, String> {
|
||||
@@ -1432,6 +1450,28 @@ fn normalize_profile_task_id(value: String) -> Result<String, RuntimeProfileFiel
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn refund_manual_review_resolution_requires_auditable_fields() {
|
||||
let input = build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
||||
" refund-1 ".to_string(),
|
||||
" admin-1 ".to_string(),
|
||||
" 已核对微信商户平台原始账单 ".to_string(),
|
||||
)
|
||||
.expect("manual review input should normalize");
|
||||
assert_eq!(input.out_refund_no, "refund-1");
|
||||
assert_eq!(input.admin_user_id, "admin-1");
|
||||
assert_eq!(input.reason, "已核对微信商户平台原始账单");
|
||||
|
||||
assert!(
|
||||
build_runtime_profile_recharge_refund_manual_review_resolve_input(
|
||||
"refund-1".to_string(),
|
||||
"admin-1".to_string(),
|
||||
" ".to_string(),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_code_metadata_tags_alias_is_saved_as_user_tags() {
|
||||
let normalized = normalize_invite_code_metadata_json(
|
||||
|
||||
@@ -1435,6 +1435,9 @@ pub struct RuntimeProfileRechargeRefundSnapshot {
|
||||
pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
|
||||
pub last_recovery_ledger_id: Option<String>,
|
||||
pub last_error_code: Option<String>,
|
||||
pub manual_review_resolved_by_admin_user_id: Option<String>,
|
||||
pub manual_review_resolution_reason: Option<String>,
|
||||
pub manual_review_resolved_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -1722,6 +1725,14 @@ pub struct RuntimeProfileRechargeRefundHoldListInput {
|
||||
pub limit: u32,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeRefundManualReviewResolveInput {
|
||||
pub out_refund_no: String,
|
||||
pub admin_user_id: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileAdminWalletGetInput {
|
||||
|
||||
@@ -709,6 +709,9 @@ pub struct AdminRechargeRefundPayload {
|
||||
pub unrecovered_points: u64,
|
||||
pub recovery_status: String,
|
||||
pub last_error_code: Option<String>,
|
||||
pub manual_review_resolved_by_admin_user_id: Option<String>,
|
||||
pub manual_review_resolution_reason: Option<String>,
|
||||
pub manual_review_resolved_at_micros: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -804,6 +807,13 @@ pub struct AdminRechargeRefundRegisterRequest {
|
||||
pub out_refund_no: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminRechargeRefundManualReviewResolveRequest {
|
||||
pub out_refund_no: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminWalletRestrictionRequest {
|
||||
@@ -854,7 +864,21 @@ pub struct AdminWalletRestrictionResponse {
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::AdminEditorShowcaseAssetPayload;
|
||||
use super::{
|
||||
AdminEditorShowcaseAssetPayload, AdminRechargeRefundManualReviewResolveRequest,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn refund_manual_review_resolution_request_uses_camel_case_contract() {
|
||||
let value = serde_json::to_value(AdminRechargeRefundManualReviewResolveRequest {
|
||||
out_refund_no: "refund-1".to_string(),
|
||||
reason: "已核对微信商户平台原始账单".to_string(),
|
||||
})
|
||||
.expect("request should serialize");
|
||||
|
||||
assert_eq!(value["outRefundNo"], json!("refund-1"));
|
||||
assert!(value.get("out_refund_no").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_showcase_asset_payload_serializes_author_fields_as_camel_case() {
|
||||
|
||||
@@ -161,6 +161,18 @@ impl From<module_runtime::RuntimeProfileRechargeRefundHoldListInput>
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput>
|
||||
for RuntimeProfileRechargeRefundManualReviewResolveInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileRechargeRefundManualReviewResolveInput) -> Self {
|
||||
Self {
|
||||
out_refund_no: input.out_refund_no,
|
||||
admin_user_id: input.admin_user_id,
|
||||
reason: input.reason,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileAdminWalletGetInput> for RuntimeProfileAdminWalletGetInput {
|
||||
fn from(input: module_runtime::RuntimeProfileAdminWalletGetInput) -> Self {
|
||||
Self {
|
||||
@@ -1417,6 +1429,9 @@ pub(crate) fn map_runtime_profile_recharge_refund_snapshot(
|
||||
),
|
||||
last_recovery_ledger_id: snapshot.last_recovery_ledger_id,
|
||||
last_error_code: snapshot.last_error_code,
|
||||
manual_review_resolved_by_admin_user_id: snapshot.manual_review_resolved_by_admin_user_id,
|
||||
manual_review_resolution_reason: snapshot.manual_review_resolution_reason,
|
||||
manual_review_resolved_at_micros: snapshot.manual_review_resolved_at_micros,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1012,6 +1012,7 @@ pub mod resolve_npc_interaction_reducer;
|
||||
pub mod resolve_npc_social_action_and_return_procedure;
|
||||
pub mod resolve_npc_social_action_input_type;
|
||||
pub mod resolve_npc_social_action_reducer;
|
||||
pub mod resolve_profile_recharge_refund_manual_review_and_return_procedure;
|
||||
pub mod resolve_treasure_interaction_and_return_procedure;
|
||||
pub mod resolve_treasure_interaction_reducer;
|
||||
pub mod restart_jump_hop_run_procedure;
|
||||
@@ -1112,6 +1113,7 @@ 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_manual_review_resolve_input_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;
|
||||
@@ -2411,6 +2413,7 @@ pub use resolve_npc_interaction_reducer::resolve_npc_interaction;
|
||||
pub use resolve_npc_social_action_and_return_procedure::resolve_npc_social_action_and_return;
|
||||
pub use resolve_npc_social_action_input_type::ResolveNpcSocialActionInput;
|
||||
pub use resolve_npc_social_action_reducer::resolve_npc_social_action;
|
||||
pub use resolve_profile_recharge_refund_manual_review_and_return_procedure::resolve_profile_recharge_refund_manual_review_and_return;
|
||||
pub use resolve_treasure_interaction_and_return_procedure::resolve_treasure_interaction_and_return;
|
||||
pub use resolve_treasure_interaction_reducer::resolve_treasure_interaction;
|
||||
pub use restart_jump_hop_run_procedure::restart_jump_hop_run;
|
||||
@@ -2511,6 +2514,7 @@ pub use runtime_profile_recharge_refund_hold_release_input_type::RuntimeProfileR
|
||||
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_manual_review_resolve_input_type::RuntimeProfileRechargeRefundManualReviewResolveInput;
|
||||
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;
|
||||
|
||||
@@ -33,6 +33,9 @@ pub struct ProfileRechargeRefund {
|
||||
pub recovery_status: RuntimeProfileRechargeRefundRecoveryStatus,
|
||||
pub last_recovery_ledger_id: Option<String>,
|
||||
pub last_error_code: Option<String>,
|
||||
pub manual_review_resolved_by_admin_user_id: Option<String>,
|
||||
pub manual_review_resolution_reason: Option<String>,
|
||||
pub manual_review_resolved_at: Option<__sdk::Timestamp>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ProfileRechargeRefund {
|
||||
@@ -73,6 +76,12 @@ pub struct ProfileRechargeRefundCols {
|
||||
>,
|
||||
pub last_recovery_ledger_id: __sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
|
||||
pub last_error_code: __sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
|
||||
pub manual_review_resolved_by_admin_user_id:
|
||||
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
|
||||
pub manual_review_resolution_reason:
|
||||
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<String>>,
|
||||
pub manual_review_resolved_at:
|
||||
__sdk::__query_builder::Col<ProfileRechargeRefund, Option<__sdk::Timestamp>>,
|
||||
}
|
||||
|
||||
impl __sdk::__query_builder::HasCols for ProfileRechargeRefund {
|
||||
@@ -116,6 +125,18 @@ impl __sdk::__query_builder::HasCols for ProfileRechargeRefund {
|
||||
"last_recovery_ledger_id",
|
||||
),
|
||||
last_error_code: __sdk::__query_builder::Col::new(table_name, "last_error_code"),
|
||||
manual_review_resolved_by_admin_user_id: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"manual_review_resolved_by_admin_user_id",
|
||||
),
|
||||
manual_review_resolution_reason: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"manual_review_resolution_reason",
|
||||
),
|
||||
manual_review_resolved_at: __sdk::__query_builder::Col::new(
|
||||
table_name,
|
||||
"manual_review_resolved_at",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
@@ -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_manual_review_resolve_input_type::RuntimeProfileRechargeRefundManualReviewResolveInput;
|
||||
use super::runtime_profile_recharge_refund_procedure_result_type::RuntimeProfileRechargeRefundProcedureResult;
|
||||
|
||||
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
|
||||
#[sats(crate = __lib)]
|
||||
struct ResolveProfileRechargeRefundManualReviewAndReturnArgs {
|
||||
pub input: RuntimeProfileRechargeRefundManualReviewResolveInput,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ResolveProfileRechargeRefundManualReviewAndReturnArgs {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
|
||||
#[allow(non_camel_case_types)]
|
||||
/// Extension trait for access to the procedure `resolve_profile_recharge_refund_manual_review_and_return`.
|
||||
///
|
||||
/// Implemented for [`super::RemoteProcedures`].
|
||||
pub trait resolve_profile_recharge_refund_manual_review_and_return {
|
||||
fn resolve_profile_recharge_refund_manual_review_and_return(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
|
||||
) {
|
||||
self.resolve_profile_recharge_refund_manual_review_and_return_then(input, |_, _| {});
|
||||
}
|
||||
|
||||
fn resolve_profile_recharge_refund_manual_review_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeRefundProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
);
|
||||
}
|
||||
|
||||
impl resolve_profile_recharge_refund_manual_review_and_return for super::RemoteProcedures {
|
||||
fn resolve_profile_recharge_refund_manual_review_and_return_then(
|
||||
&self,
|
||||
input: RuntimeProfileRechargeRefundManualReviewResolveInput,
|
||||
|
||||
__callback: impl FnOnce(
|
||||
&super::ProcedureEventContext,
|
||||
Result<RuntimeProfileRechargeRefundProcedureResult, __sdk::InternalError>,
|
||||
) + Send
|
||||
+ 'static,
|
||||
) {
|
||||
self.imp
|
||||
.invoke_procedure_with_callback::<_, RuntimeProfileRechargeRefundProcedureResult>(
|
||||
"resolve_profile_recharge_refund_manual_review_and_return",
|
||||
ResolveProfileRechargeRefundManualReviewAndReturnArgs { input },
|
||||
__callback,
|
||||
);
|
||||
}
|
||||
}
|
||||
+17
@@ -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 RuntimeProfileRechargeRefundManualReviewResolveInput {
|
||||
pub out_refund_no: String,
|
||||
pub admin_user_id: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for RuntimeProfileRechargeRefundManualReviewResolveInput {
|
||||
type Module = super::RemoteModule;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user