Merge branch 'master' into extract-wallet-entry-to-shared

This commit is contained in:
2026-07-14 16:52:47 +08:00
124 changed files with 20247 additions and 362 deletions
@@ -0,0 +1,117 @@
import {afterEach, expect, test, vi} from 'vitest';
import {
executeAdminRechargeRefund,
getAdminUserDetail,
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
} 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: '用户申请',
}),
}),
);
});
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: '已核对微信商户平台原始账单',
}),
}),
);
});
+111
View File
@@ -26,6 +26,14 @@ import type {
AdminLoginResponse,
AdminMeResponse,
AdminOverviewResponse,
AdminRechargeOrderListQuery,
AdminRechargeOrderListResponse,
AdminRechargeRefundActionResponse,
AdminRechargeRefundExecuteRequest,
AdminRechargeRefundManualReviewResolveRequest,
AdminRechargeRefundPreviewRequest,
AdminRechargeRefundPreviewResponse,
AdminRechargeRefundRegisterRequest,
AdminTrackingEventListQuery,
AdminTrackingEventKeyListResponse,
AdminTrackingEventListResponse,
@@ -41,6 +49,10 @@ import type {
AdminUpsertProfileWalletConfigRequest,
AdminUpsertPublicWorkInteractionConfigRequest,
AdminWorkVisibilityListResponse,
AdminUserDetailQuery,
AdminUserDetailResponse,
AdminWalletRestrictionRequest,
AdminWalletRestrictionResponse,
ApiErrorEnvelope,
ApiMeta,
ApiSuccessEnvelope,
@@ -580,6 +592,76 @@ 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 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,
) {
return request<AdminWalletRestrictionResponse>(
'/admin/api/profile/wallet-restriction',
{method: 'POST', token, body: payload},
);
}
function normalizeBaseUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
@@ -747,6 +829,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);
+181
View File
@@ -738,3 +738,184 @@ 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;
manualReviewResolvedByAdminUserId?: string | null;
manualReviewResolutionReason?: string | null;
manualReviewResolvedAtMicros?: number | 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 AdminRechargeRefundManualReviewResolveRequest {
outRefundNo: string;
reason: 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: '-' };
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,10 @@
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
getAdminAssetReadUrl,
isAdminApiError,
listAdminEditorAssets,
} from '../api/adminApiClient';
import type { AdminAssetReadUrlResponse } from '../api/adminApiClient';
@@ -11,6 +12,7 @@ import type {
AdminEditorAssetListQuery,
AdminEditorAssetPayload,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
interface AdminEditorAssetQueryPageProps {
@@ -19,7 +21,11 @@ interface AdminEditorAssetQueryPageProps {
}
const ADMIN_ASSET_READ_EXPIRE_SECONDS = 300;
const ADMIN_ASSET_READ_DISPATCH_SPACING_MS = 40;
const ADMIN_ASSET_READ_RETRY_DELAYS_MS = [400, 1_200, 3_000] as const;
const ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN = '240px 0px';
const AUDIO_ASSET_COVER_SRC = `${import.meta.env.DEV ? import.meta.env.BASE_URL : '/'}creation-home/audio-asset-cover.png`;
let adminAssetReadDispatchTail = Promise.resolve();
export function AdminEditorAssetQueryPage({
token,
@@ -180,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
@@ -236,6 +254,7 @@ export function AdminEditorAssetQueryPage({
<AdminAssetDetailDialog
entry={detailEntry}
token={token}
onUnauthorized={onUnauthorized}
onClose={() => setDetailEntry(null)}
onPreview={(entry) => setPreviewEntry(entry)}
onPromptPreview={(entry, prompt) =>
@@ -295,17 +314,27 @@ function AdminAssetThumbnail({
token: string;
}) {
const thumbnailSource = resolveAdminAssetThumbnailSource(entry);
const { observeElement, shouldLoad } = useAdminAssetThumbnailVisibility();
const imageSrc = useAdminResolvedAssetUrl(
token,
thumbnailSource.src,
thumbnailSource.objectKey,
shouldLoad,
);
const alt = `素材:${entry.label || entry.assetId}`;
return imageSrc ? (
<img alt={alt} className="admin-asset-query-thumb" src={imageSrc} />
<img
ref={observeElement}
alt={alt}
className="admin-asset-query-thumb"
src={imageSrc}
/>
) : (
<div className="admin-asset-query-thumb admin-asset-query-thumb-placeholder" />
<div
ref={observeElement}
className="admin-asset-query-thumb admin-asset-query-thumb-placeholder"
/>
);
}
@@ -317,12 +346,52 @@ function resolveAdminAssetThumbnailSource(entry: AdminEditorAssetPayload) {
if (mediaKind === 'video') {
return { src: entry.thumbnailSrc || '', objectKey: null };
}
if (entry.thumbnailSrc?.trim()) {
return {
src: entry.thumbnailSrc,
objectKey: adminAssetPathsMatch(entry.thumbnailSrc, entry.imageSrc)
? entry.objectKey
: null,
};
}
return {
src: entry.thumbnailSrc || entry.imageSrc,
src: entry.imageSrc,
objectKey: entry.objectKey,
};
}
function useAdminAssetThumbnailVisibility() {
const [element, setElement] = useState<HTMLElement | null>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const observeElement = useCallback((nextElement: HTMLElement | null) => {
setElement(nextElement);
}, []);
useEffect(() => {
if (shouldLoad || !element) {
return;
}
if (typeof IntersectionObserver === 'undefined') {
setShouldLoad(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: ADMIN_ASSET_THUMBNAIL_ROOT_MARGIN },
);
observer.observe(element);
return () => observer.disconnect();
}, [element, shouldLoad]);
return { observeElement, shouldLoad };
}
type AdminAssetMediaKind = 'image' | 'audio' | 'video';
function resolveAdminAssetMediaKind(
@@ -387,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;
@@ -427,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="尺寸">
@@ -604,6 +685,7 @@ function useAdminResolvedAssetUrl(
token: string,
imageSrc: string | null | undefined,
objectKey: string | null | undefined,
enabled = true,
) {
const normalizedImageSrc = imageSrc?.trim() ?? '';
const normalizedObjectKey = normalizeAdminObjectKey(objectKey);
@@ -625,38 +707,67 @@ function useAdminResolvedAssetUrl(
setResolvedImageSrc(normalizedImageSrc);
return;
}
if (!enabled) {
setResolvedImageSrc('');
return;
}
let cancelled = false;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
let retryIndex = 0;
const dispatchController = new AbortController();
setResolvedImageSrc('');
void getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
)
.then(resolveAdminAssetReadSignedUrl)
.then((signedUrl) => {
if (!cancelled) {
setResolvedImageSrc(signedUrl);
const resolveReadUrl = async () => {
try {
await waitForAdminAssetReadDispatch(dispatchController.signal);
if (cancelled) {
return;
}
})
.catch(() => {
const response = await getAdminAssetReadUrl(
token,
normalizedObjectKey
? {
objectKey: normalizedObjectKey,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
}
: {
legacyPublicPath: normalizedLegacyPublicPath,
expireSeconds: ADMIN_ASSET_READ_EXPIRE_SECONDS,
},
);
if (!cancelled) {
setResolvedImageSrc('');
setResolvedImageSrc(resolveAdminAssetReadSignedUrl(response));
}
});
} catch (error: unknown) {
if (cancelled) {
return;
}
const retryDelay = ADMIN_ASSET_READ_RETRY_DELAYS_MS[retryIndex];
if (
isAdminApiError(error) &&
error.status === 429 &&
typeof retryDelay === 'number'
) {
retryIndex += 1;
retryTimer = setTimeout(() => void resolveReadUrl(), retryDelay);
return;
}
setResolvedImageSrc('');
}
};
void resolveReadUrl();
return () => {
cancelled = true;
dispatchController.abort();
if (retryTimer !== null) {
clearTimeout(retryTimer);
}
};
}, [
enabled,
normalizedImageSrc,
normalizedLegacyPublicPath,
normalizedObjectKey,
@@ -667,10 +778,45 @@ function useAdminResolvedAssetUrl(
return resolvedImageSrc;
}
async function waitForAdminAssetReadDispatch(signal: AbortSignal) {
const dispatch = adminAssetReadDispatchTail.then(
() => waitForAdminAssetReadDispatchSpacing(signal),
() => waitForAdminAssetReadDispatchSpacing(signal),
);
adminAssetReadDispatchTail = dispatch.catch(() => undefined);
await dispatch;
}
async function waitForAdminAssetReadDispatchSpacing(signal: AbortSignal) {
if (signal.aborted) {
throw new DOMException('The operation was aborted.', 'AbortError');
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal.removeEventListener('abort', handleAbort);
resolve();
}, ADMIN_ASSET_READ_DISPATCH_SPACING_MS);
function handleAbort() {
clearTimeout(timer);
reject(new DOMException('The operation was aborted.', 'AbortError'));
}
signal.addEventListener('abort', handleAbort, { once: true });
});
}
function normalizeAdminObjectKey(value: string | null | undefined) {
return value?.trim().replace(/^\/+/u, '') ?? '';
}
function adminAssetPathsMatch(left: string, right: string) {
return (
left.trim().replace(/^\/+|[?#].*$/gu, '') ===
right.trim().replace(/^\/+|[?#].*$/gu, '')
);
}
function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
}
@@ -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>
File diff suppressed because it is too large Load Diff
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) {
+2
View File
@@ -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,10 +4057,31 @@
## 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。
- 权威查询:`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 角色可开启;生产 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。
## 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`

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