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

@@ -105,6 +105,7 @@ export function tenantPermissionCatalog() {
{ key: 'marketing:write', label: '活动内容管理' },
{ key: 'marketing:points:read', label: '积分任务/兑换查看' },
{ key: 'marketing:points:write', label: '积分任务/兑换管理' },
{ key: 'marketing:points:risk:read', label: '积分风控查看' },
{ key: 'notifications:read', label: '用户站内通知查看' },
{ key: 'badges:read', label: '勋章查看' },
{ key: 'badges:write', label: '勋章管理' },

View File

@@ -31,6 +31,7 @@ import {
pointActivityTasksRoute,
pointExchangeItemsRoute,
pointExchangeOrdersRoute,
pointsRiskReportRoute,
upsertPointActivityTaskRoute,
upsertPointExchangeItemRoute,
} from './points.js';
@@ -147,6 +148,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/point-exchange-items', pointExchangeItemsRoute],
['PUT', '/api/tenant-admin/point-exchange-items', upsertPointExchangeItemRoute],
['GET', '/api/tenant-admin/point-exchange-orders', pointExchangeOrdersRoute],
['GET', '/api/tenant-admin/points-risk-report', pointsRiskReportRoute],
['GET', '/api/tenant-admin/members', tenantMembersRoute],
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],

View File

@@ -24,6 +24,11 @@ const TASK_STATUSES = ['active', 'disabled', 'archived'];
const EXCHANGE_ITEM_TYPES = ['coupon', 'manual', 'asset', 'custom'];
const EXCHANGE_ITEM_STATUSES = ['active', 'disabled', 'archived'];
const EXCHANGE_ORDER_STATUSES = ['completed', 'pending_fulfillment', 'cancelled'];
const RISK_RANGES: Record<string, number> = {
'7d': 7,
'30d': 30,
'90d': 90,
};
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -38,6 +43,11 @@ function intValue(value: unknown, fallback: number) {
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
function numberValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? parsed : fallback;
}
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
@@ -46,6 +56,57 @@ function optionalChoice(value: unknown, allowed: string[], fallback: string) {
return candidate;
}
function boundedIntParam(ctx: RequestContext, name: string, fallback: number, min: number, max: number) {
const parsed = Number(ctx.url.searchParams.get(name) || fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(max, Math.trunc(parsed)));
}
function shanghaiDateParts(date = new Date()) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
return Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value])) as {
year: string;
month: string;
day: string;
};
}
function shanghaiDateKey(date = new Date()) {
const parts = shanghaiDateParts(date);
return `${parts.year}-${parts.month}-${parts.day}`;
}
function addDaysKey(dateKey: string, days: number) {
const date = new Date(`${dateKey}T00:00:00+08:00`);
date.setUTCDate(date.getUTCDate() + days);
return shanghaiDateKey(date);
}
function shanghaiDayStartIso(dateKey: string) {
return new Date(`${dateKey}T00:00:00+08:00`).toISOString();
}
function pointsRiskRange(value: string) {
const timeRange = value || '30d';
const days = RISK_RANGES[timeRange];
if (!days) throw new HttpError(400, 'Unsupported points risk report range', 'INVALID_POINTS_RISK_RANGE');
const endDate = shanghaiDateKey();
const startDate = addDaysKey(endDate, -(days - 1));
return {
timeRange,
days,
startDate,
endDate,
startAt: shanghaiDayStartIso(startDate),
endAt: shanghaiDayStartIso(addDaysKey(endDate, 1)),
};
}
function optionalUuidString(value: unknown, key: string) {
const candidate = nullableString(value);
if (!candidate) return null;
@@ -90,6 +151,279 @@ function requirePointMarketingPermission(auth: TenantAdminAuth, permission: 'rea
requireTenantPermission(auth, specific);
}
function requirePointsRiskPermission(auth: TenantAdminAuth) {
if (
hasTenantPermission(auth, 'marketing:points:risk:read')
|| hasTenantPermission(auth, 'marketing:points:read')
|| hasTenantPermission(auth, 'marketing:read')
) {
return;
}
requireTenantPermission(auth, 'marketing:points:risk:read');
}
function totalRows(rows: Record<string, unknown>[]) {
return intValue(rows[0]?.totalCount, rows.length);
}
function stripTotalCount(rows: Record<string, unknown>[]) {
return rows.map(({ totalCount: _totalCount, ...item }) => item);
}
export async function pointsRiskReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requirePointsRiskPermission(auth);
const range = pointsRiskRange(stringParam(ctx, 'timeRange'));
const limit = intParam(ctx, 'limit', 20, 100);
const highEarnedThreshold = boundedIntParam(ctx, 'highEarnedThreshold', 100, 1, 1000000);
const highClaimThreshold = boundedIntParam(ctx, 'highClaimThreshold', 5, 1, 10000);
const highEventPointsThreshold = boundedIntParam(ctx, 'highEventPointsThreshold', 100, 1, 1000000);
const highRedeemThreshold = boundedIntParam(ctx, 'highRedeemThreshold', 100, 1, 10000000);
const baseParams = [auth.tenantId, range.startAt, range.endAt];
const [
summary,
eventBreakdown,
suspiciousUsers,
suspiciousEvents,
suspiciousTasks,
suspiciousExchanges,
dailyTrend,
] = await Promise.all([
query<Record<string, unknown>>(
`
select
count(*)::int as "eventCount",
count(distinct user_id)::int as "affectedUsers",
coalesce(sum(points) filter (where points > 0), 0)::int as "earnedPoints",
coalesce(abs(sum(points) filter (where points < 0)), 0)::int as "spentPoints",
max(points) filter (where points > 0)::int as "maxEarnedEvent",
min(points) filter (where points < 0)::int as "maxSpentEvent"
from public.user_score_events
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
`,
baseParams,
),
query<Record<string, unknown>>(
`
select event_type as "eventType",
count(*)::int as "eventCount",
count(distinct user_id)::int as "userCount",
coalesce(sum(points), 0)::int as "netPoints",
coalesce(sum(points) filter (where points > 0), 0)::int as "earnedPoints",
coalesce(abs(sum(points) filter (where points < 0)), 0)::int as "spentPoints"
from public.user_score_events
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by event_type
order by "eventCount" desc
`,
baseParams,
),
query<Record<string, unknown>>(
`
with user_points as (
select e.user_id,
count(*)::int as event_count,
count(*) filter (where e.points > 0)::int as earn_event_count,
coalesce(sum(e.points) filter (where e.points > 0), 0)::int as earned_points,
coalesce(abs(sum(e.points) filter (where e.points < 0)), 0)::int as spent_points,
max(e.points) filter (where e.points > 0)::int as max_earned_event,
max(abs(e.points)) filter (where e.points < 0)::int as max_spent_event,
max(e.created_at) as latest_event_at
from public.user_score_events e
where e.tenant_id = $1
and e.created_at >= $2::timestamptz
and e.created_at < $3::timestamptz
group by e.user_id
),
user_claims as (
select c.user_id, count(*)::int as claim_count
from public.user_point_activity_claims c
where c.tenant_id = $1
and c.claimed_at >= $2::timestamptz
and c.claimed_at < $3::timestamptz
and c.status = 'claimed'
group by c.user_id
),
user_exchanges as (
select o.user_id,
count(*)::int as exchange_count,
coalesce(sum(o.cost_points) filter (where o.status <> 'cancelled'), 0)::int as exchange_points
from public.user_point_exchange_orders o
where o.tenant_id = $1
and o.exchanged_at >= $2::timestamptz
and o.exchanged_at < $3::timestamptz
group by o.user_id
)
select (count(*) over())::int as "totalCount",
up.user_id as "userId", u.name as "userName", u.phone as "userPhone", u.primary_role as "primaryRole",
up.event_count as "eventCount", up.earn_event_count as "earnEventCount",
coalesce(uc.claim_count, 0)::int as "claimCount",
coalesce(ue.exchange_count, 0)::int as "exchangeCount",
up.earned_points as "earnedPoints", up.spent_points as "spentPoints",
coalesce(ue.exchange_points, 0)::int as "exchangePoints",
up.max_earned_event as "maxEarnedEvent", up.max_spent_event as "maxSpentEvent",
up.latest_event_at as "latestEventAt",
array_remove(array[
case when up.earned_points >= $4::int then 'high_earned_points' end,
case when coalesce(uc.claim_count, 0) >= $5::int then 'high_claim_frequency' end,
case when coalesce(ue.exchange_points, 0) >= $6::int then 'high_exchange_points' end,
case when coalesce(up.max_earned_event, 0) >= $7::int then 'large_single_earn_event' end
], null) as "riskFlags"
from user_points up
left join user_claims uc on uc.user_id = up.user_id
left join user_exchanges ue on ue.user_id = up.user_id
left join public.platform_users u on u.id = up.user_id
where up.earned_points >= $4::int
or coalesce(uc.claim_count, 0) >= $5::int
or coalesce(ue.exchange_points, 0) >= $6::int
or coalesce(up.max_earned_event, 0) >= $7::int
order by array_length(array_remove(array[
case when up.earned_points >= $4::int then 'high_earned_points' end,
case when coalesce(uc.claim_count, 0) >= $5::int then 'high_claim_frequency' end,
case when coalesce(ue.exchange_points, 0) >= $6::int then 'high_exchange_points' end,
case when coalesce(up.max_earned_event, 0) >= $7::int then 'large_single_earn_event' end
], null), 1) desc nulls last,
up.earned_points desc, coalesce(uc.claim_count, 0) desc, up.latest_event_at desc
limit $8
`,
[
...baseParams,
highEarnedThreshold,
highClaimThreshold,
highRedeemThreshold,
highEventPointsThreshold,
limit,
],
),
query<Record<string, unknown>>(
`
select (count(*) over())::int as "totalCount",
e.id, e.user_id as "userId", u.name as "userName", u.phone as "userPhone",
e.event_type as "eventType", e.points, e.balance_after as "balanceAfter",
e.source_type as "sourceType", e.source_id as "sourceId",
e.metadata, e.created_at as "createdAt"
from public.user_score_events e
left join public.platform_users u on u.id = e.user_id
where e.tenant_id = $1
and e.created_at >= $2::timestamptz
and e.created_at < $3::timestamptz
and abs(e.points) >= $4::int
order by abs(e.points) desc, e.created_at desc
limit $5
`,
[...baseParams, highEventPointsThreshold, limit],
),
query<Record<string, unknown>>(
`
select (count(*) over())::int as "totalCount",
t.id as "taskId", t.code::text as "taskCode", t.title,
t.task_type as "taskType", t.reward_points as "rewardPoints",
count(c.id)::int as "claimCount",
count(distinct c.user_id)::int as "claimUserCount",
coalesce(sum(t.reward_points), 0)::int as "issuedPoints",
max(c.claimed_at) as "latestClaimedAt"
from public.point_activity_tasks t
join public.user_point_activity_claims c
on c.tenant_id = t.tenant_id
and c.task_id = t.id
and c.status = 'claimed'
and c.claimed_at >= $2::timestamptz
and c.claimed_at < $3::timestamptz
where t.tenant_id = $1
group by t.id, t.code, t.title, t.task_type, t.reward_points
having count(c.id) >= $4::int
or coalesce(sum(t.reward_points), 0) >= $5::int
order by "claimCount" desc, "issuedPoints" desc, "latestClaimedAt" desc
limit $6
`,
[
...baseParams,
highClaimThreshold,
highEarnedThreshold,
limit,
],
),
query<Record<string, unknown>>(
`
select (count(*) over())::int as "totalCount",
o.id, o.user_id as "userId", u.name as "userName", u.phone as "userPhone",
o.item_id as "itemId", i.code::text as "itemCode", i.title as "itemTitle",
i.item_type as "itemType", o.status, o.cost_points as "costPoints",
o.coupon_redemption_id as "couponRedemptionId", cr.coupon_code as "couponCode",
o.asset_id as "assetId", o.metadata, o.exchanged_at as "exchangedAt"
from public.user_point_exchange_orders o
join public.point_exchange_items i on i.tenant_id = o.tenant_id and i.id = o.item_id
left join public.platform_users u on u.id = o.user_id
left join public.coupon_redemptions cr on cr.tenant_id = o.tenant_id and cr.id = o.coupon_redemption_id
where o.tenant_id = $1
and o.exchanged_at >= $2::timestamptz
and o.exchanged_at < $3::timestamptz
and o.cost_points >= $4::int
order by o.cost_points desc, o.exchanged_at desc
limit $5
`,
[...baseParams, highRedeemThreshold, limit],
),
query<Record<string, unknown>>(
`
select (created_at at time zone 'Asia/Shanghai')::date::text as date,
count(*)::int as "eventCount",
coalesce(sum(points) filter (where points > 0), 0)::int as "earnedPoints",
coalesce(abs(sum(points) filter (where points < 0)), 0)::int as "spentPoints"
from public.user_score_events
where tenant_id = $1
and created_at >= $2::timestamptz
and created_at < $3::timestamptz
group by (created_at at time zone 'Asia/Shanghai')::date
order by date asc
`,
baseParams,
),
]);
const summaryRow = summary[0] || {};
const suspiciousUserCount = totalRows(suspiciousUsers);
const suspiciousEventCount = totalRows(suspiciousEvents) + totalRows(suspiciousTasks) + totalRows(suspiciousExchanges);
return {
item: {
range,
thresholds: {
highEarnedThreshold,
highClaimThreshold,
highEventPointsThreshold,
highRedeemThreshold,
},
summary: {
eventCount: intValue(summaryRow.eventCount, 0),
affectedUsers: intValue(summaryRow.affectedUsers, 0),
earnedPoints: intValue(summaryRow.earnedPoints, 0),
spentPoints: intValue(summaryRow.spentPoints, 0),
netPoints: intValue(summaryRow.earnedPoints, 0) - intValue(summaryRow.spentPoints, 0),
maxEarnedEvent: intValue(summaryRow.maxEarnedEvent, 0),
maxSpentEvent: Math.abs(intValue(summaryRow.maxSpentEvent, 0)),
suspiciousUserCount,
suspiciousEventCount,
riskScore: Math.min(100, Math.trunc(
suspiciousUserCount * 10
+ suspiciousEventCount * 5
+ numberValue(summaryRow.earnedPoints, 0) / Math.max(highEarnedThreshold, 1),
)),
},
eventBreakdown,
suspiciousUsers: stripTotalCount(suspiciousUsers),
suspiciousEvents: stripTotalCount(suspiciousEvents),
suspiciousTasks: stripTotalCount(suspiciousTasks),
suspiciousExchanges: stripTotalCount(suspiciousExchanges),
dailyTrend,
},
};
}
export async function pointActivityTasksRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requirePointMarketingPermission(auth, 'read');

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');
}