forked from wangziqi/gongxue-base
feat: add points risk report
This commit is contained in:
@@ -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: '勋章管理' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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