forked from wangziqi/gongxue-base
feat: add points risk report
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user