feat: add points risk report

This commit is contained in:
Codex
2026-06-30 15:12:32 +08:00
parent a5a2caa2e9
commit 87f4cf9c4e
15 changed files with 522 additions and 25 deletions

View File

@@ -21,6 +21,7 @@ import {
loadPointActivityTasks,
loadPointExchangeItems,
loadPointExchangeOrders,
loadPointsRiskReport,
loadTenantMembers,
loadUserNotifications,
updateCommissionSettings,
@@ -44,6 +45,7 @@ import {
type CrmQueueItem,
type PointActivityTaskItem,
type PointExchangeItem,
type PointsRiskReport,
type TenantMemberItem,
type UserNotificationAdminItem,
} from '@/services/tenantAdmin';
@@ -304,6 +306,7 @@ export default function TenantMarketingPage() {
const [pointClaims, setPointClaims] = useState<Record<string, unknown>[]>([]);
const [pointExchangeItems, setPointExchangeItems] = useState<PointExchangeItem[]>([]);
const [pointExchangeOrders, setPointExchangeOrders] = useState<Record<string, unknown>[]>([]);
const [pointsRiskReport, setPointsRiskReport] = useState<PointsRiskReport | null>(null);
const [crmForm, setCrmForm] = useState({
enabled: false,
url: '',
@@ -374,6 +377,7 @@ export default function TenantMarketingPage() {
pointClaimPayload,
pointExchangePayload,
pointExchangeOrderPayload,
pointsRiskPayload,
] = await Promise.all([
loadCoupons({ status: couponFilter.status || undefined, campaignName: couponFilter.campaignName || undefined }).catch(() => ({ items: [] })),
loadCouponReport({
@@ -406,6 +410,7 @@ export default function TenantMarketingPage() {
status: pointFilter.exchangeOrderStatus || undefined,
limit: 30,
}).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]);
const nextCrm = crmConfigPayload.item || null;
const nextSettings = commissionSettingsPayload.item || null;
@@ -427,6 +432,7 @@ export default function TenantMarketingPage() {
setPointClaims(pointClaimPayload.items || []);
setPointExchangeItems(pointExchangePayload.items || []);
setPointExchangeOrders(pointExchangeOrderPayload.items || []);
setPointsRiskReport(pointsRiskPayload.item || null);
if (nextCrm) {
setCrmForm({
enabled: nextCrm.enabled === true,
@@ -604,7 +610,7 @@ export default function TenantMarketingPage() {
setBusy('points');
setError('');
try {
const [taskPayload, claimPayload, exchangePayload, orderPayload] = await Promise.all([
const [taskPayload, claimPayload, exchangePayload, orderPayload, riskPayload] = await Promise.all([
loadPointActivityTasks({ status: nextFilter.taskStatus || undefined, limit: 100 }),
loadPointActivityClaims({
taskId: nextFilter.selectedTaskId || undefined,
@@ -618,11 +624,13 @@ export default function TenantMarketingPage() {
status: nextFilter.exchangeOrderStatus || undefined,
limit: 50,
}).catch(() => ({ items: [] })),
loadPointsRiskReport({ timeRange: '30d', limit: 20 }).catch(() => ({ item: null })),
]);
setPointTasks(taskPayload.items || []);
setPointClaims(claimPayload.items || []);
setPointExchangeItems(exchangePayload.items || []);
setPointExchangeOrders(orderPayload.items || []);
setPointsRiskReport(riskPayload.item || null);
} catch (nextError) {
setError(nextError instanceof Error ? nextError.message : '积分运营数据加载失败');
} finally {
@@ -980,6 +988,35 @@ export default function TenantMarketingPage() {
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointExchangeItems.length)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointExchangeOrders.length)}</Text></View>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'>30 </Text><Text className='admin-metric-value'>{String(pointsRiskReport?.summary?.riskScore || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointsRiskReport?.summary?.suspiciousUserCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointsRiskReport?.summary?.suspiciousEventCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(pointsRiskReport?.summary?.netPoints || 0)}</Text></View>
</View>
<View className='admin-list'>
{(pointsRiskReport?.suspiciousUsers || []).slice(0, 4).map((item, index) => (
<View className='admin-row' key={String(item.userId || index)}>
<Text className='admin-row-main'>{String(item.userName || item.userPhone || item.userId || '异常用户')} · {String(item.earnedPoints || 0)} </Text>
<Text className='admin-row-meta'> {String(item.claimCount || 0)} · {String(item.exchangePoints || 0)} · {Array.isArray(item.riskFlags) ? item.riskFlags.join(', ') : '-'}</Text>
</View>
))}
{(pointsRiskReport?.suspiciousEvents || []).slice(0, 3).map((item, index) => (
<View className='admin-row' key={String(item.id || index)}>
<Text className='admin-row-main'>{String(item.eventType || '积分事件')} · {Number(item.points || 0) > 0 ? '+' : ''}{String(item.points || 0)} </Text>
<Text className='admin-row-meta'>{String(item.userName || item.userPhone || item.userId || '学生')} · {String(item.sourceType || '-')} · {shortDate(String(item.createdAt || ''))}</Text>
</View>
))}
{(pointsRiskReport?.suspiciousTasks || []).slice(0, 3).map((item, index) => (
<View className='admin-row' key={String(item.taskId || index)}>
<Text className='admin-row-main'>{String(item.title || item.taskCode || '积分任务')} · {String(item.issuedPoints || 0)} </Text>
<Text className='admin-row-meta'>{taskTypeLabel(String(item.taskType || ''))} · {String(item.claimCount || 0)} / {String(item.claimUserCount || 0)} </Text>
</View>
))}
</View>
{!(pointsRiskReport?.suspiciousUsers?.length || pointsRiskReport?.suspiciousEvents?.length || pointsRiskReport?.suspiciousTasks?.length) ? (
<View className='admin-empty'> 30 </View>
) : null}
<View className='admin-form-grid'>
<Input
className='admin-input'

View File

@@ -579,6 +579,33 @@ export interface PointExchangeItemInput {
metadata?: Record<string, unknown>;
}
export interface PointsRiskReport {
range?: {
timeRange?: '7d' | '30d' | '90d';
startDate?: string;
endDate?: string;
};
thresholds?: Record<string, number>;
summary?: {
eventCount?: number;
affectedUsers?: number;
earnedPoints?: number;
spentPoints?: number;
netPoints?: number;
maxEarnedEvent?: number;
maxSpentEvent?: number;
suspiciousUserCount?: number;
suspiciousEventCount?: number;
riskScore?: number;
};
eventBreakdown?: Array<Record<string, unknown>>;
suspiciousUsers?: Array<Record<string, unknown>>;
suspiciousEvents?: Array<Record<string, unknown>>;
suspiciousTasks?: Array<Record<string, unknown>>;
suspiciousExchanges?: Array<Record<string, unknown>>;
dailyTrend?: Array<Record<string, unknown>>;
}
export interface CodeBatchItem {
id: string;
name?: string | null;
@@ -1242,6 +1269,19 @@ export async function loadPointExchangeOrders(query: {
});
}
export async function loadPointsRiskReport(query: {
timeRange?: '7d' | '30d' | '90d';
highEarnedThreshold?: number;
highClaimThreshold?: number;
highEventPointsThreshold?: number;
highRedeemThreshold?: number;
limit?: number;
} = {}) {
return apiRequest<{ item?: PointsRiskReport }>('/api/tenant-admin/points-risk-report', {
query: { ...query, limit: query.limit || 20 },
});
}
export async function loadCodeBatches() {
return apiRequest<{ items?: CodeBatchItem[] }>('/api/tenant-admin/code-batches');
}