合并微信支付退款闭环

新增后台充值退款管理与微信 V3 退款闭环
统一退款观察、对账、权益追回和钱包冻结
修复成功退款冲突时点数钱包未冻结问题
保留后台素材预览连续加载修复
This commit is contained in:
2026-07-14 13:58:16 +08:00
111 changed files with 17462 additions and 150 deletions
@@ -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: '用户申请',
}),
}),
);
});
+100
View File
@@ -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<AdminRechargeOrderListResponse>(
`/admin/api/profile/recharge-orders${buildAdminRechargeOrderListQuery(query)}`,
{token},
);
}
export function getAdminUserDetail(
token: string,
query: AdminUserDetailQuery,
) {
return request<AdminUserDetailResponse>(
`/admin/api/profile/users/detail${buildAdminUserDetailQuery(query)}`,
{token},
);
}
export function previewAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundPreviewRequest,
) {
return request<AdminRechargeRefundPreviewResponse>(
'/admin/api/profile/recharge-refunds/preview',
{method: 'POST', token, body: payload},
);
}
export function executeAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundExecuteRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/execute',
{method: 'POST', token, body: payload},
);
}
export function registerAdminRechargeRefund(
token: string,
payload: AdminRechargeRefundRegisterRequest,
) {
return request<AdminRechargeRefundActionResponse>(
'/admin/api/profile/recharge-refunds/register',
{method: 'POST', token, body: payload},
);
}
export function updateAdminWalletRestriction(
token: string,
payload: AdminWalletRestrictionRequest,
) {
return request<AdminWalletRestrictionResponse>(
'/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);
+173
View File
@@ -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;
}
+7
View File
@@ -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' ? (
<AdminRechargeOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
+2
View File
@@ -18,6 +18,7 @@ import {
Table2,
TicketCheck,
TicketPercent,
ReceiptText,
} from 'lucide-react';
import type {ReactNode} from 'react';
@@ -45,6 +46,7 @@ const routeIcons = {
'profile-wallet': WalletCards,
tasks: ListChecks,
'recharge-products': BadgeDollarSign,
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'editor-assets': Images,
@@ -69,3 +69,13 @@ test('后台精选审核路由可通过导航和 hash 访问', () => {
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');
});
+2
View File
@@ -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'},
@@ -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(
<div onClick={parentClick}>
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>
</div>,
);
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(
<AdminUserReferenceButton
token="admin-token"
publicUserCode="TN1001"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
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(
<AdminUserReferenceButton
token="admin-token"
userId="user-1"
onUnauthorized={vi.fn()}
/>,
);
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);
});
@@ -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<AdminUserDetailResponse | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState('');
const [restrictionReason, setRestrictionReason] = useState('');
const [isSavingRestriction, setIsSavingRestriction] = useState(false);
const closeButtonRef = useRef<HTMLButtonElement | null>(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(
<div
aria-modal="true"
className="admin-confirm-backdrop admin-user-detail-backdrop"
role="dialog"
aria-labelledby="admin-user-detail-title"
onMouseDown={(event) => {
if (
event.target === event.currentTarget &&
!isSavingRestriction &&
!isConfirming
) {
onClose();
}
}}
>
<section className="admin-detail-panel admin-user-detail-panel">
<div className="admin-panel-heading">
<div>
<h3 id="admin-user-detail-title"></h3>
<span>{detail?.publicUserCode || publicUserCode || userId || '-'}</span>
</div>
<div className="admin-detail-actions">
<button
aria-label="刷新用户信息"
className="admin-ghost-button"
disabled={isLoading}
title="刷新"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
</button>
<button
ref={closeButtonRef}
aria-label="关闭用户详情"
className="admin-ghost-button"
disabled={isSavingRestriction}
title="关闭"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
</div>
</div>
{isLoading ? (
<div className="admin-user-detail-loading" role="status">
<div className="admin-loading-mark" />
<span></span>
</div>
) : errorMessage && !detail ? (
<div className="admin-user-detail-error">
<div className="admin-alert" role="status">
{errorMessage}
</div>
<button
className="admin-secondary-button"
type="button"
onClick={() => void loadDetail()}
>
<RefreshCcw size={17} aria-hidden="true" />
<span></span>
</button>
</div>
) : detail ? (
<>
<UserIdentityHeader detail={detail} />
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<WalletSection wallet={detail.wallet} />
<section className="admin-user-restriction-section">
<div className="admin-panel-heading">
<h3></h3>
<span>
{detail.wallet.manualFrozen ? '当前已冻结' : '当前未冻结'}
</span>
</div>
{detail.wallet.manualRestriction ? (
<div className="admin-user-restriction-record">
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{detail.wallet.manualRestriction.updatedByAdminUserId}
</small>
</div>
) : null}
{detail.wallet.manualFrozen && detail.wallet.refundDebtFrozen ? (
<div className="admin-alert admin-alert-warning" role="status">
<ShieldAlert size={17} aria-hidden="true" />
<span>退</span>
</div>
) : null}
<div className="admin-user-restriction-actions">
<label className="admin-field admin-field-fill">
<span></span>
<input
aria-label="人工冻结操作原因"
disabled={isSavingRestriction}
value={restrictionReason}
onChange={(event) => setRestrictionReason(event.target.value)}
/>
</label>
<button
className={
detail.wallet.manualFrozen
? 'admin-secondary-button'
: 'admin-danger-button'
}
disabled={isSavingRestriction || !restrictionReason.trim()}
type="button"
onClick={() => void handleRestrictionChange()}
>
<ShieldAlert size={17} aria-hidden="true" />
<span>
{isSavingRestriction
? '处理中'
: detail.wallet.manualFrozen
? '解除人工冻结'
: '人工冻结钱包'}
</span>
</button>
</div>
</section>
<section className="admin-user-recharge-section">
<div className="admin-panel-heading">
<h3></h3>
<span>{detail.rechargeOrders.length} </span>
</div>
{detail.rechargeOrders.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-user-recharge-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>退</th>
<th></th>
</tr>
</thead>
<tbody>
{detail.rechargeOrders.map((order) => (
<tr key={order.orderId}>
<td>
<span className="admin-mono-value">{order.orderId}</span>
<small>{formatMicros(order.createdAtMicros)}</small>
</td>
<td>
{order.productTitle || order.productId}
<small> {order.pointsDelta} </small>
</td>
<td>{formatMoney(order.amountCents)}</td>
<td>
{formatMoney(order.cumulativeSuccessRefundCents)}
<small> {order.unrecoveredPoints} </small>
</td>
<td>{formatOrderStatus(order.status)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state"></div>
)}
</section>
</>
) : null}
</section>
{confirmDialog}
</div>,
document.body,
);
}
function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
return (
<section className="admin-user-identity">
<div className="admin-user-avatar">
{detail.avatarUrl ? (
<img alt={`${detail.displayName || detail.publicUserCode}头像`} src={detail.avatarUrl} />
) : (
<UserRound size={30} aria-hidden="true" />
)}
</div>
<div className="admin-user-identity-primary">
<strong>{detail.displayName || '未设置昵称'}</strong>
<span>{detail.publicUserCode || '未分配陶泥号'}</span>
</div>
<dl className="admin-info-list admin-user-identity-list">
<div>
<dt> ID</dt>
<dd>{detail.userId}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.phoneNumberMasked || '未绑定'}</dd>
</div>
<div>
<dt></dt>
<dd>{detail.loginMethod || '-'}</dd>
</div>
<div>
<dt></dt>
<dd>
{detail.bindingStatus || '-'} / {detail.phoneBound ? '已绑定' : '未绑定'} /
{detail.wechatBound ? '已绑定' : '未绑定'}
</dd>
</div>
</dl>
</section>
);
}
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
const metrics = [
['总余额', wallet.totalBalance],
['可消费', wallet.spendableBalance],
['永久泥点', wallet.permanentPoints],
['每日免费', wallet.dailyFreePoints],
['会员限时', wallet.membershipLimitedPoints],
['退款占用', wallet.heldPoints],
['退款欠账', wallet.refundDebtPoints],
] as const;
return (
<section className="admin-user-wallet-section">
<div className="admin-panel-heading">
<h3></h3>
<div className="admin-tag-list">
{wallet.manualFrozen ? <span className="admin-tag"></span> : null}
{wallet.refundDebtFrozen ? (
<span className="admin-tag">退</span>
) : null}
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok"></span> : null}
</div>
</div>
<div className="admin-user-wallet-grid">
{metrics.map(([label, value]) => (
<div className="admin-recharge-metric" key={label}>
<span>{label}</span>
<strong>{value}</strong>
</div>
))}
</div>
</section>
);
}
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<string, string> = {
pending: '待支付',
paid: '已支付',
refunded: '已退款',
closed: '已关闭',
};
return labels[status.toLowerCase()] ?? status;
}
@@ -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<HTMLButtonElement | null>(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<HTMLButtonElement>) {
event.stopPropagation();
setOpen(true);
}
function closeDialog() {
setOpen(false);
window.requestAnimationFrame(() => triggerRef.current?.focus());
}
return (
<>
<button
ref={triggerRef}
aria-label="查看用户信息"
className="admin-ghost-button admin-user-reference-button"
title="查看用户信息"
type="button"
onClick={openDialog}
>
<UserRoundSearch size={16} aria-hidden="true" />
</button>
{open ? (
<AdminUserDetailDialog
token={token}
{...lookup}
onClose={closeDialog}
onUnauthorized={onUnauthorized}
/>
) : null}
</>
);
}
function normalizeUserReference(value?: string | null) {
const normalized = value?.trim() ?? '';
if (!normalized || normalized.toLowerCase().startsWith('admin:')) {
return '';
}
return normalized;
}
@@ -101,5 +101,9 @@ export function useAdminWriteConfirm() {
</div>
) : null;
return {confirmWrite, confirmDialog};
return {
confirmWrite,
confirmDialog,
isConfirming: pendingConfirm !== null,
};
}
@@ -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;
}) => (
<button
aria-label={`查看用户 ${userId || publicUserCode}`}
data-public-user-code={publicUserCode}
data-user-id={userId}
type="button"
onClick={(event) => event.stopPropagation()}
/>
),
}));
const referralRows = [
{
cells: {
@@ -203,6 +221,42 @@ test('后台表查询页把表头排序交给后端并从第一页展示排序
});
});
test('数据库用户字段显示查看按钮且点击不会打开行详情', async () => {
const user = userEvent.setup();
render(
<AdminDatabaseTablesPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const userButton = await screen.findByRole('button', { name: '查看用户 u-b' });
await user.click(userButton);
expect(screen.queryByRole('dialog')).toBeNull();
});
test('数据库用户字段识别会排除后台操作者与合成邀请码字段', () => {
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'owner_user_id', 'u-1'),
).toEqual({ userId: 'u-1' });
expect(
resolveAdminDatabaseUserReference(
'auth_store_projection',
'public_user_code',
'TN1001',
),
).toEqual({ publicUserCode: 'TN1001' });
expect(
resolveAdminDatabaseUserReference('audit_log', 'operator_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('audit_log', 'admin_user_id', 'u-1'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_wallet', 'user_id', 'admin:root'),
).toBeNull();
expect(
resolveAdminDatabaseUserReference('profile_invite_code', 'user_id', 'u-1'),
).toBeNull();
});
function readFirstColumnValues(container: HTMLElement) {
return Array.from(container.querySelectorAll('tbody tr')).map(
(row) => row.querySelector('td')?.textContent?.trim() ?? '',
@@ -26,6 +26,7 @@ import type {
AdminDatabaseTableRowPayload,
AdminDatabaseTableRowsResponse,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminDatabaseTablesPageProps {
@@ -486,14 +487,28 @@ export function AdminDatabaseTablesPage({
row.cells[column],
column,
);
const userReference = resolveAdminDatabaseUserReference(
result.tableName,
column,
row.cells[column],
);
return (
<td key={column}>
<span
className="admin-table-cell-ellipsis"
title={cellValue.fullText}
>
{cellValue.content}
</span>
<div className="admin-database-user-cell">
<span
className="admin-table-cell-ellipsis"
title={cellValue.fullText}
>
{cellValue.content}
</span>
{userReference ? (
<AdminUserReferenceButton
token={token}
{...userReference}
onUnauthorized={onUnauthorized}
/>
) : null}
</div>
</td>
);
})}
@@ -703,6 +718,43 @@ function buildRowKey(row: AdminDatabaseTableRowPayload, rowIndex: number) {
return `${rowIndex}-${String(firstValue ?? '')}`;
}
export function resolveAdminDatabaseUserReference(
tableName: string,
column: string,
value: unknown,
) {
if (typeof value !== 'string' && typeof value !== 'number') {
return null;
}
const normalizedValue = String(value).trim();
const normalizedColumn = column.trim().toLowerCase();
const normalizedTable = tableName.trim().toLowerCase();
if (!normalizedValue || normalizedValue.toLowerCase().startsWith('admin:')) {
return null;
}
if (
normalizedColumn === 'operator_user_id' ||
normalizedColumn.includes('admin_user_id') ||
(normalizedTable === 'profile_invite_code' &&
normalizedColumn === 'user_id')
) {
return null;
}
if (
normalizedColumn === 'public_user_code' ||
normalizedColumn.endsWith('_public_user_code')
) {
return {publicUserCode: normalizedValue};
}
if (
normalizedColumn === 'user_id' ||
normalizedColumn.endsWith('_user_id')
) {
return {userId: normalizedValue};
}
return null;
}
function formatCellValue(value: unknown, column = ''): FormattedTableCellValue {
if (value === null || typeof value === 'undefined' || value === '') {
return { content: '-', fullText: '-' };
@@ -106,6 +106,23 @@ function installIntersectionObserverMock(): MockIntersectionObserverController {
};
}
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({
userId,
publicUserCode,
}: {
userId?: string;
publicUserCode?: string | null;
}) => (
<button
aria-label="查看素材作者"
data-public-user-code={publicUserCode ?? undefined}
data-user-id={userId}
type="button"
/>
),
}));
const generatedAsset: AdminEditorAssetPayload = {
assetId: 'asset-1',
ownerUserId: 'user-1',
@@ -182,6 +199,9 @@ test('后台素材查询展示作者昵称和陶泥号', async () => {
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
expect(screen.queryByText('user-1')).toBeNull();
const userButton = screen.getByRole('button', { name: '查看素材作者' });
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
});
test('后台素材查询按用户、搜索和时间调用查询接口', async () => {
@@ -12,6 +12,7 @@ import type {
AdminEditorAssetListQuery,
AdminEditorAssetPayload,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminEditorAssetQueryPageProps {
@@ -185,8 +186,20 @@ export function AdminEditorAssetQueryPage({
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<button
@@ -241,6 +254,7 @@ export function AdminEditorAssetQueryPage({
<AdminAssetDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
@@ -442,9 +456,11 @@ function AdminAssetDetailDialog({
onClose,
onPreview,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorAssetPayload;
token: string;
onUnauthorized: (message?: string) => void;
onClose: () => void;
onPreview: (entry: AdminEditorAssetPayload) => void;
onPromptPreview: (entry: AdminEditorAssetPayload, prompt: string) => void;
@@ -482,8 +498,18 @@ function AdminAssetDetailDialog({
</button>
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="尺寸">
@@ -31,6 +31,20 @@ vi.mock('../api/adminApiClient', () => ({
upsertAdminEditorShowcaseCampaign: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: ({ userId, publicUserCode }: {
userId?: string;
publicUserCode?: string | null;
}) => (
<button
aria-label="查看精选作者"
data-public-user-code={publicUserCode ?? undefined}
data-user-id={userId}
type="button"
/>
),
}));
const pendingShowcaseAsset: AdminEditorShowcaseAssetPayload = {
showcaseId: 'showcase-1',
assetId: 'asset-1',
@@ -156,6 +170,9 @@ test('后台精选审核展示待审核素材和活动卡配置', async () => {
expect(await screen.findByText('作者昵称')).toBeTruthy();
expect(screen.getByText('SY-00000042')).toBeTruthy();
const userButton = screen.getByRole('button', { name: '查看精选作者' });
expect(userButton.getAttribute('data-user-id')).toBe('user-1');
expect(userButton.getAttribute('data-public-user-code')).toBe('SY-00000042');
expect(screen.getAllByText('待审核').length).toBeGreaterThanOrEqual(2);
expect(screen.getByText('12 泥点')).toBeTruthy();
expect(await screen.findByDisplayValue('活动卡')).toBeTruthy();
@@ -17,6 +17,7 @@ import type {
AdminEditorShowcaseCampaignPayload,
AdminEditorShowcaseListQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminEditorShowcaseReviewPageProps {
@@ -351,8 +352,18 @@ export function AdminEditorShowcaseReviewPage({
</td>
<td>{formatDateTime(entry.submittedAt)}</td>
<td>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
{entry.reviewStatus === 'approved' ? (
@@ -598,6 +609,7 @@ export function AdminEditorShowcaseReviewPage({
<AdminShowcaseDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPromptPreview={(entry, prompt) =>
setPromptPreview({
@@ -677,10 +689,12 @@ function AdminShowcaseDetailDialog({
token,
onClose,
onPromptPreview,
onUnauthorized,
}: {
entry: AdminEditorShowcaseAssetPayload;
token: string;
onClose: () => void;
onUnauthorized: (message?: string) => void;
onPromptPreview: (
entry: AdminEditorShowcaseAssetPayload,
prompt: string,
@@ -712,8 +726,18 @@ function AdminShowcaseDetailDialog({
<AdminShowcaseThumbnail entry={entry} token={token} />
<dl className="admin-info-list admin-detail-list">
<AdminInfoItem label="作者">
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</AdminInfoItem>
<AdminInfoItem label="用户 ID">{entry.ownerUserId}</AdminInfoItem>
<AdminInfoItem label="素材 ID">{entry.assetId}</AdminInfoItem>
@@ -0,0 +1,346 @@
/* @vitest-environment jsdom */
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test, vi } from 'vitest';
import {
executeAdminRechargeRefund,
listAdminRechargeOrders,
previewAdminRechargeRefund,
registerAdminRechargeRefund,
} from '../api/adminApiClient';
import type {
AdminRechargeOrderEntryPayload,
AdminRechargeRefundActionResponse,
AdminRechargeRefundPreviewResponse,
} from '../api/adminApiTypes';
import { AdminRechargeOrderPage } from './AdminRechargeOrderPage';
vi.mock('../api/adminApiClient', () => ({
executeAdminRechargeRefund: vi.fn(),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
isAdminApiError: vi.fn(() => false),
listAdminRechargeOrders: vi.fn(),
previewAdminRechargeRefund: vi.fn(),
registerAdminRechargeRefund: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: () => (
<button aria-label="查看用户信息" type="button" />
),
}));
vi.mock('../components/useAdminWriteConfirm', () => ({
useAdminWriteConfirm: () => ({
confirmDialog: null,
confirmWrite: vi.fn(() => Promise.resolve(true)),
}),
}));
const baseOrder: AdminRechargeOrderEntryPayload = {
orderId: 'order-1001',
userId: 'user-1',
user: {
userId: 'user-1',
publicUserCode: 'TN1001',
displayName: '测试用户',
avatarUrl: null,
},
productId: 'points_60',
productTitle: '60泥点',
productKind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
paidAtMicros: 1_720_000_000_000_000,
providerTransactionId: 'wx-transaction-1',
createdAtMicros: 1_720_000_000_000_000,
pointsDelta: 60,
cumulativeSuccessRefundCents: 0,
targetRecoveryPoints: 0,
recoveredPoints: 0,
unrecoveredPoints: 0,
recoveryStatus: null,
wallet: {
userId: 'user-1',
totalBalance: 60,
spendableBalance: 60,
dailyFreePoints: 0,
membershipLimitedPoints: 0,
permanentPoints: 60,
heldPoints: 0,
refundDebtPoints: 0,
manualFrozen: false,
refundDebtFrozen: false,
walletFrozen: false,
manualRestriction: null,
},
refunds: [],
activeHold: null,
remainingRefundableCents: 600,
refundEligible: true,
refundBlockReasonCode: null,
};
const allowedPreview: AdminRechargeRefundPreviewResponse = {
order: baseOrder,
paymentCheck: {
verified: true,
tradeState: 'SUCCESS',
transactionId: 'wx-transaction-1',
amountTotalCents: 600,
knownRefundsRefreshed: 0,
},
refundAmountCents: 300,
incrementalRecoveryPoints: 30,
remainingRefundableCents: 600,
canSubmit: true,
blockReasonCode: null,
};
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
entries: [baseOrder],
});
vi.mocked(previewAdminRechargeRefund).mockResolvedValue(allowedPreview);
vi.mocked(executeAdminRechargeRefund).mockResolvedValue(actionFor(baseOrder));
vi.mocked(registerAdminRechargeRefund).mockResolvedValue(
actionFor(baseOrder),
);
});
test('充值订单查询传递全部筛选字段', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText('order-1001');
await user.type(screen.getByRole('textbox', { name: '订单号' }), 'order-x');
await user.type(
screen.getByRole('textbox', { name: '微信交易单号' }),
'wx-x',
);
await user.type(screen.getByRole('textbox', { name: '陶泥号' }), 'TNX');
await user.selectOptions(screen.getByLabelText('支付渠道'), 'wechat_native');
await user.selectOptions(screen.getByLabelText('订单状态'), 'paid');
fireEvent.change(screen.getByLabelText('创建时间起'), {
target: { value: '2026-07-01T08:30' },
});
fireEvent.change(screen.getByLabelText('创建时间止'), {
target: { value: '2026-07-13T18:45' },
});
await user.clear(screen.getByLabelText('条数'));
await user.type(screen.getByLabelText('条数'), '50');
await user.click(screen.getByRole('button', { name: '查询' }));
await waitFor(() => {
expect(listAdminRechargeOrders).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({
orderId: 'order-x',
providerTransactionId: 'wx-x',
userId: '',
publicUserCode: 'TNX',
paymentChannel: 'wechat_native',
status: 'paid',
createdAfter: new Date('2026-07-01T08:30').toISOString(),
createdBefore: new Date('2026-07-13T18:45').toISOString(),
limit: 50,
}),
);
});
});
test('修改部分退款金额会作废已有预检', async () => {
const user = userEvent.setup();
renderPage();
await openPartialRefund(user);
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
await user.type(screen.getByLabelText('退款原因'), '退还剩余金额');
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
expect(
await screen.findByRole('region', { name: '退款预检结果' }),
).toBeTruthy();
await user.clear(screen.getByLabelText('退款金额(元)'));
await user.type(screen.getByLabelText('退款金额(元)'), '2.00');
expect(screen.queryByRole('region', { name: '退款预检结果' })).toBeNull();
expect(
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
).toBe(true);
});
test('预检被阻止时不会调用退款执行接口', async () => {
const user = userEvent.setup();
vi.mocked(previewAdminRechargeRefund).mockResolvedValue({
...allowedPreview,
canSubmit: false,
blockReasonCode: 'insufficient_permanent_points',
});
renderPage();
await openPartialRefund(user);
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
expect(
await screen.findByText('用户永久泥点不足,本次不会向微信发起退款'),
).toBeTruthy();
expect(
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
).toBe(true);
expect(executeAdminRechargeRefund).not.toHaveBeenCalled();
});
test('已登记的部分退款允许在 REFUND 支付状态下继续退剩余额度', async () => {
const user = userEvent.setup();
vi.mocked(previewAdminRechargeRefund).mockResolvedValue({
...allowedPreview,
paymentCheck: {
...allowedPreview.paymentCheck,
verified: true,
tradeState: 'REFUND',
knownRefundsRefreshed: 1,
},
remainingRefundableCents: 300,
});
renderPage();
await openPartialRefund(user);
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
await user.type(screen.getByLabelText('退款原因'), '退还剩余金额');
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
expect(await screen.findByText('已核验 / REFUND')).toBeTruthy();
expect(
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
).toBe(false);
});
test('网络结果不明后重新预检会复用稳定 requestId 且双击只提交一次', async () => {
const user = userEvent.setup();
vi.mocked(executeAdminRechargeRefund)
.mockRejectedValueOnce(new Error('network disconnected'))
.mockResolvedValueOnce(actionFor(baseOrder));
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.dblClick(screen.getByRole('button', { name: '确认退款' }));
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1);
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
.calls[0]?.[1].requestId;
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
await user.dblClick(screen.getByRole('button', { name: '确认退款' }));
await waitFor(() =>
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
);
expect(
vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1].requestId,
).toBe(firstRequestId);
});
test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => {
const user = userEvent.setup();
const debtOrder = {
...baseOrder,
unrecoveredPoints: 25,
wallet: {
...baseOrder.wallet,
spendableBalance: 0,
permanentPoints: 0,
refundDebtPoints: 25,
refundDebtFrozen: true,
walletFrozen: true,
},
};
vi.mocked(executeAdminRechargeRefund).mockResolvedValue(actionFor(debtOrder));
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: '确认退款' }));
const banner = await screen.findByText('退款异常欠账 25 泥点');
expect(banner).toBeTruthy();
expect(
screen.getByText(
'支付侧退款已记录,当前泥点不足,已限制继续消费并进入人工处理。',
),
).toBeTruthy();
});
test('商户平台退款登记只提交 outRefundNo', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText('order-1001');
await user.click(screen.getByRole('button', { name: '登记商户退款' }));
const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' });
await user.type(
within(dialog).getByRole('textbox', { name: '商户退款单号 out_refund_no' }),
'refund-1001',
);
await user.click(within(dialog).getByRole('button', { name: '查询并登记' }));
await waitFor(() => {
expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', {
outRefundNo: 'refund-1001',
});
});
});
test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText('order-1001');
await user.click(screen.getByRole('button', { name: '登记商户退款' }));
const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' });
await user.type(
within(dialog).getByRole('textbox', { name: '商户退款单号 out_refund_no' }),
'50000000000000000000000000000',
);
await user.click(within(dialog).getByRole('button', { name: '查询并登记' }));
await waitFor(() => {
expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', {
outRefundNo: '50000000000000000000000000000',
});
});
});
function renderPage() {
return render(
<AdminRechargeOrderPage token="admin-token" onUnauthorized={vi.fn()} />,
);
}
async function openPartialRefund(user: ReturnType<typeof userEvent.setup>) {
await screen.findByText('order-1001');
await user.click(screen.getByRole('button', { name: '退款' }));
await user.click(screen.getByRole('button', { name: '部分退款' }));
}
function actionFor(
order: AdminRechargeOrderEntryPayload,
): AdminRechargeRefundActionResponse {
return {
outRefundNo: 'refund-1001',
providerStatus: 'SUCCESS',
resultCode: 'settled',
providerStatusUnknown: false,
order,
};
}
File diff suppressed because it is too large Load Diff
@@ -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({
<small>dayKey: {entry.dayKey}</small>
</td>
<td>
{entry.userId || '-'}
<small>owner: {entry.ownerUserId || '-'}</small>
<div className="admin-tracking-user-reference">
<span>{entry.userId || '-'}</span>
{entry.userId ? (
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
) : null}
</div>
<div className="admin-tracking-user-reference">
<small>owner: {entry.ownerUserId || '-'}</small>
{entry.ownerUserId ? (
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
onUnauthorized={onUnauthorized}
/>
) : null}
</div>
</td>
<td>
{entry.profileId || '-'}
@@ -364,6 +383,8 @@ export function AdminTrackingEventsPage({
{detailEntry ? (
<TrackingEventDetailPanel
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => 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({
<pre className="admin-code-block">
{formatMetadataJson(entry.metadataJson)}
</pre>
) : column.key === 'userId' && entry.userId ? (
<div className="admin-inline-identity">
<span>{entry.userId}</span>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</div>
) : column.key === 'ownerUserId' && entry.ownerUserId ? (
<div className="admin-inline-identity">
<span>{entry.ownerUserId}</span>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
onUnauthorized={onUnauthorized}
/>
</div>
) : (
formatExportCell(entry[column.key], column.key) || '-'
)}
@@ -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({
<small>{entry.subtitle || entry.profileId}</small>
</td>
<td>
{entry.authorDisplayName || '玩家'}
<small>{entry.ownerUserId}</small>
<div className="admin-inline-identity">
<div>
{entry.authorDisplayName || '玩家'}
<small>{entry.ownerUserId}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<span className="admin-table-cell-ellipsis">
+345 -3
View File
@@ -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;
+11
View File
@@ -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) {
@@ -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`
@@ -2128,6 +2128,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.`,或结果页能看到历史生成记录但试玩、发布和正式运行态仍显示默认积木。
@@ -3024,3 +3048,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 域名>/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`

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