diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index 97ec8bbb6..55e87cce5 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -6,6 +6,7 @@ import { getAdminFeatureGateConfig, getAdminUserDetail, listAdminRechargeOrders, + reconcileAdminUserConsumption, resolveAdminRechargeRefundManualReview, updateAdminAccount, uploadAdminEditorShowcaseCampaignImage, @@ -16,7 +17,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => { +test('后台账号创建和更新同时携带 Tab 与独立操作权限', async () => { const fetchMock = vi.fn().mockImplementation(() => Promise.resolve( new Response(JSON.stringify({ account: { accountId: 'member-1' } }), { @@ -31,11 +32,13 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => displayName: '运营', password: 'secret123', tabPermissions: ['dashboard', 'tracking'], + actionPermissions: ['profile-wallet-consumption-reconcile'], enabled: true, }); await updateAdminAccount('owner-token', 'member/1', { displayName: '运营二组', tabPermissions: ['tracking'], + actionPermissions: [], enabled: false, }); @@ -44,6 +47,14 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ Authorization: 'Bearer owner-token' }), + body: JSON.stringify({ + username: 'operator', + displayName: '运营', + password: 'secret123', + tabPermissions: ['dashboard', 'tracking'], + actionPermissions: ['profile-wallet-consumption-reconcile'], + enabled: true, + }), }), ); expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1'); @@ -53,6 +64,7 @@ test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => body: JSON.stringify({ displayName: '运营二组', tabPermissions: ['tracking'], + actionPermissions: [], enabled: false, }), }), @@ -238,6 +250,33 @@ test('用户详情只发送实际提供的用户定位字段', async () => { }); }); +test('历史花费手动对账使用独立管理员写接口', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + userId: 'user-1', + historicalConsumedPoints: 1300, + changed: true, + }), + { status: 200 }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + + await reconcileAdminUserConsumption('token-1', { userId: 'user-1' }); + + expect(String(fetchMock.mock.calls[0]?.[0])).toBe( + '/admin/api/profile/users/reconcile-consumption', + ); + expect(fetchMock.mock.calls[0]?.[1]).toEqual( + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ Authorization: 'Bearer token-1' }), + body: JSON.stringify({ userId: 'user-1' }), + }), + ); +}); + test('退款执行使用独立 execute 管理员路由', async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ outRefundNo: 'refund-1' }), { diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index e3697bb90..9912b9cf7 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -48,6 +48,8 @@ import type { AdminUpsertProfileRedeemCodeRequest, AdminUpsertProfileTaskConfigRequest, AdminUpsertProfileWalletConfigRequest, + AdminUserConsumptionReconcileRequest, + AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse, AdminWalletRestrictionRequest, @@ -577,6 +579,16 @@ export function getAdminUserDetail(token: string, query: AdminUserDetailQuery) { ); } +export function reconcileAdminUserConsumption( + token: string, + payload: AdminUserConsumptionReconcileRequest, +) { + return request( + '/admin/api/profile/users/reconcile-consumption', + { method: 'POST', token, body: payload }, + ); +} + export function previewAdminRechargeRefund( token: string, payload: AdminRechargeRefundPreviewRequest, diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 3900ab01d..88933171a 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -38,6 +38,7 @@ export interface AdminSessionPayload { roles: string[]; accountRole: 'owner' | 'member'; tabPermissions: string[]; + actionPermissions: string[]; issuedAt: string; expiresAt: string; } @@ -48,6 +49,7 @@ export interface AdminAccountPayload { displayName: string; accountRole: 'owner' | 'member'; tabPermissions: string[]; + actionPermissions: string[]; enabled: boolean; tokenVersion: number; createdBy: string; @@ -65,6 +67,7 @@ export interface AdminCreateAccountRequest { displayName: string; password: string; tabPermissions: string[]; + actionPermissions: string[]; enabled: boolean; } @@ -76,6 +79,7 @@ export interface AdminUpdateAccountRequest { displayName: string; password?: string; tabPermissions: string[]; + actionPermissions: string[]; enabled: boolean; } @@ -819,10 +823,24 @@ export interface AdminUserDetailResponse { bindingStatus: string; phoneBound: boolean; wechatBound: boolean; + historicalConsumedPoints: number; + canReconcileConsumption: boolean; wallet: AdminProfileWalletPayload; rechargeOrders: AdminRechargeOrderEntryPayload[]; } +export interface AdminUserConsumptionReconcileRequest { + userId: string; +} + +export interface AdminUserConsumptionReconcileResponse { + userId: string; + previousHistoricalConsumedPoints?: number | null; + historicalConsumedPoints: number; + changed: boolean; + reconciledAtMicros: number; +} + export interface AdminRechargeRefundPreviewRequest { orderId: string; refundAmountCents: number; diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx index 341c6f0e7..8760a7907 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx @@ -6,6 +6,7 @@ import {beforeEach, expect, test, vi} from 'vitest'; import { getAdminUserDetail, + reconcileAdminUserConsumption, updateAdminWalletRestriction, } from '../api/adminApiClient'; import type { @@ -20,6 +21,7 @@ vi.mock('../api/adminApiClient', () => ({ ), getAdminUserDetail: vi.fn(), isAdminApiError: vi.fn(() => false), + reconcileAdminUserConsumption: vi.fn(), updateAdminWalletRestriction: vi.fn(), })); @@ -48,6 +50,8 @@ const detail: AdminUserDetailResponse = { bindingStatus: 'bound', phoneBound: true, wechatBound: true, + historicalConsumedPoints: 1234, + canReconcileConsumption: true, wallet, rechargeOrders: [ { @@ -82,6 +86,13 @@ const detail: AdminUserDetailResponse = { beforeEach(() => { vi.clearAllMocks(); vi.mocked(getAdminUserDetail).mockResolvedValue(detail); + vi.mocked(reconcileAdminUserConsumption).mockResolvedValue({ + userId: 'user-1', + previousHistoricalConsumedPoints: 1234, + historicalConsumedPoints: 1300, + changed: true, + reconciledAtMicros: 1_720_000_000_000_000, + }); vi.mocked(updateAdminWalletRestriction).mockResolvedValue({wallet}); }); @@ -111,6 +122,8 @@ test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退 expect(screen.getByText('138****5678')).toBeTruthy(); expect(screen.getByText('退款欠账限制')).toBeTruthy(); expect(screen.getByText('25', {selector: 'strong'})).toBeTruthy(); + expect(screen.getByText('历史花费')).toBeTruthy(); + expect(screen.getByText('1234', {selector: 'strong'})).toBeTruthy(); expect(screen.getByText('order-1')).toBeTruthy(); await user.keyboard('{Escape}'); @@ -136,6 +149,50 @@ test('只有陶泥号时按 publicUserCode 查询用户', async () => { }); }); +test('历史花费支持手动对账并用权威结果校准展示', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', {name: '查看用户信息'})); + await screen.findByText('陶泥用户'); + await user.click(screen.getByRole('button', {name: '手动对账历史花费'})); + await user.click(screen.getByRole('button', {name: '确认'})); + + await waitFor(() => { + expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', { + userId: 'user-1', + }); + }); + expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy(); + expect(screen.getByText('1300', {selector: 'strong'})).toBeTruthy(); +}); + +test('没有独立操作权限时不显示历史花费对账按钮', async () => { + vi.mocked(getAdminUserDetail).mockResolvedValue({ + ...detail, + canReconcileConsumption: false, + }); + const user = userEvent.setup(); + render( + , + ); + + await user.click(screen.getByRole('button', {name: '查看用户信息'})); + await screen.findByText('陶泥用户'); + + expect(screen.queryByRole('button', {name: '手动对账历史花费'})).toBeNull(); +}); + test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => { const user = userEvent.setup(); const manuallyFrozenWallet: AdminProfileWalletPayload = { diff --git a/apps/admin-web/src/components/AdminUserDetailDialog.tsx b/apps/admin-web/src/components/AdminUserDetailDialog.tsx index e966378d5..6fa395250 100644 --- a/apps/admin-web/src/components/AdminUserDetailDialog.tsx +++ b/apps/admin-web/src/components/AdminUserDetailDialog.tsx @@ -6,6 +6,7 @@ import { formatAdminApiError, getAdminUserDetail, isAdminApiError, + reconcileAdminUserConsumption, updateAdminWalletRestriction, } from '../api/adminApiClient'; import type { @@ -34,6 +35,8 @@ export function AdminUserDetailDialog({ const [errorMessage, setErrorMessage] = useState(''); const [restrictionReason, setRestrictionReason] = useState(''); const [isSavingRestriction, setIsSavingRestriction] = useState(false); + const [isReconcilingConsumption, setIsReconcilingConsumption] = useState(false); + const [reconcileMessage, setReconcileMessage] = useState(''); const closeButtonRef = useRef(null); const requestVersionRef = useRef(0); const {confirmWrite, confirmDialog, isConfirming} = useAdminWriteConfirm(); @@ -60,7 +63,12 @@ export function AdminUserDetailDialog({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !isSavingRestriction && !isConfirming) { + if ( + event.key === 'Escape' && + !isSavingRestriction && + !isReconcilingConsumption && + !isConfirming + ) { event.preventDefault(); onClose(); } @@ -69,13 +77,14 @@ export function AdminUserDetailDialog({ return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [isConfirming, isSavingRestriction, onClose]); + }, [isConfirming, isReconcilingConsumption, isSavingRestriction, onClose]); async function loadDetail() { const requestVersion = requestVersionRef.current + 1; requestVersionRef.current = requestVersion; setIsLoading(true); setErrorMessage(''); + setReconcileMessage(''); try { const response = await getAdminUserDetail(token, { userId: userId?.trim() || undefined, @@ -144,6 +153,47 @@ export function AdminUserDetailDialog({ } } + async function handleConsumptionReconcile() { + if (!detail || isReconcilingConsumption) { + return; + } + const confirmed = await confirmWrite({ + action: '手动对账历史花费', + target: `${detail.displayName || detail.publicUserCode} / ${detail.userId}`, + }); + if (!confirmed) { + return; + } + + setIsReconcilingConsumption(true); + setErrorMessage(''); + setReconcileMessage(''); + try { + const response = await reconcileAdminUserConsumption(token, { + userId: detail.userId, + }); + setDetail((current) => + current + ? { + ...current, + historicalConsumedPoints: response.historicalConsumedPoints, + } + : current, + ); + setReconcileMessage( + response.changed ? '对账完成,历史花费已校准' : '对账完成,数据一致', + ); + } catch (error: unknown) { + if (isAdminApiError(error) && error.status === 401) { + onUnauthorized('登录状态已失效'); + } else { + setErrorMessage(formatAdminApiError(error)); + } + } finally { + setIsReconcilingConsumption(false); + } + } + if (typeof document === 'undefined') { return null; } @@ -158,6 +208,7 @@ export function AdminUserDetailDialog({ if ( event.target === event.currentTarget && !isSavingRestriction && + !isReconcilingConsumption && !isConfirming ) { onClose(); @@ -174,7 +225,7 @@ export function AdminUserDetailDialog({ ) : null} - {!wallet.walletFrozen ? 正常 : null} +
+ {wallet.manualFrozen ? 人工冻结 : null} + {wallet.refundDebtFrozen ? ( + 退款欠账限制 + ) : null} + {!wallet.walletFrozen ? ( + 正常 + ) : null} +
+ {reconcileMessage ? ( +
+ {reconcileMessage} +
+ ) : null}
{metrics.map(([label, value]) => (
diff --git a/apps/admin-web/src/pages/AdminAccountsPage.tsx b/apps/admin-web/src/pages/AdminAccountsPage.tsx index 0403d2d18..c7d51af6e 100644 --- a/apps/admin-web/src/pages/AdminAccountsPage.tsx +++ b/apps/admin-web/src/pages/AdminAccountsPage.tsx @@ -17,6 +17,7 @@ interface AdminAccountsPageProps { } const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly); +const consumptionReconcilePermission = 'profile-wallet-consumption-reconcile'; export function AdminAccountsPage({ token, @@ -29,6 +30,7 @@ export function AdminAccountsPage({ const [password, setPassword] = useState(''); const [enabled, setEnabled] = useState(true); const [tabPermissions, setTabPermissions] = useState([]); + const [actionPermissions, setActionPermissions] = useState([]); const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [errorMessage, setErrorMessage] = useState(''); @@ -65,6 +67,7 @@ export function AdminAccountsPage({ setPassword(''); setEnabled(true); setTabPermissions([]); + setActionPermissions([]); setErrorMessage(''); } @@ -75,6 +78,7 @@ export function AdminAccountsPage({ setPassword(''); setEnabled(account.enabled); setTabPermissions(account.tabPermissions); + setActionPermissions(account.actionPermissions ?? []); setErrorMessage(''); } @@ -127,6 +131,7 @@ export function AdminAccountsPage({ displayName: normalizedDisplayName, ...(password ? {password} : {}), tabPermissions, + actionPermissions, enabled, }) : await createAdminAccount(token, { @@ -134,6 +139,7 @@ export function AdminAccountsPage({ displayName: normalizedDisplayName, password, tabPermissions, + actionPermissions, enabled, }); setAccounts((current) => { @@ -289,6 +295,28 @@ export function AdminAccountsPage({
+
+ 独立操作权限 +
+ +
+
+