持久化用户历史消费泥点投影
后台用户详情展示历史花费并保留手动对账 消费落账原子更新投影并提供存量初始化 为手动对账增加独立管理员操作权限 更新SpacetimeDB绑定、文档和测试
This commit is contained in:
@@ -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' }), {
|
||||
|
||||
@@ -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<AdminUserConsumptionReconcileResponse>(
|
||||
'/admin/api/profile/users/reconcile-consumption',
|
||||
{ method: 'POST', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
export function previewAdminRechargeRefund(
|
||||
token: string,
|
||||
payload: AdminRechargeRefundPreviewRequest,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AdminUserReferenceButton
|
||||
token="admin-token"
|
||||
userId="user-1"
|
||||
onUnauthorized={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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<HTMLButtonElement | null>(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({
|
||||
<button
|
||||
aria-label="刷新用户信息"
|
||||
className="admin-ghost-button"
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isReconcilingConsumption}
|
||||
title="刷新"
|
||||
type="button"
|
||||
onClick={() => void loadDetail()}
|
||||
@@ -185,7 +236,7 @@ export function AdminUserDetailDialog({
|
||||
ref={closeButtonRef}
|
||||
aria-label="关闭用户详情"
|
||||
className="admin-ghost-button"
|
||||
disabled={isSavingRestriction}
|
||||
disabled={isSavingRestriction || isReconcilingConsumption}
|
||||
title="关闭"
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -222,7 +273,15 @@ export function AdminUserDetailDialog({
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<WalletSection wallet={detail.wallet} />
|
||||
<WalletSection
|
||||
wallet={detail.wallet}
|
||||
historicalConsumedPoints={detail.historicalConsumedPoints}
|
||||
canReconcileConsumption={detail.canReconcileConsumption}
|
||||
isBusy={isReconcilingConsumption || isSavingRestriction}
|
||||
isReconciling={isReconcilingConsumption}
|
||||
reconcileMessage={reconcileMessage}
|
||||
onReconcile={() => void handleConsumptionReconcile()}
|
||||
/>
|
||||
|
||||
<section className="admin-user-restriction-section">
|
||||
<div className="admin-panel-heading">
|
||||
@@ -251,7 +310,7 @@ export function AdminUserDetailDialog({
|
||||
<span>操作原因</span>
|
||||
<input
|
||||
aria-label="人工冻结操作原因"
|
||||
disabled={isSavingRestriction}
|
||||
disabled={isSavingRestriction || isReconcilingConsumption}
|
||||
value={restrictionReason}
|
||||
onChange={(event) => setRestrictionReason(event.target.value)}
|
||||
/>
|
||||
@@ -262,7 +321,11 @@ export function AdminUserDetailDialog({
|
||||
? 'admin-secondary-button'
|
||||
: 'admin-danger-button'
|
||||
}
|
||||
disabled={isSavingRestriction || !restrictionReason.trim()}
|
||||
disabled={
|
||||
isSavingRestriction ||
|
||||
isReconcilingConsumption ||
|
||||
!restrictionReason.trim()
|
||||
}
|
||||
type="button"
|
||||
onClick={() => void handleRestrictionChange()}
|
||||
>
|
||||
@@ -369,7 +432,23 @@ function UserIdentityHeader({detail}: {detail: AdminUserDetailResponse}) {
|
||||
);
|
||||
}
|
||||
|
||||
function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
|
||||
function WalletSection({
|
||||
wallet,
|
||||
historicalConsumedPoints,
|
||||
canReconcileConsumption,
|
||||
isBusy,
|
||||
isReconciling,
|
||||
reconcileMessage,
|
||||
onReconcile,
|
||||
}: {
|
||||
wallet: AdminProfileWalletPayload;
|
||||
historicalConsumedPoints: number;
|
||||
canReconcileConsumption: boolean;
|
||||
isBusy: boolean;
|
||||
isReconciling: boolean;
|
||||
reconcileMessage: string;
|
||||
onReconcile: () => void;
|
||||
}) {
|
||||
const metrics = [
|
||||
['总余额', wallet.totalBalance],
|
||||
['可消费', wallet.spendableBalance],
|
||||
@@ -378,19 +457,41 @@ function WalletSection({wallet}: {wallet: AdminProfileWalletPayload}) {
|
||||
['会员限时', wallet.membershipLimitedPoints],
|
||||
['退款占用', wallet.heldPoints],
|
||||
['退款欠账', wallet.refundDebtPoints],
|
||||
['历史花费', historicalConsumedPoints],
|
||||
] 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>
|
||||
<div className="admin-detail-actions">
|
||||
{canReconcileConsumption ? (
|
||||
<button
|
||||
aria-label="手动对账历史花费"
|
||||
className="admin-ghost-button admin-user-wallet-reconcile-button"
|
||||
disabled={isBusy}
|
||||
type="button"
|
||||
onClick={onReconcile}
|
||||
>
|
||||
<RefreshCcw size={15} aria-hidden="true" />
|
||||
<span>{isReconciling ? '对账中' : '手动对账'}</span>
|
||||
</button>
|
||||
) : null}
|
||||
{!wallet.walletFrozen ? <span className="admin-status admin-status-ok">正常</span> : null}
|
||||
<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>
|
||||
{reconcileMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{reconcileMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="admin-user-wallet-grid">
|
||||
{metrics.map(([label, value]) => (
|
||||
<div className="admin-recharge-metric" key={label}>
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [actionPermissions, setActionPermissions] = useState<string[]>([]);
|
||||
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({
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="admin-permission-fieldset">
|
||||
<legend>独立操作权限</legend>
|
||||
<div className="admin-permission-grid">
|
||||
<label>
|
||||
<input
|
||||
checked={actionPermissions.includes(
|
||||
consumptionReconcilePermission,
|
||||
)}
|
||||
type="checkbox"
|
||||
onChange={(event) =>
|
||||
setActionPermissions(
|
||||
event.target.checked
|
||||
? [consumptionReconcilePermission]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>手动对账用户历史花费</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={isSaving}
|
||||
|
||||
@@ -1270,6 +1270,13 @@ button:disabled {
|
||||
background: #f8efe7;
|
||||
}
|
||||
|
||||
.admin-ghost-button.admin-user-wallet-reconcile-button {
|
||||
width: auto;
|
||||
min-width: 92px;
|
||||
padding: 0 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-ghost-button.admin-query-reset-button {
|
||||
width: auto;
|
||||
min-width: 76px;
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
|
||||
|
||||
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
|
||||
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`,SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
|
||||
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 常规权限粒度固定为后台 15 个一级 Tab,“账号管理”只允许 owner 且不可授予 member;2026-07-24 起,历史花费手动对账作为独立高风险操作权限 `profile-wallet-consumption-reconcile`,不随任意 Tab 自动授予。member 每次请求重新读取当前账号并校验启停、`token_version`、Tab 权限和独立操作权限;任一权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`,SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航和操作按钮过滤只负责体验,正式授权由 api-server 路由权限矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
|
||||
- 影响范围:`admin_account`、SpacetimeDB typed procedures / client facade、后台 JWT 与 session DTO、`/admin/api/accounts*`、后台路由权限中间件、admin-web 导航和账号管理页。
|
||||
- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。
|
||||
- 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`。
|
||||
@@ -4502,3 +4502,11 @@
|
||||
- 决策:普通手机号认证请求统一使用可选 `countryCode` 与必填 `purePhoneNumber`,省略国家码时默认中国大陆 `86`,直接替换旧 `phone` 字段。前端把浏览器 E.164 自动填充值拆成这两个字段;后端先验证国家码,再复用纯手机号规范化并生成 E.164 存储。
|
||||
- 微信边界:小程序客户端仍只上传 `wechatPhoneCode`;`platform-auth` 必须要求微信成功响应中的 `phoneNumber`、`countryCode` 与 `purePhoneNumber` 均存在且非空,但只使用后两项执行国家码校验和 E.164 构造。腾讯官方仅说明境外 `phoneNumber` 会带区号,并未承诺 E.164 格式,中国号码示例中它与纯号码相同,因此不得校验 `phoneNumber == +{countryCode}{purePhoneNumber}`。微信字段缺失时失败关闭,不能使用普通请求的 `86` 默认值。
|
||||
- 数据边界:认证投影与 SpacetimeDB 的 `phone_number_e164` 保持不变,不新增国家码或纯号码列,也不需要 schema 迁移或 bindings 生成。
|
||||
|
||||
## 2026-07-24 后台用户详情展示历史花费泥点
|
||||
|
||||
- 口径:`historicalConsumedPoints` 表示用户历史总消费,只累计 `profile_wallet_ledger.source_type = asset_operation_consume` 且 `amount_delta < 0` 的绝对值;`asset_operation_refund` 不冲减,充值退款追回、余额重置、赠送和退款 hold 均不计入。
|
||||
- 投影边界:新增 `profile_wallet_consumption_total`,已有投影时消费流水成功落账在同一 SpacetimeDB 事务内按主键 O(1) 原子累加;退款不回减。首次上线在停止业务写入的维护窗口由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户建立存量投影,成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费和 runtime service identity 受限的 `admin_get_profile_wallet_detail_and_return` 都可按用户索引兜底重建一次;消费事务重建已包含当前流水,不重复加本次金额。不得用最近 50 条流水列表近似,也不得把全量流水扫描塞进充值订单每行复用的通用钱包快照。
|
||||
- 对账边界:保留管理员显式手动对账。owner 始终可用;member 必须单独持有 `profile-wallet-consumption-reconcile` 独立操作权限,任意 Tab 都不隐式授予。`POST /admin/api/profile/users/reconcile-consumption` 经二次确认后调用 runtime service identity 受限 procedure,扫描该用户全部权威流水、比较并校准投影,记录管理员与对账时间。
|
||||
- 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。
|
||||
- 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -3363,3 +3363,10 @@
|
||||
- 处理:灰度页只能以 `/admin/api/feature-gates` 为数据源,固定目标列表只登记现役功能;新增或退役业务 target 只修改固定目标注册,不得让通用页面依赖业务列表接口。旧 `creation-entry:*` 目标、接口和页面保持退役。
|
||||
- 验证:`adminRoutes` 必须包含 `gray-release`,admin-web TypeScript/ESLint/Vitest 不得排除灰度页;页面测试必须断言只请求 feature-gates,并继续覆盖现役固定 target、直接 Gate Key 保存与新 target 状态重置。
|
||||
- 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/app/adminRoutes.ts`、`server-rs/crates/api-server/src/modules/admin.rs`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。
|
||||
|
||||
## 历史钱包消费不能从最近流水或通用订单快照推算
|
||||
|
||||
- 现象:后台用户详情要展示累计花费时,直接复用只返回最近 50 条的 `list_profile_wallet_ledger`,或在充值订单每行使用的通用钱包快照里扫描该用户全部流水。
|
||||
- 原因:最近流水会低估历史总额;通用钱包快照又会被订单列表反复构造,把一次按用户聚合放大为 `订单数 × 流水数` 的重复扫描。
|
||||
- 处理:历史花费只累计 `asset_operation_consume` 负向流水绝对值,退款不冲减;通过 `profile_wallet_consumption_total` 在已有投影时按主键 O(1) 累加。首次上线必须在停写维护窗口由 owner 执行全量初始化,为每个已有钱包流水的用户建立投影,不能让所有存量用户的首次正常消费各自扫描历史;维护遗漏或新用户缺行时才在首次消费或详情读取中按用户索引兜底重建一次。手动对账扫描是独立高风险操作,member 必须单独持有 `profile-wallet-consumption-reconcile`,不能因为能打开共享用户详情就自动获得。
|
||||
- 验证:构造消费、退款、充值退款追回和赠送混合流水,断言只累计消费;维护初始化后正常消费只按主键累加;重复详情读取不得重复扫描或重复累计;任意 Tab 权限不能调用手动对账,同时确认充值订单列表的通用钱包快照没有新增历史流水扫描。
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# 后台管理多账号与 Tab 访问权限方案
|
||||
|
||||
更新时间:`2026-07-23`
|
||||
更新时间:`2026-07-24`
|
||||
|
||||
## 1. 文档定位
|
||||
|
||||
本文定义陶泥儿后台从单一环境变量管理员扩展为“1 个 owner 引导账号 + 多个 member 持久账号”的编码契约,并为每个一级 Tab 建立前后端一致的访问权限。
|
||||
|
||||
本次只增加后台管理员账号与整页访问权限,不引入页面内按钮级、字段级或只读权限。正式实现必须同时完成前端导航过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
|
||||
后台权限默认仍以整页 Tab 为粒度;只有会触发权威钱包全量扫描的“手动对账用户历史花费”作为明确例外,使用独立操作权限,不随任何 Tab 自动授予。正式实现必须同时完成前端按钮过滤和后端 API 鉴权;前端过滤只改善体验,不能作为安全边界。
|
||||
|
||||
## 2. 当前基线与目标
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
1. 现有环境变量账号升级为 `owner`,仍由部署环境提供,不迁移、不复制到 SpacetimeDB。
|
||||
2. owner 始终拥有全部 15 个业务 Tab 权限,并独占“账号管理”Tab 和账号管理 API。
|
||||
3. owner 可以创建、修改、启停 member;member 保存在 SpacetimeDB 私有表 `admin_account`。
|
||||
4. member 按一级 Tab 分配权限;获得一个 Tab 权限即获得该页面内全部读写能力,页面内部二级 Tab、弹窗和操作区继承一级权限。
|
||||
5. member JWT 每次请求都重新读取当前账号并校验 `enabled`、`token_version` 和实时权限,权限、密码或启停变更应立即让旧 JWT 失效。
|
||||
4. member 按一级 Tab 分配常规权限;获得一个 Tab 权限即获得该页面内常规读写能力。历史花费手动对账必须另行授予 `profile-wallet-consumption-reconcile`,任何 Tab 都不隐式包含。
|
||||
5. member JWT 每次请求都重新读取当前账号并校验 `enabled`、`token_version`、实时 Tab 权限和独立操作权限,权限、密码或启停变更应立即让旧 JWT 失效。
|
||||
|
||||
## 3. 角色与不可变规则
|
||||
|
||||
@@ -26,18 +26,18 @@
|
||||
|
||||
- owner 用户名和密码继续读取 `GENARRATIVE_ADMIN_USERNAME`、`GENARRATIVE_ADMIN_PASSWORD`。
|
||||
- owner 是环境变量构造的虚拟账号,不写入 `admin_account`,不允许通过后台改名、改密、禁用或删除。
|
||||
- owner 始终拥有本文列出的全部 15 个可分配权限,不能在前端取消,也不从数据库加载权限。
|
||||
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS`,不能写入 member 的 `permissions_json`。
|
||||
- owner 始终拥有本文列出的全部 15 个 Tab 权限和全部独立操作权限,不能在前端取消,也不从数据库加载权限。
|
||||
- “账号管理”是 owner-only 能力。它可以作为新增一级路由 `accounts` / `#accounts` 展示,但 `accounts` 不进入 `ADMIN_TAB_PERMISSIONS` 或 `ADMIN_ACTION_PERMISSIONS`,不能写入 member 的权限 JSON。
|
||||
- owner 会话返回 `accountRole = "owner"`、`roles = ["admin", "owner"]`;账号管理权限必须根据服务端确认的 `accountRole` 判断,不能只相信前端角色字符串。
|
||||
- owner 配置缺失时,后台整体保持未启用状态;不能依赖数据库中的 member 绕过 owner 引导配置启动后台。
|
||||
|
||||
### 3.2 member
|
||||
|
||||
- member 只来自 `admin_account`,不新增第二套环境变量账号。
|
||||
- member 会话返回 `accountRole = "member"`、`roles = ["admin", "member"]` 和当前实时 `tabPermissions`。
|
||||
- member 会话返回 `accountRole = "member"`、`roles = ["admin", "member"]`、当前实时 `tabPermissions` 和 `actionPermissions`。
|
||||
- member 永远不能访问账号管理页面或账号管理 API,也不能给自己或他人分配 `accounts`。
|
||||
- member 的一个一级 Tab 权限覆盖该页面的查询、创建、修改、启停、退款等全部现有操作,不拆成 `read` / `write`。
|
||||
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗继承触发它的一级 Tab 权限,不另设 permission id。
|
||||
- member 的一个一级 Tab 权限覆盖该页面的常规查询、创建、修改、启停、退款等操作,不拆成通用 `read` / `write`。
|
||||
- 页面内二级 Tab、筛选视图、抽屉、弹窗和共享详情弹窗默认继承触发它的一级 Tab 权限;历史花费手动对账是唯一独立高风险操作例外,无权限时共享用户详情不展示按钮,直接请求仍由后端返回 403。
|
||||
|
||||
## 4. 权限标识
|
||||
|
||||
@@ -61,9 +61,15 @@
|
||||
| `editor-showcase` | 精选审核 | `#editor-showcase` |
|
||||
| `editor-assets` | 素材查询 | `#editor-assets` |
|
||||
|
||||
权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
|
||||
Tab 权限数组必须去重并按上表顺序规范化后保存。保存时拒绝未知值和 `accounts`;读取旧数据时遇到未知值应忽略并记录告警,绝不能将未知值解释为全权限。空数组合法,表示 member 可以登录但没有业务页面权限。
|
||||
|
||||
后续新增一级 Tab 时,必须在同一次改动中更新:
|
||||
`ADMIN_ACTION_PERMISSIONS` 是独立操作权限闭合集合,当前只有:
|
||||
|
||||
| permission id | 操作 | 授权边界 |
|
||||
| --- | --- | --- |
|
||||
| `profile-wallet-consumption-reconcile` | 手动对账用户历史花费 | owner 默认拥有;member 必须在账号管理中单独勾选,不要求同时持有特定 Tab |
|
||||
|
||||
独立操作权限保存在 `action_permissions_json`,响应为 `actionPermissions`;未知值必须拒绝。后续新增一级 Tab 时,必须在同一次改动中更新:
|
||||
|
||||
- shared-contracts 的 `ADMIN_TAB_PERMISSIONS`。
|
||||
- admin-web 的路由定义、权限标签和第一可访问项顺序。
|
||||
@@ -80,13 +86,14 @@
|
||||
| `username` | `String` | `unique`;登录名,创建后不可修改;按 `trim + ASCII lowercase` 规范化 |
|
||||
| `display_name` | `String` | 展示名,去除首尾空白后 1 至 64 字符 |
|
||||
| `password_hash` | `String` | Argon2id PHC 字符串;只在内部登录查询中返回给 api-server,永不进入 HTTP DTO、日志或前端状态 |
|
||||
| `permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` |
|
||||
| `tab_permissions_json` | `String` | 规范化后的 Tab permission JSON;只允许第 4 节 15 个值,空数组为 `[]` |
|
||||
| `enabled` | `bool` | 是否允许登录和继续使用现有 JWT |
|
||||
| `token_version` | `u64` | 初始为 `1`;权限、密码或启停状态发生有效变化时加 `1` |
|
||||
| `created_by` | `String` | 创建者后台 subject;当前只能是 owner subject |
|
||||
| `updated_by` | `String` | 最近更新者后台 subject;当前只能是 owner subject |
|
||||
| `created_at` | `Timestamp` | 创建时间,使用 `ctx.timestamp` |
|
||||
| `updated_at` | `Timestamp` | 最近更新时间,使用 `ctx.timestamp` |
|
||||
| `action_permissions_json` | `Option<String>` | 既有表末尾追加;旧行默认 `None` 并按 `[]` 读取,只允许第 4 节独立操作权限 |
|
||||
|
||||
账号规则:
|
||||
|
||||
@@ -94,7 +101,7 @@
|
||||
- owner 用户名属于保留名称。创建 member 时必须同时与当前规范化后的 owner 用户名比较并拒绝冲突,不能只依赖 `admin_account.username` 唯一索引。
|
||||
- 密码明文只存在于登录、创建和改密请求生命周期内;限制为 6 至 128 个字符,并复用 `platform-auth` 的 Argon2id 哈希与校验能力。Argon2id 必须在 blocking 任务中执行,api-server 通过有界信号量限制同时 hash / verify 数量,不得占用 Tokio worker 或无界堆积高成本任务。
|
||||
- 不提供物理删除 API。离职或停用通过 `enabled = false` 完成,以保留 `created_by`、`updated_by` 和账号标识。
|
||||
- `display_name` 单独变化只更新 `updated_by`、`updated_at`,不要求递增 `token_version`;权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
|
||||
- `display_name` 单独变化只更新 `updated_by`、`updated_at`,不要求递增 `token_version`;Tab 权限、独立操作权限、密码、`enabled` 任一有效变化必须在同一事务中递增版本。
|
||||
- `u64` 版本到达上限时更新失败关闭,不能回绕。
|
||||
|
||||
## 6. SpacetimeDB 与 facade 边界
|
||||
@@ -158,9 +165,10 @@ owner 优先既保持原账号行为,也防止数据库同名记录遮蔽或
|
||||
```text
|
||||
accountRole: "owner" | "member"
|
||||
tabPermissions: string[]
|
||||
actionPermissions: string[]
|
||||
```
|
||||
|
||||
owner 返回全部 15 个 permission id;member 返回数据库中的实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航。
|
||||
owner 返回全部 15 个 Tab permission id 和全部独立操作权限;member 返回数据库中的两组实时规范化数组。`GET /admin/api/me` 同样执行逐请求校验并返回实时权限,供刷新页面后恢复导航和操作按钮。
|
||||
|
||||
后台所有面向运营展示的管理员身份统一使用 `displayName`。审计表继续保存稳定 subject,例如 owner subject 或 `admin-account-<uuid>`;api-server 在返回兑换码、邀请码等操作记录时,按 owner 运行态和 `admin_account` 批量解析显示名称,同时兼容历史用户名记录。已无法解析的历史主体统一展示“已停用管理员”,前端不得直接渲染 `operatorUserId`、账号 ID 或登录用户名代替显示名称。对写接口,显示名目录必须在主事务前加载,或在主事务成功后降级为占位文案;不得因二次读取失败把已提交写入伪装成失败。
|
||||
|
||||
@@ -168,14 +176,15 @@ owner 返回全部 15 个 permission id;member 返回数据库中的实时规
|
||||
|
||||
在统一 `require_admin_auth` 之后增加可复用的权限守卫,支持:
|
||||
|
||||
- `require_admin_permission(permission)`:owner 自动通过;member 必须包含该 permission。
|
||||
- `require_any_admin_permission([permission...])`:owner 自动通过;member 至少包含一个,用于共享 API。
|
||||
- `require_admin_tab_permission(permission)`:owner 自动通过;member 必须包含该 Tab permission。
|
||||
- `require_any_admin_tab_permission([permission...])`:owner 自动通过;member 至少包含一个,用于共享读取 API。
|
||||
- `require_admin_action_permission(permission)`:owner 自动通过;member 必须包含该独立操作 permission。
|
||||
- `require_admin_owner`:只接受服务端确认的 owner。
|
||||
|
||||
返回语义统一如下:
|
||||
|
||||
- `401 Unauthorized`:token 缺失、无效、过期,member 不存在、被停用或 `token_version` 过期。
|
||||
- `403 Forbidden`:会话有效但缺少目标 Tab 权限,或 member 请求 owner-only API。
|
||||
- `403 Forbidden`:会话有效但缺少目标 Tab / 独立操作权限,或 member 请求 owner-only API。
|
||||
- 前端收到 `401` 清除本地 token 并回到登录页;收到 `403` 不应伪装成掉线,应刷新 `/me` 权限并跳转到第一可访问项或零权限空态。
|
||||
|
||||
## 9. API-to-Tab 权限矩阵
|
||||
@@ -223,6 +232,8 @@ owner 返回全部 15 个 permission id;member 返回数据库中的实时规
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/register` | `recharge-orders` |
|
||||
| `POST` | `/admin/api/profile/recharge-refunds/manual-review/resolve` | `recharge-orders` |
|
||||
| `GET` | `/admin/api/profile/users/detail` | `tables OR tracking OR recharge-orders OR editor-showcase OR editor-assets` |
|
||||
| `POST` | `/admin/api/profile/users/reconcile-consumption` | 独立操作权限 `profile-wallet-consumption-reconcile` |
|
||||
| `POST` | `/admin/api/profile/users/initialize-consumption-projections` | owner-only 维护窗口操作 |
|
||||
| `POST` | `/admin/api/profile/wallet-restriction` | `recharge-orders` |
|
||||
| `GET` | `/admin/api/accounts` | owner-only |
|
||||
| `POST` | `/admin/api/accounts` | owner-only |
|
||||
@@ -249,6 +260,7 @@ accounts: Array<{
|
||||
username,
|
||||
displayName,
|
||||
tabPermissions,
|
||||
actionPermissions,
|
||||
enabled,
|
||||
tokenVersion,
|
||||
createdBy,
|
||||
@@ -270,6 +282,7 @@ accounts: Array<{
|
||||
displayName: string,
|
||||
password: string,
|
||||
tabPermissions: string[],
|
||||
actionPermissions: string[],
|
||||
enabled?: boolean
|
||||
}
|
||||
```
|
||||
@@ -285,11 +298,12 @@ accounts: Array<{
|
||||
displayName: string,
|
||||
password?: string,
|
||||
tabPermissions: string[],
|
||||
actionPermissions: string[],
|
||||
enabled: boolean
|
||||
}
|
||||
```
|
||||
|
||||
`username` 和 `account_id` 不可修改。更新请求完整提交显示名称、Tab 权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by`、`updated_at`。响应仍不返回密码或 hash。
|
||||
`username` 和 `account_id` 不可修改。更新请求完整提交显示名称、Tab 权限、独立操作权限和启停状态;密码省略表示不修改,空字符串密码作为非法参数拒绝。api-server 只在提供新密码时生成新 hash。procedure 比较有效变化,在权限、密码或启停任一变化时只递增一次 `token_version`,并在同一事务写入账号字段、`updated_by`、`updated_at`。响应仍不返回密码或 hash。
|
||||
|
||||
## 11. admin-web 行为
|
||||
|
||||
@@ -313,7 +327,7 @@ accounts: Array<{
|
||||
|
||||
### 11.3 账号管理页
|
||||
|
||||
- 权限编辑器展示 15 个明确的 checkbox,每项使用现有 Tab 中文名称;不能展示或提交 `accounts`。
|
||||
- 权限编辑器分为 15 个 Tab checkbox 和独立操作权限区;当前独立区只显示“手动对账用户历史花费”。不能展示或提交 `accounts`。
|
||||
- 创建和编辑使用独立弹窗或抽屉,不在列表下方追加表单。
|
||||
- 编辑时密码字段默认空,空表示请求中省略 `password`;页面永不展示现有密码或 hash。
|
||||
- 停用使用开关并二次确认。保存成功后以 API 返回 account snapshot 更新列表。
|
||||
@@ -323,17 +337,17 @@ accounts: Array<{
|
||||
|
||||
建议按以下边界落地,避免在前端或 `api-server` 重新发明持久化规则:
|
||||
|
||||
- `shared-contracts`:`ADMIN_TAB_PERMISSIONS`、扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
|
||||
- `shared-contracts`:`ADMIN_TAB_PERMISSIONS`、`ADMIN_ACTION_PERMISSIONS`、扩展后的 `AdminSessionPayload`、账号管理 request/response DTO。
|
||||
- `spacetime-module`:私有表、输入类型、typed procedures、唯一性与版本递增事务。
|
||||
- `spacetime-client`:生成绑定、row mapper、登录查询与账号管理 facade。
|
||||
- `api-server`:owner/member 登录编排、Argon2id、逐请求账号解析、权限 middleware、账号管理 handlers。
|
||||
- `apps/admin-web`:权限感知路由、hash 回落、零权限空态、owner-only 账号管理页。
|
||||
|
||||
不能将 `permissions_json` 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`。
|
||||
不能将 Tab / 独立操作权限 JSON 的解析与授权只放在前端;不能让 admin-web 直连 SpacetimeDB;不能用进程内 member 列表替代 `admin_account`。
|
||||
|
||||
## 13. 迁移、绑定与发布顺序
|
||||
|
||||
`admin_account` 是新增私有表,没有旧数据回填。原环境变量 owner 不入表,因此迁移不创建 owner 行。
|
||||
`admin_account` 已是私有表;本次只在表结构体最后追加带 `None` 默认值的 `action_permissions_json`,旧 member 自动按空独立权限读取。原环境变量 owner 不入表,并始终由 api-server 合成全部权限。
|
||||
|
||||
实现 schema 后必须:
|
||||
|
||||
@@ -380,16 +394,17 @@ spacetime publish <database> \
|
||||
- member 私表不能被普通 SpacetimeDB identity 查询或调用 procedure;只有 runtime service identity 可读写。
|
||||
- 创建重复规范化用户名、owner 保留用户名、未知权限或 `accounts` 权限均失败。
|
||||
- GET/POST/PUT 账号 API 任何响应和日志都不包含明文密码或 `password_hash`。
|
||||
- 权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
|
||||
- Tab 权限、独立操作权限、密码、启停更新各自会递增 `token_version`;同一次请求修改多项只递增一次;仅改展示名不递增。
|
||||
- member 被停用、改密或改权限后,旧 JWT 下一次请求返回 401;重新登录后获得实时权限。
|
||||
- API-to-Tab 矩阵逐路由覆盖 `modules/admin.rs`,每条路由至少测试 owner 成功、具备权限的 member 成功、缺权限 member 返回 403。
|
||||
- 两个共享读取接口分别覆盖每个允许 permission 的成功用例,以及无关 permission 的 403 用例。
|
||||
- owner-only 账号 API 对任意 member 都返回 403,即使其 `permissions_json` 被污染为包含 `accounts`。
|
||||
- 历史花费手动对账对仅持有任意 Tab 的 member 返回 403;只持有独立操作权限时允许调用;全量投影初始化始终 owner-only。
|
||||
- owner-only 账号 API 对任意 member 都返回 403,即使其 Tab 或独立权限 JSON 被污染为包含 `accounts`。
|
||||
|
||||
### 14.2 前端
|
||||
|
||||
- owner 看到 15 个业务 Tab 和账号管理;member 只看到被分配的业务 Tab。
|
||||
- 每个一级 Tab 内的二级 Tab、弹窗和写操作继承一级权限并正常使用,不出现“页面可见但内部 API 403”的错误映射。
|
||||
- 常规二级 Tab、弹窗和写操作继承一级权限;历史花费对账按钮只在用户详情返回 `canReconcileConsumption=true` 时显示。
|
||||
- 直接输入无权限 hash 自动替换为第一可访问项,不短暂挂载无权限页面。
|
||||
- 当前 Tab 权限被 owner 收回后,下一请求触发重新登录;新会话恢复后落到第一可访问项。
|
||||
- 零权限 member 登录后显示空态,不回落 Dashboard、不发送 Dashboard 或其它业务请求,并可正常退出。
|
||||
|
||||
@@ -216,7 +216,7 @@ npm run check:server-rs-ddd
|
||||
22. 后台主动退款只支持 `wechat_mp`、`wechat_jsapi`、`wechat_h5`、`wechat_native` 普通 V3 泥点订单。`api-server` 必须先按正式支付渠道和商品类型拦截不支持的订单,再做微信支付订单查单预检,然后调用 SpacetimeDB procedure 原子创建退款 hold;只有 hold 成功才允许调用微信退款。`wechat_mp_virtual`、历史非正式渠道值、会员、未支付、对账未完成、退款已满额、人工冻结、退款欠账或永久泥点不足必须在调用普通 V3 provider 前 fail-closed。
|
||||
23. `profile_recharge_refund_hold` 以稳定 `out_refund_no` 为主键,保存订单、用户、本次退款金额、占用永久泥点、管理员、原因和 `active / settled / released` 状态。重试只有在订单、`out_refund_no`、退款金额、管理员和归一化原因全部与原 hold 一致时才可复用,任一不一致都按幂等内容冲突 fail-closed,不能用新原因调用微信后保留旧审计。部分退款的 hold 在累计应追回增量之外额外保留 1 泥点并发舍入缓冲,全额退款不加缓冲;活动 hold 不改变钱包总额,但普通钱包消费必须预留全部活动 hold;成功退款 observation 扣款并结算匹配 hold,关闭退款释放 hold,外部退款追回不得消耗其他活动 hold。
|
||||
24. 退款欠账继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 为唯一真相;不新增平行 debt 累计。`profile_wallet_manual_restriction` 只保存人工冻结,普通消费同时检查人工冻结与退款欠账。后续永久泥点到账后继续偿还欠账,每日免费与会员周期泥点不参与;解除人工冻结不得清除退款欠账限制。
|
||||
25. 管理员充值订单、用户详情、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement 或钱包冻结。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
|
||||
25. 管理员充值订单、用户详情、历史花费手动对账、退款预检/执行、应急退款号登记、退款人工复核和钱包冻结接口只留在 `api-server` 管理员鉴权路由。用户详情中的历史花费泥点数读取 `profile_wallet_consumption_total` 投影;已有投影时,每次 `asset_operation_consume` 负向流水落账在同一事务内按主键 O(1) 原子累加,退款不回减,充值退款追回、余额重置、赠送和 hold 均不计入。首次上线必须在停止业务写入的维护窗口内,由 owner 调用 `POST /admin/api/profile/users/initialize-consumption-projections`,一次扫描全部权威钱包流水,为每个已有钱包流水的用户初始化存量投影;接口成功后才能恢复流量。维护遗漏或新用户缺行时,首次消费按该用户索引一次性重建(当前消费流水已经在同一事务中,不能重复加本次金额);钱包详情首次读取也保留同一按用户兜底。`POST /admin/api/profile/users/reconcile-consumption` 是显式手动对账入口:owner 始终可用,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限,任何一级 Tab 都不自动附带;用户详情只在后端返回 `canReconcileConsumption=true` 时展示按钮。操作经二次确认后扫描该用户全部权威钱包流水、比较并校准投影,同时记录管理员和对账时间。退款人工复核 BFF 必须从管理员会话写入操作人,要求非空原因,返回微信退款交易号、订单总额以及获批错误码等正式审计字段,并调用 runtime service identity 受限 procedure;后台确认面板必须展示这些后端事实,前端不得自行改 settlement、钱包冻结或消费累计。外部微信副作用由 `platform-wechat` 执行,退款/hold/钱包事务留在 `spacetime-module`,后台前端只展示 BFF 返回的正式状态。
|
||||
|
||||
## 创作入口泥点扣费契约
|
||||
|
||||
@@ -518,8 +518,8 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`AdminAccount`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/admin_account_storage.rs`
|
||||
- 说明:后台 member 私有账号表,保存规范化用户名、展示名、Argon2id 密码摘要、一级 Tab 权限 JSON、启停状态、会话版本和创建 / 更新审计字段。原环境变量管理员作为虚拟 owner,不写入该表。账号查询与写入 procedure 只接受 runtime service identity;HTTP 列表和写响应不返回密码摘要。
|
||||
- 索引:`account_id` 为主键,`username` 为唯一登录名;权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
|
||||
- 说明:后台 member 私有账号表,保存规范化用户名、展示名、Argon2id 密码摘要、一级 Tab 权限 JSON、独立操作权限 JSON、启停状态、会话版本和创建 / 更新审计字段。`action_permissions_json` 是既有表末尾新增的可选字段,旧行默认空数组语义;当前唯一独立操作权限为 `profile-wallet-consumption-reconcile`。原环境变量管理员作为虚拟 owner,不写入该表。账号查询与写入 procedure 只接受 runtime service identity;HTTP 列表和写响应不返回密码摘要。
|
||||
- 索引:`account_id` 为主键,`username` 为唯一登录名;Tab 权限、独立操作权限、密码或启停状态发生变化时在同一事务递增 `token_version`,使旧 JWT 下一次请求立即失效。
|
||||
|
||||
### `editor_agent_conversation`
|
||||
|
||||
@@ -907,6 +907,12 @@ npm run check:server-rs-ddd
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs`
|
||||
- 说明:账号钱包流水表。`created_at` 表示钱包事务实际结算时间,列表先按当前余额反向校验 `balance_after - amount_delta` 的结算链,再以该时间倒序兜底,避免支付回调或退款重放延迟时出现余额顺序倒置;支付平台确认时间继续保存在充值订单 `paid_at`。`metadata_json` 为可选 JSON 对象字符串,旧行缺失时读取层按 `{}` 归一;外部生成扣费 / 退款写入 `externalGenerationJobId`,使退款记录可以追溯到对应 `external_generation_job`。
|
||||
|
||||
### `profile_wallet_consumption_total`
|
||||
|
||||
- Rust 结构体:`ProfileWalletConsumptionTotal`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/runtime/active/profile.rs`
|
||||
- 作用:用户历史消费泥点累计投影。已有投影时,只在 `asset_operation_consume` 负向流水成功落账时同事务按主键 O(1) 累加,退款不回减;用户详情按 `user_id` 主键读取。首次上线在停写维护窗口由 owner 管理接口调用 `admin_initialize_profile_wallet_consumption_projections_and_return`,全表扫描一次,为所有已有钱包流水的用户建立存量投影。维护遗漏或新用户缺行时,首次消费与管理员钱包详情读取都可按 `profile_wallet_ledger.user_id` 索引兜底重建一次;消费事务中的重建结果已包含当前流水,不再额外叠加当前金额。持有独立操作权限的管理员显式触发手动对账时,`admin_reconcile_profile_wallet_consumption_and_return` 扫描该用户权威流水并覆盖校准投影,记录 `last_reconciled_by_admin_user_id` 与 `last_reconciled_at`。
|
||||
|
||||
### `asset_operation_wallet_settlement`
|
||||
|
||||
- Rust 结构体:`AssetOperationWalletSettlement`
|
||||
|
||||
@@ -145,7 +145,17 @@ spacetime sql <database> "SELECT * FROM profile_recharge_refund_bill_checkpoint"
|
||||
|
||||
核对规则:部分退款时原订单保持 `paid`;累计退款等于订单金额后才为 `refunded`,但 `paid_at` 继续保留,因此不会恢复首充资格。泥点退款只回收普通永久泥点;每日免费泥点和会员周期泥点不动。永久泥点不足时 `recovery_status=shortfall`、`unrecovered_points>0`、`wallet_frozen=true`,正式钱包消费在欠款清零前 fail-closed;会员订单统一为 `manual_review`,不得自动缩短有效期或扣周期泥点。
|
||||
|
||||
后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`、预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。
|
||||
后台充值订单退款必须通过管理员鉴权接口执行,不得从数据库页面直接改表:列表 `GET /admin/api/profile/recharge-orders`、用户详情 `GET /admin/api/profile/users/detail`、历史花费手动对账 `POST /admin/api/profile/users/reconcile-consumption`、存量消费投影初始化 `POST /admin/api/profile/users/initialize-consumption-projections`、预检 `POST /admin/api/profile/recharge-refunds/preview`、执行 `POST /admin/api/profile/recharge-refunds/execute`、应急退款号登记 `POST /admin/api/profile/recharge-refunds/register`、人工冻结/解冻 `POST /admin/api/profile/wallet-restriction`。用户详情返回的 `historicalConsumedPoints` 来自 `profile_wallet_consumption_total`,表示退款不冲减的历史总消费;已有投影的正常消费只做按主键 O(1) 原子累加,缺行时按该用户钱包流水索引兜底重建一次,不得用只返回最近 50 条的钱包流水列表在 BFF 或前端重算。手动对账是独立操作权限:owner 始终拥有,member 必须在账号管理中单独勾选“手动对账用户历史花费”,任意 Tab 权限都不隐式授予;接口经二次确认后才扫描该用户全部权威流水、校准投影并记录管理员和时间。投影初始化接口只允许 owner,必须在首次上线的停写维护窗口执行并成功后再恢复业务流量;它扫描全部权威钱包流水并初始化或校准全部消费投影。维护遗漏的存量缺行仍由用户详情首次读取按用户索引兜底回填。预检返回微信支付状态、本地累计退款、剩余可退金额、预计追回泥点、钱包总额、可消费余额、活动占用和退款欠账;只有预检允许且二次确认后才提交退款。提交使用稳定 `requestId`,接口超时后重试必须复用同一值。若返回“退款处理中”,先查同一 `out_refund_no`,不要换号再次发起。商户平台应急退款完成后,在后台登记原 `out_refund_no` 触发验签查单;微信已退款但缺少退款号时等待 T+1 账单,不得凭截图或支付订单 `REFUND` 状态直接手写退款事实。
|
||||
|
||||
首次发布消费投影时,先停止业务写入并确认 api-server 与新 SpacetimeDB module 已就绪,再使用当前 owner 登录获得的短期 token 执行:
|
||||
|
||||
```bash
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: Bearer ${ADMIN_BEARER_TOKEN}" \
|
||||
"https://<API 域名>/admin/api/profile/users/initialize-consumption-projections"
|
||||
```
|
||||
|
||||
响应中的 `scannedLedgerCount` 是扫描流水数,`projectedUserCount` 是已存在钱包流水并完成投影的用户数;只有请求成功且返回 `ok=true` 后才恢复业务流量。该接口允许幂等重跑,但每次都会全表扫描,只能在维护窗口由 owner 执行,不得加入普通定时任务或页面自动请求。
|
||||
|
||||
正式落账上线前已经被旧 debug handler 返回成功的退款回调不会因部署新版本自动重放。已知 `out_refund_no` 的历史退款应由具备真实商户凭据的受控服务端操作先调用单笔退款查询,验签后写入同一 observation 事务;未知的商户平台退款等待次日交易账单发现。自动账单按分片补扫微信 API 可查询的近 90 天,超出窗口的历史退款需从商户平台导出核对后逐笔受控查单补录,不能直接把商户平台截图或 CSV 行当作退款终态,也不能开放匿名或普通用户补录 / 退款入口。
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ V3 退款入口在正式落账的同时保留可用于真实联调的安全诊
|
||||
4. 已经发生的外部退款没有 hold 时沿用现有结算:回收当前未被其他 hold 占用的永久泥点,余额不足部分继续以 `profile_recharge_order_refund_settlement.unrecovered_points` 作为唯一退款欠账真相,状态为 `shortfall` 并限制消费。后续永久泥点到账后在同一钱包事务内按最早退款单自动继续追回;每日免费和会员周期泥点不参与。不得再建一张平行 debt 表重复累计欠账。
|
||||
5. 人工钱包冻结单独使用 `profile_wallet_manual_restriction`,保存当前是否冻结、原因、操作管理员和操作时间。普通消费同时检查人工冻结、退款欠账和活动 hold;解除人工冻结不能解除仍存在的退款欠账。
|
||||
6. 后台 API 统一位于管理员鉴权下:充值订单列表与详情、用户详情、退款预检、退款执行、应急 `out_refund_no` 登记、钱包人工冻结/解冻。任何接口都不得返回原始手机号、商户私钥、APIv3 Key、微信签名、回调密文或账单下载 URL。
|
||||
7. 通用用户详情通过内部 `user_id` 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、冻结原因和最近充值订单。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。
|
||||
7. 通用用户详情通过内部 `user_id` 或陶泥号解析同一认证用户,展示头像、昵称、陶泥号、内部 ID、脱敏手机号、登录/微信绑定状态、钱包总额、可消费余额、活动占用、退款欠账、历史花费、冻结原因和最近充值订单。历史花费读取 `profile_wallet_consumption_total`:首次上线在停写维护窗口由 owner 全量初始化存量投影,已有投影的消费落账按主键 O(1) 原子累加,退款不冲减;维护遗漏或新用户缺行时,在首次消费或详情读取中按用户全部流水兜底回填一次。不得用最近 50 条账单列表近似。手动对账不继承任意 Tab,member 必须单独持有 `profile-wallet-consumption-reconcile` 操作权限;无权限时用户详情不展示对账按钮。后台语义明确的用户 ID 或陶泥号旁统一使用图标按钮打开同一个弹窗,不复制页面级用户查询逻辑。
|
||||
|
||||
真实联调时显式开启该模块的 debug 日志:
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@ function mapAccountSnapshot(value) {
|
||||
return value;
|
||||
}
|
||||
assert(
|
||||
Array.isArray(value) && value.length === 10,
|
||||
Array.isArray(value) && value.length === 11,
|
||||
'Invalid admin account snapshot.',
|
||||
);
|
||||
return {
|
||||
@@ -209,6 +209,7 @@ function mapAccountSnapshot(value) {
|
||||
updated_by: value[7],
|
||||
created_at_micros: value[8],
|
||||
updated_at_micros: value[9],
|
||||
action_permissions_json: value[10],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,6 +323,7 @@ async function runSmoke(serverUrl, bootstrapSecret) {
|
||||
tab_permissions_json: '["dashboard"]',
|
||||
enabled: true,
|
||||
created_by: 'admin-account-smoke',
|
||||
action_permissions_json: '[]',
|
||||
};
|
||||
const updateInput = {
|
||||
account_id: 'member-1',
|
||||
@@ -330,6 +332,7 @@ async function runSmoke(serverUrl, bootstrapSecret) {
|
||||
tab_permissions_json: '["dashboard","tracking"]',
|
||||
enabled: false,
|
||||
updated_by: 'admin-account-smoke',
|
||||
action_permissions_json: '["profile-wallet-consumption-reconcile"]',
|
||||
};
|
||||
|
||||
assertDenied(
|
||||
@@ -475,6 +478,11 @@ async function runSmoke(serverUrl, bootstrapSecret) {
|
||||
updated.ok && updated.account?.token_version === 2,
|
||||
'State update did not increment once.',
|
||||
);
|
||||
assert(
|
||||
updated.account?.action_permissions_json ===
|
||||
'["profile-wallet-consumption-reconcile"]',
|
||||
'Action permission was not persisted.',
|
||||
);
|
||||
|
||||
const displayOnly = parseAccountResult(
|
||||
await callProcedure(
|
||||
|
||||
@@ -21,6 +21,7 @@ use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use shared_contracts::admin::{
|
||||
ADMIN_ACTION_PERMISSIONS, ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE,
|
||||
ADMIN_TAB_PERMISSIONS, AdminAccountRole, AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketRequest,
|
||||
AdminCreateEditorShowcaseCampaignImageUploadTicketResponse, AdminDashboardBreakdownRowPayload,
|
||||
@@ -154,6 +155,15 @@ impl AuthenticatedAdmin {
|
||||
pub fn session(&self) -> &AdminSessionPayload {
|
||||
&self.session
|
||||
}
|
||||
|
||||
pub fn can(&self, action_permission: &str) -> bool {
|
||||
self.session.account_role == AdminAccountRole::Owner
|
||||
|| self
|
||||
.session
|
||||
.action_permissions
|
||||
.iter()
|
||||
.any(|permission| permission == action_permission)
|
||||
}
|
||||
}
|
||||
|
||||
impl AdminDisplayNameDirectory {
|
||||
@@ -214,7 +224,8 @@ pub async fn admin_login(
|
||||
}
|
||||
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let (claims, tab_permissions) = if submitted_username == expected_username.to_ascii_lowercase()
|
||||
let (claims, tab_permissions, action_permissions) = if submitted_username
|
||||
== expected_username.to_ascii_lowercase()
|
||||
{
|
||||
if submitted_password.trim() != expected_password {
|
||||
verify_admin_password(admin_dummy_password_hash().await?, submitted_password)
|
||||
@@ -228,6 +239,10 @@ pub async fn admin_login(
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect(),
|
||||
ADMIN_ACTION_PERMISSIONS
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
let submitted_username = match normalize_admin_account_username(&submitted_username) {
|
||||
@@ -264,6 +279,7 @@ pub async fn admin_login(
|
||||
};
|
||||
let account = account.account;
|
||||
let tab_permissions = account.tab_permissions.clone();
|
||||
let action_permissions = account.action_permissions.clone();
|
||||
(
|
||||
runtime
|
||||
.build_account_claims(
|
||||
@@ -276,6 +292,7 @@ pub async fn admin_login(
|
||||
)
|
||||
.map_err(internal_admin_error)?,
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
)
|
||||
};
|
||||
let token = runtime.sign_token(&claims).map_err(|error| {
|
||||
@@ -293,6 +310,7 @@ pub async fn admin_login(
|
||||
claims.username.clone(),
|
||||
claims.display_name.clone(),
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
)
|
||||
.map_err(internal_admin_error)?
|
||||
};
|
||||
@@ -1613,12 +1631,14 @@ pub async fn require_admin_auth(
|
||||
account.username,
|
||||
account.display_name,
|
||||
account.tab_permissions,
|
||||
account.action_permissions,
|
||||
)
|
||||
}
|
||||
.map_err(|error| AppError::from_status(StatusCode::UNAUTHORIZED).with_message(error))?;
|
||||
enforce_admin_request_permission(
|
||||
&admin_session.account_role,
|
||||
&admin_session.tab_permissions,
|
||||
&admin_session.action_permissions,
|
||||
request.method(),
|
||||
request.uri().path(),
|
||||
)?;
|
||||
@@ -1683,13 +1703,15 @@ fn is_admin_account_not_found(error: &SpacetimeClientError) -> bool {
|
||||
|
||||
enum AdminPermissionRequirement {
|
||||
Authenticated,
|
||||
AnyOf(&'static [&'static str]),
|
||||
AnyTab(&'static [&'static str]),
|
||||
Action(&'static str),
|
||||
OwnerOnly,
|
||||
}
|
||||
|
||||
fn enforce_admin_request_permission(
|
||||
account_role: &str,
|
||||
tab_permissions: &[String],
|
||||
action_permissions: &[String],
|
||||
method: &Method,
|
||||
path: &str,
|
||||
) -> Result<(), AppError> {
|
||||
@@ -1700,42 +1722,49 @@ fn enforce_admin_request_permission(
|
||||
let allowed = match requirement {
|
||||
AdminPermissionRequirement::Authenticated => true,
|
||||
AdminPermissionRequirement::OwnerOnly => false,
|
||||
AdminPermissionRequirement::AnyOf(required) => required
|
||||
AdminPermissionRequirement::AnyTab(required) => required
|
||||
.iter()
|
||||
.any(|permission| tab_permissions.iter().any(|owned| owned == permission)),
|
||||
AdminPermissionRequirement::Action(required) => action_permissions
|
||||
.iter()
|
||||
.any(|permission| permission == required),
|
||||
};
|
||||
if allowed {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("当前后台账号无权访问该 Tab"))
|
||||
Err(AppError::from_status(StatusCode::FORBIDDEN).with_message("当前后台账号无权执行该操作"))
|
||||
}
|
||||
}
|
||||
|
||||
fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermissionRequirement {
|
||||
use AdminPermissionRequirement::{AnyOf, Authenticated, OwnerOnly};
|
||||
use AdminPermissionRequirement::{Action, AnyTab, Authenticated, OwnerOnly};
|
||||
|
||||
match path {
|
||||
"/admin/api/me" => Authenticated,
|
||||
"/admin/api/dashboard" => AnyOf(&["dashboard"]),
|
||||
"/admin/api/overview" => AnyOf(&["overview"]),
|
||||
"/admin/api/debug/http" => AnyOf(&["debug"]),
|
||||
"/admin/api/tracking/events" => AnyOf(&["tracking"]),
|
||||
"/admin/api/tracking/event-keys" => AnyOf(&["tracking", "tasks"]),
|
||||
"/admin/api/feature-gates" => AnyOf(&["gray-release"]),
|
||||
"/admin/api/editor-generation-pricing" => AnyOf(&["editor-generation-pricing"]),
|
||||
"/admin/api/editor-assets" => AnyOf(&["editor-assets"]),
|
||||
"/admin/api/assets/read-url" => AnyOf(&["editor-assets", "editor-showcase"]),
|
||||
path if path.starts_with("/admin/api/editor-showcase/") => AnyOf(&["editor-showcase"]),
|
||||
"/admin/api/dashboard" => AnyTab(&["dashboard"]),
|
||||
"/admin/api/overview" => AnyTab(&["overview"]),
|
||||
"/admin/api/debug/http" => AnyTab(&["debug"]),
|
||||
"/admin/api/tracking/events" => AnyTab(&["tracking"]),
|
||||
"/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]),
|
||||
"/admin/api/feature-gates" => AnyTab(&["gray-release"]),
|
||||
"/admin/api/editor-generation-pricing" => AnyTab(&["editor-generation-pricing"]),
|
||||
"/admin/api/editor-assets" => AnyTab(&["editor-assets"]),
|
||||
"/admin/api/assets/read-url" => AnyTab(&["editor-assets", "editor-showcase"]),
|
||||
path if path.starts_with("/admin/api/editor-showcase/") => AnyTab(&["editor-showcase"]),
|
||||
"/admin/api/profile/redeem-codes" | "/admin/api/profile/redeem-codes/disable" => {
|
||||
AnyOf(&["redeem"])
|
||||
AnyTab(&["redeem"])
|
||||
}
|
||||
"/admin/api/profile/invite-codes" => AnyOf(&["invite"]),
|
||||
"/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyOf(&["tasks"]),
|
||||
"/admin/api/profile/wallet-config" => AnyOf(&["profile-wallet"]),
|
||||
"/admin/api/profile/recharge-products" => AnyOf(&["recharge-products"]),
|
||||
path if path.starts_with("/admin/api/profile/recharge-") => AnyOf(&["recharge-orders"]),
|
||||
"/admin/api/profile/wallet-restriction" => AnyOf(&["recharge-orders"]),
|
||||
"/admin/api/profile/users/detail" => AnyOf(&[
|
||||
"/admin/api/profile/invite-codes" => AnyTab(&["invite"]),
|
||||
"/admin/api/profile/tasks" | "/admin/api/profile/tasks/disable" => AnyTab(&["tasks"]),
|
||||
"/admin/api/profile/wallet-config" => AnyTab(&["profile-wallet"]),
|
||||
"/admin/api/profile/recharge-products" => AnyTab(&["recharge-products"]),
|
||||
path if path.starts_with("/admin/api/profile/recharge-") => AnyTab(&["recharge-orders"]),
|
||||
"/admin/api/profile/wallet-restriction" => AnyTab(&["recharge-orders"]),
|
||||
"/admin/api/profile/users/reconcile-consumption" => {
|
||||
Action(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE)
|
||||
}
|
||||
"/admin/api/profile/users/initialize-consumption-projections" => OwnerOnly,
|
||||
"/admin/api/profile/users/detail" => AnyTab(&[
|
||||
"tables",
|
||||
"tracking",
|
||||
"recharge-orders",
|
||||
@@ -1745,7 +1774,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission
|
||||
path if path == "/admin/api/database/tables"
|
||||
|| path.starts_with("/admin/api/database/tables/") =>
|
||||
{
|
||||
AnyOf(&["tables"])
|
||||
AnyTab(&["tables"])
|
||||
}
|
||||
path if path == "/admin/api/accounts" || path.starts_with("/admin/api/accounts/") => {
|
||||
OwnerOnly
|
||||
@@ -3982,6 +4011,14 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
} else {
|
||||
session.tab_permissions
|
||||
};
|
||||
let action_permissions = if account_role == AdminAccountRole::Owner {
|
||||
ADMIN_ACTION_PERMISSIONS
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect()
|
||||
} else {
|
||||
session.action_permissions
|
||||
};
|
||||
AdminSessionPayload {
|
||||
subject: session.subject,
|
||||
username: session.username,
|
||||
@@ -3989,6 +4026,7 @@ fn build_admin_session_payload(session: crate::state::AdminSession) -> AdminSess
|
||||
roles: session.roles,
|
||||
account_role,
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
issued_at: session
|
||||
.issued_at
|
||||
.format(&Rfc3339)
|
||||
@@ -5417,6 +5455,7 @@ mod tests {
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["tasks".to_string()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/tracking/event-keys",
|
||||
)
|
||||
@@ -5426,6 +5465,7 @@ mod tests {
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["tracking".to_string()],
|
||||
&[],
|
||||
&Method::POST,
|
||||
"/admin/api/profile/wallet-restriction",
|
||||
)
|
||||
@@ -5478,6 +5518,7 @@ mod tests {
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&[permission.to_string()],
|
||||
&[],
|
||||
&method,
|
||||
path,
|
||||
)
|
||||
@@ -5487,19 +5528,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wallet_consumption_reconcile_requires_its_standalone_action_permission() {
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["recharge-orders".to_string()],
|
||||
&[],
|
||||
&Method::POST,
|
||||
"/admin/api/profile/users/reconcile-consumption",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&[],
|
||||
&["profile-wallet-consumption-reconcile".to_string()],
|
||||
&Method::POST,
|
||||
"/admin/api/profile/users/reconcile-consumption",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&[],
|
||||
&["profile-wallet-consumption-reconcile".to_string()],
|
||||
&Method::POST,
|
||||
"/admin/api/profile/users/initialize-consumption-projections",
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_admin_routes_are_owner_only() {
|
||||
assert!(enforce_admin_request_permission(
|
||||
"owner",
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/future-route",
|
||||
)
|
||||
.is_ok());
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"owner",
|
||||
&[],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/future-route",
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
enforce_admin_request_permission(
|
||||
"member",
|
||||
&["dashboard".to_string()],
|
||||
&[],
|
||||
&Method::GET,
|
||||
"/admin/api/future-route",
|
||||
)
|
||||
|
||||
@@ -5,9 +5,9 @@ use axum::{
|
||||
};
|
||||
use serde_json::Value;
|
||||
use shared_contracts::admin::{
|
||||
ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload, AdminAccountRole,
|
||||
AdminCreateAccountRequest, AdminCreateAccountResponse, AdminUpdateAccountRequest,
|
||||
AdminUpdateAccountResponse,
|
||||
ADMIN_ACTION_PERMISSIONS, ADMIN_TAB_PERMISSIONS, AdminAccountListResponse, AdminAccountPayload,
|
||||
AdminAccountRole, AdminCreateAccountRequest, AdminCreateAccountResponse,
|
||||
AdminUpdateAccountRequest, AdminUpdateAccountResponse,
|
||||
};
|
||||
use spacetime_client::{
|
||||
AdminAccountCreateRecordInput, AdminAccountRecord, AdminAccountUpdateRecordInput,
|
||||
@@ -45,6 +45,10 @@ pub async fn admin_list_accounts(
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect(),
|
||||
action_permissions: ADMIN_ACTION_PERMISSIONS
|
||||
.iter()
|
||||
.map(|permission| (*permission).to_string())
|
||||
.collect(),
|
||||
enabled: true,
|
||||
token_version: 1,
|
||||
created_by: "environment".to_string(),
|
||||
@@ -85,6 +89,7 @@ pub async fn admin_create_account(
|
||||
let display_name = normalize_display_name(&payload.display_name)?;
|
||||
validate_password(&payload.password)?;
|
||||
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
||||
let action_permissions = normalize_action_permissions(payload.action_permissions)?;
|
||||
let password_hash = hash_admin_password(&payload.password).await?;
|
||||
|
||||
let account = state
|
||||
@@ -95,6 +100,7 @@ pub async fn admin_create_account(
|
||||
display_name,
|
||||
password_hash,
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
enabled: payload.enabled,
|
||||
created_by: admin.session().subject.clone(),
|
||||
})
|
||||
@@ -133,6 +139,7 @@ pub async fn admin_update_account(
|
||||
}
|
||||
let display_name = normalize_display_name(&payload.display_name)?;
|
||||
let tab_permissions = normalize_tab_permissions(payload.tab_permissions)?;
|
||||
let action_permissions = normalize_action_permissions(payload.action_permissions)?;
|
||||
let password_hash = match payload.password {
|
||||
Some(password) => {
|
||||
validate_password(&password)?;
|
||||
@@ -148,6 +155,7 @@ pub async fn admin_update_account(
|
||||
display_name,
|
||||
password_hash,
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
enabled: payload.enabled,
|
||||
updated_by: admin.session().subject.clone(),
|
||||
})
|
||||
@@ -204,19 +212,37 @@ fn validate_password(password: &str) -> Result<(), AppError> {
|
||||
}
|
||||
|
||||
fn normalize_tab_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
normalize_permissions(
|
||||
permissions,
|
||||
&ADMIN_TAB_PERMISSIONS,
|
||||
"后台 Tab 权限包含未知值",
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_action_permissions(permissions: Vec<String>) -> Result<Vec<String>, AppError> {
|
||||
normalize_permissions(
|
||||
permissions,
|
||||
&ADMIN_ACTION_PERMISSIONS,
|
||||
"后台操作权限包含未知值",
|
||||
)
|
||||
}
|
||||
|
||||
fn normalize_permissions(
|
||||
permissions: Vec<String>,
|
||||
allowed_permissions: &[&str],
|
||||
unknown_message: &str,
|
||||
) -> Result<Vec<String>, AppError> {
|
||||
let requested = permissions
|
||||
.into_iter()
|
||||
.map(|permission| permission.trim().to_string())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
if requested
|
||||
.iter()
|
||||
.any(|permission| !ADMIN_TAB_PERMISSIONS.contains(&permission.as_str()))
|
||||
.any(|permission| !allowed_permissions.contains(&permission.as_str()))
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message("后台 Tab 权限包含未知值")
|
||||
);
|
||||
return Err(AppError::from_status(StatusCode::BAD_REQUEST).with_message(unknown_message));
|
||||
}
|
||||
Ok(ADMIN_TAB_PERMISSIONS
|
||||
Ok(allowed_permissions
|
||||
.iter()
|
||||
.filter(|permission| requested.contains(**permission))
|
||||
.map(|permission| (*permission).to_string())
|
||||
@@ -230,6 +256,7 @@ fn admin_account_payload(account: AdminAccountRecord) -> AdminAccountPayload {
|
||||
display_name: account.display_name,
|
||||
account_role: AdminAccountRole::Member,
|
||||
tab_permissions: account.tab_permissions,
|
||||
action_permissions: account.action_permissions,
|
||||
enabled: account.enabled,
|
||||
token_version: account.token_version,
|
||||
created_by: account.created_by,
|
||||
@@ -271,6 +298,16 @@ mod tests {
|
||||
assert!(normalize_tab_permissions(vec!["accounts".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_permissions_are_separate_from_tab_permissions() {
|
||||
assert_eq!(
|
||||
normalize_action_permissions(vec!["profile-wallet-consumption-reconcile".to_string(),])
|
||||
.expect("action permission should normalize"),
|
||||
["profile-wallet-consumption-reconcile"]
|
||||
);
|
||||
assert!(normalize_action_permissions(vec!["recharge-orders".to_string()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usernames_are_ascii_and_lowercase() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -21,6 +21,8 @@ use module_runtime::{
|
||||
build_runtime_profile_recharge_refund_hold_preview_input,
|
||||
build_runtime_profile_recharge_refund_manual_review_resolve_input,
|
||||
build_runtime_profile_recharge_refund_settlement_plan,
|
||||
build_runtime_profile_wallet_consumption_projection_initialize_input,
|
||||
build_runtime_profile_wallet_consumption_reconcile_input,
|
||||
build_runtime_profile_wallet_manual_restriction_upsert_input,
|
||||
};
|
||||
use platform_wechat::pay::{
|
||||
@@ -29,12 +31,14 @@ use platform_wechat::pay::{
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::admin::{
|
||||
AdminProfileWalletPayload, AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery,
|
||||
AdminRechargeOrderListResponse, AdminRechargeRefundActionResponse,
|
||||
AdminRechargeRefundExecuteRequest, AdminRechargeRefundHoldPayload,
|
||||
AdminRechargeRefundManualReviewResolveRequest, AdminRechargeRefundPayload,
|
||||
AdminRechargeRefundPreviewRequest, AdminRechargeRefundPreviewResponse,
|
||||
AdminRechargeRefundRegisterRequest, AdminUserDetailQuery, AdminUserDetailResponse,
|
||||
ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE, AdminProfileWalletPayload,
|
||||
AdminRechargeOrderEntryPayload, AdminRechargeOrderListQuery, AdminRechargeOrderListResponse,
|
||||
AdminRechargeRefundActionResponse, AdminRechargeRefundExecuteRequest,
|
||||
AdminRechargeRefundHoldPayload, AdminRechargeRefundManualReviewResolveRequest,
|
||||
AdminRechargeRefundPayload, AdminRechargeRefundPreviewRequest,
|
||||
AdminRechargeRefundPreviewResponse, AdminRechargeRefundRegisterRequest,
|
||||
AdminUserConsumptionProjectionInitializeResponse, AdminUserConsumptionReconcileRequest,
|
||||
AdminUserConsumptionReconcileResponse, AdminUserDetailQuery, AdminUserDetailResponse,
|
||||
AdminUserSummaryPayload, AdminWalletManualRestrictionPayload, AdminWalletRestrictionRequest,
|
||||
AdminWalletRestrictionResponse, AdminWechatPaymentCheckPayload,
|
||||
};
|
||||
@@ -105,14 +109,14 @@ pub async fn admin_list_recharge_orders(
|
||||
pub async fn admin_get_user_detail(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_admin): Extension<AuthenticatedAdmin>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Query(query): Query<AdminUserDetailQuery>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user = resolve_user(&state, query.user_id, query.public_user_code)
|
||||
.map_err(|error| error_response(&request_context, error))?;
|
||||
let wallet = state
|
||||
let wallet_detail = state
|
||||
.spacetime_client()
|
||||
.admin_get_profile_wallet(
|
||||
.admin_get_profile_wallet_detail(
|
||||
build_runtime_profile_admin_wallet_get_input(user.id.clone()).map_err(|message| {
|
||||
error_response(
|
||||
&request_context,
|
||||
@@ -160,7 +164,9 @@ pub async fn admin_get_user_detail(
|
||||
binding_status: user.binding_status.as_str().to_string(),
|
||||
phone_bound: user.phone_number_masked.is_some(),
|
||||
wechat_bound: user.wechat_bound,
|
||||
wallet: map_wallet(wallet, Some(&admin_display_names)),
|
||||
historical_consumed_points: wallet_detail.historical_consumed_points,
|
||||
can_reconcile_consumption: admin.can(ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE),
|
||||
wallet: map_wallet(wallet_detail.wallet, Some(&admin_display_names)),
|
||||
recharge_orders: orders
|
||||
.into_iter()
|
||||
.map(|entry| map_order_entry(entry, Some(summary.clone())))
|
||||
@@ -169,6 +175,74 @@ pub async fn admin_get_user_detail(
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_reconcile_user_consumption(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
Json(payload): Json<AdminUserConsumptionReconcileRequest>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let user = resolve_user(&state, Some(payload.user_id), None)
|
||||
.map_err(|error| error_response(&request_context, error))?;
|
||||
let input = build_runtime_profile_wallet_consumption_reconcile_input(
|
||||
user.id,
|
||||
admin.session().subject.clone(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message),
|
||||
)
|
||||
})?;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_reconcile_profile_wallet_consumption(input)
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminUserConsumptionReconcileResponse {
|
||||
user_id: record.user_id,
|
||||
previous_historical_consumed_points: record.previous_historical_consumed_points,
|
||||
historical_consumed_points: record.historical_consumed_points,
|
||||
changed: record.changed,
|
||||
reconciled_at_micros: record.reconciled_at_micros,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_initialize_user_consumption_projections(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(admin): Extension<AuthenticatedAdmin>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
let input = build_runtime_profile_wallet_consumption_projection_initialize_input(
|
||||
admin.session().subject.clone(),
|
||||
)
|
||||
.map_err(|message| {
|
||||
error_response(
|
||||
&request_context,
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_message(message),
|
||||
)
|
||||
})?;
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
.admin_initialize_profile_wallet_consumption_projections(input)
|
||||
.await
|
||||
.map_err(|error| spacetime_error_response(&request_context, error))?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
AdminUserConsumptionProjectionInitializeResponse {
|
||||
scanned_ledger_count: record.scanned_ledger_count,
|
||||
projected_user_count: record.projected_user_count,
|
||||
inserted_projection_count: record.inserted_projection_count,
|
||||
updated_projection_count: record.updated_projection_count,
|
||||
initialized_at_micros: record.initialized_at_micros,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn admin_preview_recharge_refund(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
@@ -1515,6 +1589,16 @@ mod tests {
|
||||
let cases = [
|
||||
("GET", "/admin/api/profile/recharge-orders", None),
|
||||
("GET", "/admin/api/profile/users/detail?userId=user-1", None),
|
||||
(
|
||||
"POST",
|
||||
"/admin/api/profile/users/reconcile-consumption",
|
||||
Some(r#"{"userId":"user-1"}"#),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/admin/api/profile/users/initialize-consumption-projections",
|
||||
Some("{}"),
|
||||
),
|
||||
(
|
||||
"POST",
|
||||
"/admin/api/profile/recharge-refunds/preview",
|
||||
|
||||
@@ -18,9 +18,11 @@ use crate::{
|
||||
},
|
||||
admin_accounts::{admin_create_account, admin_list_accounts, admin_update_account},
|
||||
admin_recharge::{
|
||||
admin_execute_recharge_refund, admin_get_user_detail, admin_list_recharge_orders,
|
||||
admin_preview_recharge_refund, admin_register_recharge_refund,
|
||||
admin_resolve_recharge_refund_manual_review, admin_update_wallet_restriction,
|
||||
admin_execute_recharge_refund, admin_get_user_detail,
|
||||
admin_initialize_user_consumption_projections, admin_list_recharge_orders,
|
||||
admin_preview_recharge_refund, admin_reconcile_user_consumption,
|
||||
admin_register_recharge_refund, admin_resolve_recharge_refund_manual_review,
|
||||
admin_update_wallet_restriction,
|
||||
},
|
||||
runtime_profile::{
|
||||
admin_disable_profile_redeem_code, admin_disable_profile_task_config,
|
||||
@@ -279,6 +281,19 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/users/reconcile-consumption",
|
||||
post(admin_reconcile_user_consumption).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_admin_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/users/initialize-consumption-projections",
|
||||
post(admin_initialize_user_consumption_projections).route_layer(
|
||||
middleware::from_fn_with_state(state.clone(), require_admin_auth),
|
||||
),
|
||||
)
|
||||
.route(
|
||||
"/admin/api/profile/wallet-restriction",
|
||||
post(admin_update_wallet_restriction)
|
||||
|
||||
@@ -321,6 +321,7 @@ pub struct AdminSession {
|
||||
pub roles: Vec<String>,
|
||||
pub account_role: String,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub issued_at: OffsetDateTime,
|
||||
pub expires_at: OffsetDateTime,
|
||||
}
|
||||
@@ -1891,6 +1892,7 @@ impl AdminRuntime {
|
||||
roles: claims.roles.clone(),
|
||||
account_role: "owner".to_string(),
|
||||
tab_permissions: Vec::new(),
|
||||
action_permissions: Vec::new(),
|
||||
issued_at,
|
||||
expires_at,
|
||||
})
|
||||
@@ -1904,6 +1906,7 @@ impl AdminRuntime {
|
||||
username: String,
|
||||
display_name: String,
|
||||
tab_permissions: Vec<String>,
|
||||
action_permissions: Vec<String>,
|
||||
) -> Result<AdminSession, String> {
|
||||
if claims.user_id() != expected_subject {
|
||||
return Err("后台管理员主体不匹配".to_string());
|
||||
@@ -1925,6 +1928,7 @@ impl AdminRuntime {
|
||||
roles: claims.roles.clone(),
|
||||
account_role: "member".to_string(),
|
||||
tab_permissions,
|
||||
action_permissions,
|
||||
issued_at,
|
||||
expires_at,
|
||||
})
|
||||
@@ -1942,6 +1946,7 @@ impl AdminRuntime {
|
||||
roles: claims.roles.clone(),
|
||||
account_role: "owner".to_string(),
|
||||
tab_permissions: Vec::new(),
|
||||
action_permissions: Vec::new(),
|
||||
issued_at: claims.issued_at,
|
||||
expires_at: claims.expires_at,
|
||||
}
|
||||
|
||||
@@ -653,6 +653,30 @@ pub fn build_runtime_profile_admin_wallet_get_input(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_consumption_reconcile_input(
|
||||
user_id: String,
|
||||
admin_user_id: String,
|
||||
) -> Result<RuntimeProfileWalletConsumptionReconcileInput, String> {
|
||||
Ok(RuntimeProfileWalletConsumptionReconcileInput {
|
||||
user_id: normalize_profile_recharge_refund_identifier(user_id, "wallet_reconcile.user_id")?,
|
||||
admin_user_id: normalize_profile_recharge_refund_identifier(
|
||||
admin_user_id,
|
||||
"wallet_reconcile.admin_user_id",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_consumption_projection_initialize_input(
|
||||
admin_user_id: String,
|
||||
) -> Result<RuntimeProfileWalletConsumptionProjectionInitializeInput, String> {
|
||||
Ok(RuntimeProfileWalletConsumptionProjectionInitializeInput {
|
||||
admin_user_id: normalize_profile_recharge_refund_identifier(
|
||||
admin_user_id,
|
||||
"wallet_consumption_initialize.admin_user_id",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_runtime_profile_wallet_manual_restriction_upsert_input(
|
||||
user_id: String,
|
||||
frozen: bool,
|
||||
|
||||
@@ -1581,6 +1581,30 @@ pub struct RuntimeProfileAdminWalletSnapshot {
|
||||
pub manual_restriction: Option<RuntimeProfileWalletManualRestrictionSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileAdminWalletDetailRecord {
|
||||
pub wallet: RuntimeProfileAdminWalletSnapshot,
|
||||
pub historical_consumed_points: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileWalletConsumptionReconcileRecord {
|
||||
pub user_id: String,
|
||||
pub previous_historical_consumed_points: Option<u64>,
|
||||
pub historical_consumed_points: u64,
|
||||
pub changed: bool,
|
||||
pub reconciled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RuntimeProfileWalletConsumptionProjectionInitializeRecord {
|
||||
pub scanned_ledger_count: u64,
|
||||
pub projected_user_count: u64,
|
||||
pub inserted_projection_count: u64,
|
||||
pub updated_projection_count: u64,
|
||||
pub initialized_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeOrderAdminEntrySnapshot {
|
||||
@@ -1797,6 +1821,19 @@ pub struct RuntimeProfileAdminWalletGetInput {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionReconcileInput {
|
||||
pub user_id: String,
|
||||
pub admin_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionProjectionInitializeInput {
|
||||
pub admin_user_id: String,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletManualRestrictionUpsertInput {
|
||||
@@ -1956,6 +1993,51 @@ pub struct RuntimeProfileAdminWalletProcedureResult {
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileAdminWalletDetailProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileAdminWalletSnapshot>,
|
||||
pub historical_consumed_points: u64,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionReconcileSnapshot {
|
||||
pub user_id: String,
|
||||
pub previous_historical_consumed_points: Option<u64>,
|
||||
pub historical_consumed_points: u64,
|
||||
pub changed: bool,
|
||||
pub reconciled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionReconcileProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileWalletConsumptionReconcileSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionProjectionInitializeSnapshot {
|
||||
pub scanned_ledger_count: u64,
|
||||
pub projected_user_count: u64,
|
||||
pub inserted_projection_count: u64,
|
||||
pub updated_projection_count: u64,
|
||||
pub initialized_at_micros: i64,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult {
|
||||
pub ok: bool,
|
||||
pub record: Option<RuntimeProfileWalletConsumptionProjectionInitializeSnapshot>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RuntimeProfileRechargeRefundBillCheckpointProcedureResult {
|
||||
|
||||
@@ -28,6 +28,11 @@ pub const ADMIN_TAB_PERMISSIONS: [&str; 15] = [
|
||||
"editor-assets",
|
||||
];
|
||||
|
||||
/// 不随一级 Tab 自动授予的后台高风险操作权限。
|
||||
pub const ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE: &str =
|
||||
"profile-wallet-consumption-reconcile";
|
||||
pub const ADMIN_ACTION_PERMISSIONS: [&str; 1] = [ADMIN_ACTION_PROFILE_WALLET_CONSUMPTION_RECONCILE];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AdminAccountRole {
|
||||
@@ -411,6 +416,7 @@ pub struct AdminSessionPayload {
|
||||
pub roles: Vec<String>,
|
||||
pub account_role: AdminAccountRole,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub issued_at: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
@@ -424,6 +430,7 @@ pub struct AdminAccountPayload {
|
||||
pub display_name: String,
|
||||
pub account_role: AdminAccountRole,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub token_version: u64,
|
||||
pub created_by: String,
|
||||
@@ -445,6 +452,8 @@ pub struct AdminCreateAccountRequest {
|
||||
pub display_name: String,
|
||||
pub password: String,
|
||||
pub tab_permissions: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
@@ -461,6 +470,7 @@ pub struct AdminUpdateAccountRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
@@ -915,10 +925,38 @@ pub struct AdminUserDetailResponse {
|
||||
pub binding_status: String,
|
||||
pub phone_bound: bool,
|
||||
pub wechat_bound: bool,
|
||||
pub historical_consumed_points: u64,
|
||||
pub can_reconcile_consumption: bool,
|
||||
pub wallet: AdminProfileWalletPayload,
|
||||
pub recharge_orders: Vec<AdminRechargeOrderEntryPayload>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUserConsumptionReconcileRequest {
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUserConsumptionReconcileResponse {
|
||||
pub user_id: String,
|
||||
pub previous_historical_consumed_points: Option<u64>,
|
||||
pub historical_consumed_points: u64,
|
||||
pub changed: bool,
|
||||
pub reconciled_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminUserConsumptionProjectionInitializeResponse {
|
||||
pub scanned_ledger_count: u64,
|
||||
pub projected_user_count: u64,
|
||||
pub inserted_projection_count: u64,
|
||||
pub updated_projection_count: u64,
|
||||
pub initialized_at_micros: i64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminRechargeRefundPreviewRequest {
|
||||
|
||||
@@ -6,6 +6,7 @@ pub struct AdminAccountRecord {
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub token_version: u64,
|
||||
pub created_by: String,
|
||||
@@ -27,6 +28,7 @@ pub struct AdminAccountCreateRecordInput {
|
||||
pub display_name: String,
|
||||
pub password_hash: String,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub created_by: String,
|
||||
}
|
||||
@@ -37,6 +39,7 @@ pub struct AdminAccountUpdateRecordInput {
|
||||
pub display_name: String,
|
||||
pub password_hash: Option<String>,
|
||||
pub tab_permissions: Vec<String>,
|
||||
pub action_permissions: Vec<String>,
|
||||
pub enabled: bool,
|
||||
pub updated_by: String,
|
||||
}
|
||||
@@ -52,6 +55,8 @@ impl From<AdminAccountCreateRecordInput> for crate::module_bindings::AdminAccoun
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
enabled: input.enabled,
|
||||
created_by: input.created_by,
|
||||
action_permissions_json: serde_json::to_string(&input.action_permissions)
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +71,8 @@ impl From<AdminAccountUpdateRecordInput> for crate::module_bindings::AdminAccoun
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
enabled: input.enabled,
|
||||
updated_by: input.updated_by,
|
||||
action_permissions_json: serde_json::to_string(&input.action_permissions)
|
||||
.unwrap_or_else(|_| "[]".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,6 +130,10 @@ fn map_admin_account_snapshot(
|
||||
snapshot.tab_permissions_json.as_str(),
|
||||
)
|
||||
.map_err(SpacetimeClientError::validation_failed)?,
|
||||
action_permissions: serde_json::from_str::<Vec<String>>(
|
||||
snapshot.action_permissions_json.as_str(),
|
||||
)
|
||||
.map_err(SpacetimeClientError::validation_failed)?,
|
||||
enabled: snapshot.enabled,
|
||||
token_version: snapshot.token_version,
|
||||
created_by: snapshot.created_by,
|
||||
@@ -158,10 +169,15 @@ mod tests {
|
||||
updated_by: "owner".to_string(),
|
||||
created_at_micros: 0,
|
||||
updated_at_micros: 0,
|
||||
action_permissions_json: r#"["profile-wallet-consumption-reconcile"]"#.to_string(),
|
||||
})
|
||||
.expect("snapshot should map");
|
||||
|
||||
assert_eq!(record.tab_permissions, ["dashboard", "tracking"]);
|
||||
assert_eq!(
|
||||
record.action_permissions,
|
||||
["profile-wallet-consumption-reconcile"]
|
||||
);
|
||||
assert!(!record.enabled);
|
||||
assert_eq!(record.token_version, 3);
|
||||
}
|
||||
@@ -180,6 +196,7 @@ mod tests {
|
||||
updated_by: "owner".to_string(),
|
||||
created_at_micros: 0,
|
||||
updated_at_micros: 0,
|
||||
action_permissions_json: "[]".to_string(),
|
||||
},
|
||||
password_hash: "$argon2id$hash".to_string(),
|
||||
})
|
||||
|
||||
@@ -234,6 +234,29 @@ impl From<module_runtime::RuntimeProfileAdminWalletGetInput> for RuntimeProfileA
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileWalletConsumptionReconcileInput>
|
||||
for RuntimeProfileWalletConsumptionReconcileInput
|
||||
{
|
||||
fn from(input: module_runtime::RuntimeProfileWalletConsumptionReconcileInput) -> Self {
|
||||
Self {
|
||||
user_id: input.user_id,
|
||||
admin_user_id: input.admin_user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeInput>
|
||||
for RuntimeProfileWalletConsumptionProjectionInitializeInput
|
||||
{
|
||||
fn from(
|
||||
input: module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeInput,
|
||||
) -> Self {
|
||||
Self {
|
||||
admin_user_id: input.admin_user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<module_runtime::RuntimeProfileWalletManualRestrictionUpsertInput>
|
||||
for RuntimeProfileWalletManualRestrictionUpsertInput
|
||||
{
|
||||
@@ -790,6 +813,69 @@ pub(crate) fn map_runtime_profile_admin_wallet_procedure_result(
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile admin wallet 快照"))
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_admin_wallet_detail_procedure_result(
|
||||
result: RuntimeProfileAdminWalletDetailProcedureResult,
|
||||
) -> Result<module_runtime::RuntimeProfileAdminWalletDetailRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
let wallet = result
|
||||
.record
|
||||
.map(map_runtime_profile_admin_wallet_snapshot)
|
||||
.ok_or_else(|| SpacetimeClientError::missing_snapshot("profile admin wallet 详情快照"))?;
|
||||
Ok(module_runtime::RuntimeProfileAdminWalletDetailRecord {
|
||||
wallet,
|
||||
historical_consumed_points: result.historical_consumed_points,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_wallet_consumption_reconcile_procedure_result(
|
||||
result: RuntimeProfileWalletConsumptionReconcileProcedureResult,
|
||||
) -> Result<module_runtime::RuntimeProfileWalletConsumptionReconcileRecord, SpacetimeClientError> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
result
|
||||
.record
|
||||
.map(
|
||||
|record| module_runtime::RuntimeProfileWalletConsumptionReconcileRecord {
|
||||
user_id: record.user_id,
|
||||
previous_historical_consumed_points: record.previous_historical_consumed_points,
|
||||
historical_consumed_points: record.historical_consumed_points,
|
||||
changed: record.changed,
|
||||
reconciled_at_micros: record.reconciled_at_micros,
|
||||
},
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
SpacetimeClientError::missing_snapshot("profile wallet consumption 对账快照")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_wallet_consumption_projection_initialize_procedure_result(
|
||||
result: RuntimeProfileWalletConsumptionProjectionInitializeProcedureResult,
|
||||
) -> Result<
|
||||
module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeRecord,
|
||||
SpacetimeClientError,
|
||||
> {
|
||||
if !result.ok {
|
||||
return Err(SpacetimeClientError::procedure_failed(result.error_message));
|
||||
}
|
||||
result
|
||||
.record
|
||||
.map(
|
||||
|record| module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeRecord {
|
||||
scanned_ledger_count: record.scanned_ledger_count,
|
||||
projected_user_count: record.projected_user_count,
|
||||
inserted_projection_count: record.inserted_projection_count,
|
||||
updated_projection_count: record.updated_projection_count,
|
||||
initialized_at_micros: record.initialized_at_micros,
|
||||
},
|
||||
)
|
||||
.ok_or_else(|| {
|
||||
SpacetimeClientError::missing_snapshot("profile wallet consumption 初始化快照")
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_runtime_profile_recharge_refund_bill_checkpoint_optional_result(
|
||||
result: RuntimeProfileRechargeRefundBillCheckpointProcedureResult,
|
||||
) -> Result<
|
||||
|
||||
@@ -830,6 +830,87 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_get_profile_wallet_detail(
|
||||
&self,
|
||||
input: module_runtime::RuntimeProfileAdminWalletGetInput,
|
||||
) -> Result<module_runtime::RuntimeProfileAdminWalletDetailRecord, SpacetimeClientError> {
|
||||
let procedure_input: RuntimeProfileAdminWalletGetInput = input.into();
|
||||
self.call_after_connect(
|
||||
"admin_get_profile_wallet_detail_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_get_profile_wallet_detail_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(map_runtime_profile_admin_wallet_detail_procedure_result);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_reconcile_profile_wallet_consumption(
|
||||
&self,
|
||||
input: module_runtime::RuntimeProfileWalletConsumptionReconcileInput,
|
||||
) -> Result<module_runtime::RuntimeProfileWalletConsumptionReconcileRecord, SpacetimeClientError>
|
||||
{
|
||||
let procedure_input: RuntimeProfileWalletConsumptionReconcileInput = input.into();
|
||||
self.call_after_connect(
|
||||
"admin_reconcile_profile_wallet_consumption_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_reconcile_profile_wallet_consumption_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(
|
||||
map_runtime_profile_wallet_consumption_reconcile_procedure_result,
|
||||
);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_initialize_profile_wallet_consumption_projections(
|
||||
&self,
|
||||
input: module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeInput,
|
||||
) -> Result<
|
||||
module_runtime::RuntimeProfileWalletConsumptionProjectionInitializeRecord,
|
||||
SpacetimeClientError,
|
||||
> {
|
||||
let procedure_input: RuntimeProfileWalletConsumptionProjectionInitializeInput =
|
||||
input.into();
|
||||
self.call_after_connect(
|
||||
"admin_initialize_profile_wallet_consumption_projections_and_return",
|
||||
move |connection, sender| {
|
||||
connection
|
||||
.procedures()
|
||||
.admin_initialize_profile_wallet_consumption_projections_and_return_then(
|
||||
procedure_input,
|
||||
move |_, result| {
|
||||
let mapped = result
|
||||
.map_err(SpacetimeClientError::from_sdk_error)
|
||||
.and_then(
|
||||
map_runtime_profile_wallet_consumption_projection_initialize_procedure_result,
|
||||
);
|
||||
send_once(&sender, mapped);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn admin_upsert_profile_wallet_manual_restriction(
|
||||
&self,
|
||||
input: module_runtime::RuntimeProfileWalletManualRestrictionUpsertInput,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user