merge: add lucky gift user portraits

This commit is contained in:
zhx 2026-07-16 18:16:54 +08:00
commit c0c997ba09
9 changed files with 2141 additions and 2 deletions

View File

@ -366,6 +366,30 @@
"x-permissions": ["lucky-gift:view"]
}
},
"/admin/ops-center/lucky-gifts/user-profiles": {
"get": {
"operationId": "listLuckyGiftUserProfiles",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:view",
"x-permissions": ["lucky-gift:view"]
}
},
"/admin/ops-center/lucky-gifts/user-profile": {
"get": {
"operationId": "getLuckyGiftUserProfile",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "lucky-gift:view",
"x-permissions": ["lucky-gift:view"]
}
},
"/admin/ops-center/lucky-gifts/summary": {
"get": {
"operationId": "getLuckyGiftDrawSummary",

View File

@ -2,8 +2,9 @@ import AppsOutlined from "@mui/icons-material/AppsOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CasinoOutlined from "@mui/icons-material/CasinoOutlined";
import InsightsOutlined from "@mui/icons-material/InsightsOutlined";
import PeopleAltOutlined from "@mui/icons-material/PeopleAltOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
@ -19,9 +20,15 @@ import { OpsShell } from "./components/OpsShell.jsx";
import { OverviewView } from "./components/OverviewView.jsx";
import { formatNumber } from "./format.js";
// trace
const UserProfilesView = lazy(() =>
import("./components/UserProfilesView.jsx").then((module) => ({ default: module.UserProfilesView })),
);
const views = [
{ icon: InsightsOutlined, key: "overview", label: "数据总览" },
{ icon: CardGiftcardOutlined, key: "configs", label: "幸运礼物配置" },
{ icon: PeopleAltOutlined, key: "profiles", label: "用户画像" },
{ icon: CasinoOutlined, key: "draws", label: "抽奖记录" },
{ icon: AppsOutlined, key: "apps", label: "应用列表" },
];
@ -48,6 +55,7 @@ export function OpsCenterApp({ can = denyPermission }) {
const [state, setState] = useState({ data: initialDashboard, error: "", loaded: false, loading: true });
const [dialog, setDialog] = useState(null);
const [poolDialog, setPoolDialog] = useState(null);
const [profileRefreshKey, setProfileRefreshKey] = useState(0);
const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const { showToast } = useToast();
@ -75,6 +83,14 @@ export function OpsCenterApp({ can = denyPermission }) {
loadDashboard();
}, [loadDashboard]);
const refreshCurrentView = useCallback(() => {
if (activeView === "profiles") {
setProfileRefreshKey((current) => current + 1);
return;
}
loadDashboard();
}, [activeView, loadDashboard]);
const { data } = state;
const appOptions = useMemo(
@ -201,7 +217,7 @@ export function OpsCenterApp({ can = denyPermission }) {
<Button
disabled={state.loading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={loadDashboard}
onClick={refreshCurrentView}
>
刷新
</Button>
@ -246,6 +262,16 @@ export function OpsCenterApp({ can = denyPermission }) {
{activeView === "draws" ? (
<DrawsView apps={data.apps} defaultAppCode={defaultAppCode} poolIDsByApp={poolIDsByApp} />
) : null}
{activeView === "profiles" ? (
<Suspense fallback={<DataState loading onRetry={refreshCurrentView} />}>
<UserProfilesView
apps={data.apps}
defaultAppCode={defaultAppCode}
key={profileRefreshKey}
poolIDsByApp={poolIDsByApp}
/>
</Suspense>
) : null}
{activeView === "apps" ? <AppsView apps={data.apps} /> : null}
</DataState>
{dialog ? (

View File

@ -77,6 +77,99 @@ export async function fetchLuckyGiftDraws({
};
}
export async function fetchLuckyGiftUserProfiles({
appCode,
cursor = "",
identityType = "all",
keyword = "",
page = 1,
pageSize = 20,
poolId,
sortBy = "last_draw_at",
sortDirection = "desc",
stage = "",
status = "",
} = {}) {
const operationPath = apiEndpointPath(API_OPERATIONS.listLuckyGiftUserProfiles);
const payload = await opsRequest(opsRelativePath(operationPath), {
query: {
app_code: String(appCode || "")
.trim()
.toLowerCase(),
cursor: String(cursor || "").trim(),
identity_type: normalizeIdentityType(identityType),
page,
page_size: pageSize,
pool_id: String(poolId || "").trim(),
sort_by: String(sortBy || "last_draw_at").trim(),
sort_direction: String(sortDirection || "desc").trim(),
stage: String(stage || "").trim(),
status: String(status || "").trim(),
user_keyword: String(keyword || "").trim(),
},
});
const snapshotAtMs = numberValue(payload.snapshot_at_ms ?? payload.snapshotAtMs);
return {
items: normalizeProfileItems(payload).map((item) => normalizeLuckyGiftUserProfile(item, snapshotAtMs)),
page: numberValue(payload.page) || page,
pageSize: numberValue(payload.page_size ?? payload.pageSize) || pageSize,
nextCursor: String(payload.next_cursor ?? payload.nextCursor ?? "").trim(),
hasMore: Boolean(payload.has_more ?? payload.hasMore),
snapshotAtMs,
total: numberValue(payload.total),
};
}
export async function fetchLuckyGiftUserProfile({
appCode,
identityType,
externalUserId,
jackpotCursor = "",
jackpotPage = 1,
jackpotPageSize = 20,
poolId,
userId,
} = {}) {
const normalizedIdentityType = normalizeIdentityType(identityType);
const operationPath = apiEndpointPath(API_OPERATIONS.getLuckyGiftUserProfile);
const payload = await opsRequest(opsRelativePath(operationPath), {
query: {
app_code: String(appCode || "")
.trim()
.toLowerCase(),
// 外部稳定用户键仅用于 owner 内部关联,前端查询和展示始终使用接入方原始 external_user_id。
external_user_id: normalizedIdentityType === "external" ? String(externalUserId || "").trim() : "",
identity_type: normalizedIdentityType,
internal_user_id: normalizedIdentityType === "internal" ? String(userId || "").trim() : "",
jackpot_page: jackpotPage,
jackpot_page_size: jackpotPageSize,
jackpot_cursor: String(jackpotCursor || "").trim(),
pool_id: String(poolId || "").trim(),
},
});
const profileSource = payload.profile || payload.item || payload;
return {
jackpotHits: normalizeItems(payload.jackpot_hits ?? payload.jackpotHits).map(normalizeLuckyGiftJackpotHit),
jackpotHitTotal: numberValue(payload.jackpot_hit_total ?? payload.jackpotHitTotal),
jackpotPage: numberValue(payload.jackpot_page ?? payload.jackpotPage) || jackpotPage,
jackpotPageSize: numberValue(payload.jackpot_page_size ?? payload.jackpotPageSize) || jackpotPageSize,
nextJackpotCursor: String(payload.next_jackpot_cursor ?? payload.nextJackpotCursor ?? "").trim(),
jackpotHasMore: Boolean(payload.jackpot_has_more ?? payload.jackpotHasMore),
multiplierDistribution: aggregateLuckyGiftMultiplierMetrics(
normalizeItems(
payload.multiplier_distribution ?? payload.multiplierDistribution ?? payload.multiplier_stats,
),
),
profile: normalizeLuckyGiftUserProfile(
profileSource,
payload.snapshot_at_ms ??
payload.snapshotAtMs ??
profileSource.snapshot_at_ms ??
profileSource.snapshotAtMs,
),
};
}
export async function saveLuckyGiftConfig(config = {}) {
const payload = luckyGiftConfigPayload(config);
return opsRequest("/lucky-gifts/config", {
@ -322,6 +415,349 @@ function normalizePoolAdjustmentResult(payload = {}) {
};
}
function normalizeLuckyGiftUserProfile(profile = {}, fallbackSnapshotAtMs = 0) {
const identity = profile.identity || {};
const internalUser = profile.internal_user || profile.internalUser || profile.user || identity.user || {};
const externalUserID = String(
profile.external_user_id ??
profile.externalUserId ??
identity.external_user_id ??
identity.externalUserId ??
"",
).trim();
const userID = String(
profile.internal_user_id ??
profile.internalUserId ??
profile.user_id ??
profile.userId ??
internalUser.user_id ??
internalUser.userId ??
identity.user_id ??
identity.userId ??
"",
).trim();
const identityType = externalUserID
? "external"
: normalizeIdentityType(
profile.identity_type ?? profile.identityType ?? profile.user_type ?? profile.userType ?? "internal",
);
const windows = profile.windows || profile.window_metrics || profile.windowMetrics || {};
const snapshotAtMs = numberValue(profile.snapshot_at_ms ?? profile.snapshotAtMs ?? fallbackSnapshotAtMs);
const profileUpdatedAtMs = numberValue(
profile.aggregated_at_ms ??
profile.aggregatedAtMs ??
profile.profile_updated_at_ms ??
profile.profileUpdatedAtMs ??
profile.updated_at_ms ??
profile.updatedAtMs,
);
const explicitLag = profile.data_lag_ms ?? profile.dataLagMs;
const jackpotCounts = profile.jackpot_counts || profile.jackpotCounts || {};
// 画像接口是独立读模型,字段名可能经过 protobuf JSON 或 admin envelope 转成 camelCase
// 在边界一次性归一化,组件只消费稳定模型,且明确丢弃 owner 内部的 strategy_user_id / user_key 哈希。
return {
appCode: String(profile.app_code ?? profile.appCode ?? "")
.trim()
.toLowerCase(),
cumulativeSpendJackpotCount: numberValue(
profile.cumulative_spend_jackpot_count ??
profile.cumulativeSpendJackpotCount ??
jackpotCounts.cumulative_spend ??
jackpotCounts.cumulativeSpend,
),
dataLagMs:
explicitLag !== undefined
? Math.max(0, numberValue(explicitLag))
: snapshotAtMs && profileUpdatedAtMs
? Math.max(0, snapshotAtMs - profileUpdatedAtMs)
: 0,
downweightActive: Boolean(
profile.downweight_active ??
profile.downweightActive ??
profile.user_24h_rtp_downweight_active ??
profile.user24hRtpDownweightActive,
),
equivalentDraws: numberValue(profile.equivalent_draws ?? profile.equivalentDraws),
externalUserId: identityType === "external" ? externalUserID : "",
failedDrawCount: numberValue(profile.failed_draw_count ?? profile.failedDrawCount),
firstDrawAtMs: numberValue(profile.first_draw_at_ms ?? profile.firstDrawAtMs),
grantedDrawCount: numberValue(profile.granted_draw_count ?? profile.grantedDrawCount),
guaranteeDrawsRemaining: numberValue(
profile.guarantee_draws_remaining ?? profile.guaranteeDrawsRemaining ?? profile.loss_protection_remaining,
),
guaranteeThreshold: numberValue(
profile.guarantee_threshold ?? profile.guaranteeThreshold ?? profile.loss_streak_guarantee,
),
highMultiplierOrdinaryWinCount: numberValue(
profile.high_multiplier_ordinary_win_count ?? profile.highMultiplierOrdinaryWinCount,
),
identityType,
currentRtpPpm: numberValue(profile.current_rtp_ppm ?? profile.currentRtpPpm),
hasCurrentRtp: Boolean(profile.has_current_rtp ?? profile.hasCurrentRtp),
key: `${profile.app_code ?? profile.appCode ?? ""}:${profile.pool_id ?? profile.poolId ?? ""}:${identityType}:${
identityType === "external" ? externalUserID : userID
}`,
lastDrawAtMs: numberValue(profile.last_draw_at_ms ?? profile.lastDrawAtMs),
latestMultiplierPpm: numberValue(profile.latest_multiplier_ppm ?? profile.latestMultiplierPpm),
latestRuleVersion: numberValue(
profile.latest_rule_version ?? profile.latestRuleVersion ?? profile.rule_version,
),
latestStrategyVersion: String(
profile.latest_strategy_version ?? profile.latestStrategyVersion ?? profile.strategy_version ?? "",
).trim(),
latestWaterZone: String(
profile.latest_water_zone ?? profile.latestWaterZone ?? profile.water_zone ?? "",
).trim(),
lossStreak: numberValue(profile.loss_streak ?? profile.lossStreak),
maxMultiplierPpm: numberValue(profile.max_multiplier_ppm ?? profile.maxMultiplierPpm),
maxRewardCoins: numberValue(profile.max_reward_coins ?? profile.maxRewardCoins),
ordinaryWinCount: numberValue(profile.ordinary_win_count ?? profile.ordinaryWinCount),
pendingCumulativeSpendTokens: numberValue(
profile.pending_spend_jackpot_tokens ??
profile.pendingSpendJackpotTokens ??
profile.pending_cumulative_spend_tokens ??
profile.pendingCumulativeSpendTokens,
),
pendingRtpCompensationTokens: numberValue(
profile.pending_rtp_compensation_tokens ?? profile.pendingRtpCompensationTokens,
),
poolId: String(profile.pool_id ?? profile.poolId ?? "").trim(),
pendingDrawCount: numberValue(profile.pending_draw_count ?? profile.pendingDrawCount),
paidDraws: numberValue(profile.paid_draws ?? profile.paidDraws),
profileUpdatedAtMs,
rtpCompensationJackpotCount: numberValue(
profile.rtp_compensation_jackpot_count ??
profile.rtpCompensationJackpotCount ??
jackpotCounts.rtp_compensation ??
jackpotCounts.rtpCompensation,
),
snapshotAtMs,
stage: String(profile.stage ?? profile.current_stage ?? profile.currentStage ?? "").trim(),
baseRewardCoins: numberValue(profile.base_reward_coins ?? profile.baseRewardCoins),
guaranteeHitCount: numberValue(profile.guarantee_hit_count ?? profile.guaranteeHitCount),
user: {
avatar: String(internalUser.avatar ?? internalUser.avatar_url ?? internalUser.avatarUrl ?? "").trim(),
defaultDisplayUserId: String(
internalUser.default_display_user_id ?? internalUser.defaultDisplayUserId ?? "",
).trim(),
displayUserId: String(internalUser.display_user_id ?? internalUser.displayUserId ?? "").trim(),
prettyDisplayUserId: String(
internalUser.pretty_display_user_id ?? internalUser.prettyDisplayUserId ?? "",
).trim(),
prettyId: String(internalUser.pretty_id ?? internalUser.prettyId ?? "").trim(),
status: String(internalUser.status || "").trim(),
userId: identityType === "internal" ? userID : "",
username: String(internalUser.username ?? internalUser.name ?? "").trim(),
},
user24hOrdinaryWinFactorPpm: numberValue(
profile.downweight_factor_ppm ??
profile.downweightFactorPpm ??
profile.user_24h_ordinary_win_factor_ppm ??
profile.user24hOrdinaryWinFactorPpm,
),
user24hRtpThresholdPpm: numberValue(profile.user_24h_rtp_threshold_ppm ?? profile.user24hRtpThresholdPpm),
windows: {
fortyEightHours: normalizeLuckyGiftWindowMetric(
windows.forty_eight_hours ??
windows.fortyEightHours ??
windows.last_48h ??
windows.h48 ??
profile.forty_eight_hours ??
profile.fortyEightHours,
),
lifetime: normalizeLuckyGiftWindowMetric(
windows.lifetime ??
windows.all ??
windows.total ??
profile.lifetime ??
profile.all_time ??
profile.allTime,
),
oneHour: normalizeLuckyGiftWindowMetric(
windows.one_hour ??
windows.oneHour ??
windows.last_1h ??
windows.h1 ??
profile.one_hour ??
profile.oneHour,
),
twentyFourHours: normalizeLuckyGiftWindowMetric(
windows.twenty_four_hours ??
windows.twentyFourHours ??
windows.last_24h ??
windows.h24 ??
profile.twenty_four_hours ??
profile.twentyFourHours,
),
},
zeroDrawCount: numberValue(profile.zero_draw_count ?? profile.zeroDrawCount),
};
}
function normalizeLuckyGiftWindowMetric(metric = {}) {
const wagerCoins = numberValue(
metric.wager_coins ?? metric.wagerCoins ?? metric.spent_coins ?? metric.spentCoins ?? metric.consumed_coins,
);
const payoutCoins = numberValue(
metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins ?? metric.returned_coins,
);
return {
drawCount: numberValue(metric.draw_count ?? metric.drawCount ?? metric.draws ?? metric.total_draws),
hasRtp: Boolean(metric.has_rtp ?? metric.hasRtp ?? wagerCoins > 0),
// 净输赢固定采用用户视角“返奖 - 消耗”,不直接接受含义可能相反的平台 profit 字段。
netCoins: payoutCoins - wagerCoins,
payoutCoins,
rtpPpm: numberValue(metric.rtp_ppm ?? metric.rtpPpm ?? metric.actual_rtp_ppm ?? metric.actualRtpPpm),
wagerCoins,
};
}
function normalizeLuckyGiftMultiplierMetric(metric = {}) {
const hitType = String(metric.hit_type ?? metric.hitType ?? "")
.trim()
.toLowerCase();
const drawCount = numberValue(metric.draw_count ?? metric.drawCount);
const ordinaryHitCount =
numberValue(
metric.ordinary_hit_count ?? metric.ordinaryHitCount ?? metric.ordinary_count ?? metric.ordinaryCount,
) || (hitType === "ordinary" ? drawCount : 0);
const rtpCompensationJackpotCount =
numberValue(metric.rtp_compensation_jackpot_count ?? metric.rtpCompensationJackpotCount) ||
(hitType === "rtp_compensation" ? drawCount : 0);
const cumulativeSpendJackpotCount =
numberValue(metric.cumulative_spend_jackpot_count ?? metric.cumulativeSpendJackpotCount) ||
(["cumulative_spend", "spend_milestone"].includes(hitType) ? drawCount : 0);
const zeroDrawCount =
numberValue(metric.zero_draw_count ?? metric.zeroDrawCount) || (hitType === "zero" ? drawCount : 0);
return {
cumulativeSpendJackpotCount,
hitCount:
numberValue(metric.hit_count ?? metric.hitCount) ||
ordinaryHitCount + rtpCompensationJackpotCount + cumulativeSpendJackpotCount + zeroDrawCount,
multiplierPpm: numberValue(metric.multiplier_ppm ?? metric.multiplierPpm),
ordinaryHitCount,
payoutCoins: numberValue(
metric.payout_coins ?? metric.payoutCoins ?? metric.reward_coins ?? metric.rewardCoins,
),
rtpCompensationJackpotCount,
wagerCoins: numberValue(metric.wager_coins ?? metric.wagerCoins),
zeroDrawCount,
};
}
function aggregateLuckyGiftMultiplierMetrics(metrics) {
const byMultiplier = new Map();
metrics.map(normalizeLuckyGiftMultiplierMetric).forEach((metric) => {
const current = byMultiplier.get(metric.multiplierPpm) || {
cumulativeSpendJackpotCount: 0,
hitCount: 0,
multiplierPpm: metric.multiplierPpm,
ordinaryHitCount: 0,
payoutCoins: 0,
rtpCompensationJackpotCount: 0,
wagerCoins: 0,
zeroDrawCount: 0,
};
current.cumulativeSpendJackpotCount += metric.cumulativeSpendJackpotCount;
current.hitCount += metric.hitCount;
current.ordinaryHitCount += metric.ordinaryHitCount;
current.payoutCoins += metric.payoutCoins;
current.rtpCompensationJackpotCount += metric.rtpCompensationJackpotCount;
current.wagerCoins += metric.wagerCoins;
current.zeroDrawCount += metric.zeroDrawCount;
byMultiplier.set(metric.multiplierPpm, current);
});
return [...byMultiplier.values()];
}
function normalizeLuckyGiftJackpotHit(hit = {}) {
const mechanism = String(
hit.mechanism ?? hit.jackpot_mechanism ?? hit.jackpotMechanism ?? hit.jackpot_type ?? hit.jackpotType ?? "",
).trim();
return {
conditions: normalizeLuckyGiftJackpotConditions(
hit.conditions ??
hit.conditions_json ??
hit.conditionsJson ??
hit.condition_snapshot_json ??
hit.conditionSnapshotJson,
),
drawId: String(hit.draw_id ?? hit.drawId ?? "").trim(),
eventKey: String(hit.event_key ?? hit.eventKey ?? "").trim(),
mechanism,
multiplierPpm: numberValue(hit.multiplier_ppm ?? hit.multiplierPpm),
occurredAtMs: numberValue(hit.occurred_at_ms ?? hit.occurredAtMs ?? hit.created_at_ms ?? hit.createdAtMs),
payoutCoins: numberValue(hit.payout_coins ?? hit.payoutCoins ?? hit.reward_coins ?? hit.rewardCoins),
reason: String(
hit.reason_summary ??
hit.reasonSummary ??
hit.reason ??
hit.final_reason ??
hit.finalReason ??
hit.decision_reason ??
hit.decisionReason ??
"",
).trim(),
reasonCode: String(hit.reason_code ?? hit.reasonCode ?? "").trim(),
requestId: String(hit.request_id ?? hit.requestId ?? "").trim(),
ruleVersion: numberValue(hit.rule_version ?? hit.ruleVersion),
stage: String(hit.stage ?? "").trim(),
tierId: String(hit.tier_id ?? hit.tierId ?? "").trim(),
wagerCoins: numberValue(hit.wager_coins ?? hit.wagerCoins),
};
}
function normalizeLuckyGiftJackpotConditions(value) {
const parsed = parseJSONValue(value);
if (!Array.isArray(parsed)) {
return parsed;
}
return parsed.map((condition) => ({
actual: String(condition.actual ?? condition.actual_value ?? condition.actualValue ?? "").trim(),
denominator: numberValue(condition.denominator),
factorPpm: numberValue(condition.factor_ppm ?? condition.factorPpm),
label: String(condition.label || "").trim(),
limitPpm: numberValue(condition.limit_ppm ?? condition.limitPpm),
name: String(condition.name || "").trim(),
numerator: numberValue(condition.numerator),
operator: String(condition.operator || "").trim(),
passed: Boolean(condition.passed),
ratioPpm: numberValue(condition.ratio_ppm ?? condition.ratioPpm),
reason: String(condition.reason || "").trim(),
threshold: String(condition.threshold ?? condition.threshold_value ?? condition.thresholdValue ?? "").trim(),
}));
}
function normalizeIdentityType(value) {
const normalized = String(value || "all")
.trim()
.toLowerCase();
return normalized === "internal" || normalized === "external" ? normalized : "all";
}
function parseJSONValue(value) {
if (!value) {
return {};
}
if (typeof value === "object") {
return value;
}
try {
return JSON.parse(value);
} catch {
return { detail: String(value) };
}
}
function normalizeProfileItems(value) {
const items = normalizeItems(value);
if (items.length || !value || typeof value !== "object") {
return items;
}
return Array.isArray(value.profiles) ? value.profiles : [];
}
function opsRelativePath(operationPath) {
const prefix = "/v1/admin/ops-center";
if (!operationPath.startsWith(prefix)) {

View File

@ -0,0 +1,384 @@
import Skeleton from "@mui/material/Skeleton";
import { Button } from "@/shared/ui/Button.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { formatMultiplierFromPPM, formatNumber, formatPercentFromPPM } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function UserProfileDetail({ detailState, onLoadMore, onRetry, profile }) {
return (
<div className="admin-row ops-user-profile-detail-row">
<div className="ops-user-profile-detail-cell">
{!detailState || detailState.loading ? <DetailSkeleton /> : null}
{detailState?.error ? (
<div className="ops-user-profile-detail-error" role="alert">
<span>{detailState.error}</span>
<Button size="small" onClick={onRetry}>
重试
</Button>
</div>
) : null}
{detailState?.data && !detailState.loading ? (
<ProfileDetailContent
data={detailState.data}
fallbackProfile={profile}
loadingMore={detailState.loadingMore}
onLoadMore={onLoadMore}
/>
) : null}
</div>
</div>
);
}
function ProfileDetailContent({ data, fallbackProfile, loadingMore, onLoadMore }) {
const profile = data.profile?.key ? data.profile : fallbackProfile;
const distribution = [...(data.multiplierDistribution || [])].sort(
(left, right) => right.multiplierPpm - left.multiplierPpm,
);
const jackpotHits = data.jackpotHits || [];
return (
<div className="ops-user-profile-detail">
<section className="ops-user-profile-detail__summary" aria-label="用户控制状态详情">
<DetailMetric label="最近开奖阶段" value={stageLabel(profile.stage)} />
<DetailMetric label="连续未中奖" value={`${formatNumber(profile.lossStreak)}`} />
<DetailMetric label="等价抽数" value={formatNumber(profile.equivalentDraws)} />
<DetailMetric
label="保底状态"
value={
profile.guaranteeDrawsRemaining > 0
? `还差 ${formatNumber(profile.guaranteeDrawsRemaining)}`
: "保护剩余 0 抽"
}
/>
<DetailMetric
label="24 小时降权"
value={
profile.downweightActive
? `已触发,普通中奖保留 ${formatPercentFromPPM(profile.user24hOrdinaryWinFactorPpm)}`
: "未触发"
}
/>
<DetailMetric label="待兑消费大奖" value={`${formatNumber(profile.pendingCumulativeSpendTokens)}`} />
<DetailMetric label="累计有效抽数" value={`${formatNumber(profile.paidDraws)}`} />
<DetailMetric label="基础返奖" value={`${formatNumber(profile.baseRewardCoins)} 金币`} />
<DetailMetric label="保底命中" value={`${formatNumber(profile.guaranteeHitCount)}`} />
<DetailMetric
label="历史最高单次"
value={`${formatMultiplierFromPPM(profile.maxMultiplierPpm)} / ${formatNumber(profile.maxRewardCoins)} 金币`}
/>
<DetailMetric label="首次抽奖" value={<TimeText value={profile.firstDrawAtMs} />} />
<DetailMetric
label="处理结果"
value={`已发 ${formatNumber(profile.grantedDrawCount)} / 待处理 ${formatNumber(
profile.pendingDrawCount,
)} / 失败 ${formatNumber(profile.failedDrawCount)}`}
/>
</section>
<section className="ops-user-profile-detail__section">
<header>
<div>
<h3>倍率命中分布</h3>
<p>普通随机和两条大奖通道分列倍率相同时也不会混为一次命中</p>
</div>
<span>{distribution.length} 个倍率</span>
</header>
{distribution.length ? (
<div className="ops-profile-multiplier-table">
<div className="ops-profile-multiplier-row is-head">
<span>倍率</span>
<span>未中奖</span>
<span>普通命中</span>
<span>RTP 补偿大奖</span>
<span>累计消费大奖</span>
<span>合计命中</span>
<span>返奖金币</span>
</div>
{distribution.map((metric) => (
<div className="ops-profile-multiplier-row" key={metric.multiplierPpm}>
<strong>{formatMultiplierFromPPM(metric.multiplierPpm)}</strong>
<span>{formatNumber(metric.zeroDrawCount)}</span>
<span>{formatNumber(metric.ordinaryHitCount)}</span>
<span>{formatNumber(metric.rtpCompensationJackpotCount)}</span>
<span>{formatNumber(metric.cumulativeSpendJackpotCount)}</span>
<span>{formatNumber(metric.hitCount)}</span>
<span>{formatNumber(metric.payoutCoins)}</span>
</div>
))}
</div>
) : (
<div className="ops-user-profile-detail__empty">当前没有倍率命中数据</div>
)}
</section>
<section className="ops-user-profile-detail__section">
<header>
<div>
<h3>大奖命中依据</h3>
<p>逐次展示走哪条大奖通道命中时的关键门槛和实际判断结果</p>
</div>
<span>
展示 {jackpotHits.length} / {formatNumber(data.jackpotHitTotal || jackpotHits.length)}
</span>
</header>
{jackpotHits.length ? (
<>
<div className="ops-profile-jackpot-list">
{jackpotHits.map((hit, index) => (
<JackpotHit
hit={hit}
key={hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${index}`}
/>
))}
</div>
{data.jackpotHasMore && data.nextJackpotCursor ? (
<div className="ops-profile-jackpot-more">
<Button disabled={loadingMore} onClick={onLoadMore} size="small">
{loadingMore ? "加载中…" : "加载更早的大奖记录"}
</Button>
</div>
) : null}
</>
) : (
<div className="ops-user-profile-detail__empty">该用户当前没有大奖命中记录</div>
)}
</section>
</div>
);
}
function DetailMetric({ label, value }) {
return (
<dl>
<dt>{label}</dt>
<dd>{value || "-"}</dd>
</dl>
);
}
function JackpotHit({ hit }) {
const conditions = flattenConditions(hit.conditions);
const mechanism = mechanismLabel(hit.mechanism);
return (
<article className="ops-profile-jackpot-hit">
<div className="ops-profile-jackpot-hit__head">
<div>
<OpsStatusBadge label={mechanism} tone={mechanismTone(hit.mechanism)} />
<strong>{formatMultiplierFromPPM(hit.multiplierPpm)}</strong>
<span>返奖 {formatNumber(hit.payoutCoins)} 金币</span>
</div>
<TimeText value={hit.occurredAtMs} />
</div>
<div className="ops-profile-jackpot-hit__reason">
<strong>为什么命中</strong>
<span>{reasonLabel(hit.reason, hit.reasonCode, hit.mechanism)}</span>
</div>
{conditions.length ? (
<dl className="ops-profile-jackpot-conditions">
{conditions.map((condition) => (
<div key={condition.key}>
<dt>{condition.value?.label || conditionLabel(condition.key)}</dt>
<dd>{conditionValue(condition.key, condition.value)}</dd>
</div>
))}
</dl>
) : (
<div className="ops-profile-jackpot-hit__no-conditions">该历史记录没有保存更多判断快照</div>
)}
<footer>
<span>阶段{stageLabel(hit.stage)}</span>
<span>规则v{hit.ruleVersion || "-"}</span>
<span>消耗{formatNumber(hit.wagerCoins)} 金币</span>
{hit.tierId ? <span>奖档{hit.tierId}</span> : null}
{hit.drawId ? <span>抽奖单{hit.drawId}</span> : null}
{hit.requestId ? <span>请求号{hit.requestId}</span> : null}
</footer>
</article>
);
}
function DetailSkeleton() {
return (
<div className="ops-user-profile-detail-skeleton" aria-hidden="true">
<div>
{Array.from({ length: 6 }, (_, index) => (
<Skeleton animation="wave" height={42} key={index} variant="rounded" />
))}
</div>
<Skeleton animation="wave" height={112} variant="rounded" />
<Skeleton animation="wave" height={156} variant="rounded" />
</div>
);
}
function flattenConditions(value, prefix = "") {
if (Array.isArray(value)) {
return value
.filter((condition) => condition && !isInternalIdentityKey(condition.name))
.map((condition, index) => ({
key: condition.name || `condition_${index + 1}`,
value: condition,
}));
}
if (!value || typeof value !== "object") {
return [];
}
return Object.entries(value).flatMap(([key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
// stable hashstrategy user key owner
if (isInternalIdentityKey(path)) {
return [];
}
if (child && typeof child === "object" && !Array.isArray(child)) {
return flattenConditions(child, path);
}
return [{ key: path, value: Array.isArray(child) ? child.join("、") : child }];
});
}
function isInternalIdentityKey(key) {
const normalized = String(key).toLowerCase();
return ["strategy_user_id", "user_key", "stable_user", "hashed_user", "user_hash"].some((token) =>
normalized.includes(token),
);
}
function mechanismLabel(mechanism) {
const normalized = String(mechanism || "").toLowerCase();
if (["rtp_compensation", "mechanism_1", "jackpot_mechanism_1", "compensation"].includes(normalized)) {
return "RTP 补偿大奖";
}
if (["cumulative_spend", "spend_milestone", "mechanism_2", "jackpot_mechanism_2", "spend"].includes(normalized)) {
return "累计消费大奖";
}
return mechanism || "大奖";
}
function mechanismTone(mechanism) {
return mechanismLabel(mechanism) === "累计消费大奖" ? "warning" : "succeeded";
}
function reasonLabel(reason, reasonCode, mechanism) {
const normalized = String(reason || "").trim();
const known = {
cumulative_spend_token_redeemed: "累计消费达到门槛后生成大奖机会,本抽满足支付条件并完成兑付。",
jackpot_token_redeemed: "此前生成的大奖机会在本抽满足支付条件并完成兑付。",
milestone_token_jackpot: "用户累计消费已获得一次大奖机会,且奖池余额足够支付,因此触发累计消费大奖。",
rtp_compensation_eligible: "用户和全局 RTP 等补偿条件均满足,本抽进入 RTP 补偿大奖通道并命中。",
rtp_compensation_jackpot: "用户与奖池均满足补偿条件,且奖池余额足够支付,因此触发 RTP 补偿大奖。",
rtp_compensation_token_redeemed: "RTP 补偿机会已生成,本抽满足奖池支付条件并完成兑付。",
};
const normalizedCode = String(reasonCode || "")
.trim()
.toLowerCase();
if (known[normalizedCode] || known[normalized.toLowerCase()] || normalized) {
return known[normalizedCode] || known[normalized.toLowerCase()] || normalized;
}
return mechanismLabel(mechanism) === "累计消费大奖"
? "累计消费达到规则门槛,生成的大奖机会在本抽完成兑付。"
: "用户和全局 RTP、奖池水位等门槛均允许RTP 补偿机会在本抽完成兑付。";
}
function conditionLabel(key) {
const normalized = String(key).split(".").pop()?.toLowerCase() || key;
const labels = {
pool_balance_coins: "命中前奖池余额",
pool_payable: "奖池能否支付",
global_rtp_ppm: "命中前全局 RTP",
global_rtp_max_ppm: "全局 RTP 上限",
user_24h_rtp_ppm: "命中前用户 24 小时 RTP",
user_48h_rtp_ppm: "命中前用户 48 小时 RTP",
user_48h_rtp_max_ppm: "用户 48 小时 RTP 上限",
user_round_rtp_ppm: "命中前用户本轮 RTP",
user_round_rtp_max_ppm: "用户本轮 RTP 上限",
daily_hit_count: "当日已中大奖次数",
daily_hit_limit: "单日大奖次数上限",
cumulative_spend_coins: "累计消费金币",
cumulative_spend_threshold_coins: "累计消费门槛",
pending_token_count: "命中前待兑大奖机会",
multiplier_ppm: "命中倍率",
stage: "命中阶段",
eligible: "是否满足条件",
allowed: "是否允许发放",
};
return labels[normalized] || humanizeConditionKey(normalized);
}
function conditionValue(key, value) {
if (value && typeof value === "object" && !Array.isArray(value)) {
const parts = [];
const hasFriendlyValues = Boolean(value.actual || value.threshold);
if (hasFriendlyValues) {
parts.push(
[value.actual ? `实际 ${value.actual}` : "", operatorLabel(value.operator), value.threshold || ""]
.filter(Boolean)
.join(" "),
);
}
if (!hasFriendlyValues && value.denominator > 0) {
parts.push(`${formatNumber(value.numerator)} / ${formatNumber(value.denominator)}`);
}
if (!hasFriendlyValues && (value.ratioPpm || value.limitPpm)) {
parts.push(`实际 ${formatPercentFromPPM(value.ratioPpm)} / 门槛 ${formatPercentFromPPM(value.limitPpm)}`);
}
if (value.factorPpm) {
parts.push(`权重 ${formatPercentFromPPM(value.factorPpm)}`);
}
parts.push(value.passed ? "通过" : "未通过");
if (value.reason) {
parts.push(value.reason);
}
return parts.join(" · ");
}
const normalized = String(key).toLowerCase();
if (typeof value === "boolean") {
return value ? "是" : "否";
}
if (value === null || value === undefined || value === "") {
return "-";
}
if (normalized.endsWith("_ppm") || normalized.includes("rtp_ppm")) {
return formatPercentFromPPM(value);
}
if (normalized.endsWith("_coins") || normalized.includes("count") || normalized.includes("limit")) {
return formatNumber(value);
}
return String(value);
}
function operatorLabel(value) {
const labels = {
eq: "等于",
gt: "高于",
gte: "不低于",
lt: "低于",
lte: "不高于",
};
const normalized = String(value || "")
.trim()
.toLowerCase();
return labels[normalized] || value || "";
}
function humanizeConditionKey(key) {
return String(key)
.replaceAll("_", " ")
.replace(/\bRtp\b/gi, "RTP")
.replace(/\bPpm\b/gi, "百分比")
.replace(/\bCoins\b/gi, "金币");
}
function stageLabel(stage) {
const normalized = String(stage || "").toLowerCase();
if (["novice", "newbie", "new"].includes(normalized)) {
return "新手阶段";
}
if (["normal", "ordinary"].includes(normalized)) {
return "普通阶段";
}
if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
return "高价阶段";
}
return stage || "未记录";
}

View File

@ -0,0 +1,268 @@
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { formatNumber, formatPercentFromPPM } from "../format.js";
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
export function buildUserProfileColumns(expandedKey) {
return [
{
fixed: "left",
key: "identity",
label: "用户身份",
render: (profile) => <UserIdentityCell expanded={expandedKey === profile.key} profile={profile} />,
width: "270px",
},
windowColumn("one_hour", "近 1 小时", (profile) => profile.windows.oneHour),
windowColumn("twenty_four_hours", "近 24 小时", (profile) => profile.windows.twentyFourHours),
windowColumn("forty_eight_hours", "近 48 小时", (profile) => profile.windows.fortyEightHours),
windowColumn("lifetime", "累计", (profile) => profile.windows.lifetime),
{
key: "control_state",
label: "最近开奖阶段 / 连续未中",
render: (profile) => <ControlStateCell profile={profile} />,
width: "190px",
},
{
key: "downweight",
label: "24 小时 RTP 降权",
render: (profile) => <DownweightCell profile={profile} />,
width: "190px",
},
{
key: "ordinary_wins",
label: "普通中奖",
render: (profile) => (
<div className="ops-profile-counts">
<strong>{formatNumber(profile.ordinaryWinCount)} </strong>
<span>其中高倍率 {formatNumber(profile.highMultiplierOrdinaryWinCount)} </span>
<span>未中奖 {formatNumber(profile.zeroDrawCount)} </span>
<small>普通奖与大奖通道独立统计</small>
</div>
),
width: "170px",
},
{
key: "jackpots",
label: "两类大奖",
render: (profile) => (
<div className="ops-profile-counts">
<strong>
{formatNumber(profile.rtpCompensationJackpotCount + profile.cumulativeSpendJackpotCount)}
</strong>
<span>RTP 补偿 {formatNumber(profile.rtpCompensationJackpotCount)}</span>
<span>累计消费 {formatNumber(profile.cumulativeSpendJackpotCount)}</span>
<small>点击行查看每次命中依据</small>
</div>
),
width: "180px",
},
{
key: "last_draw_at_ms",
label: "最近抽奖",
render: (profile) => (
<div className="ops-profile-time">
<TimeText value={profile.lastDrawAtMs} />
<small>
v{profile.latestRuleVersion || "-"} · {profile.latestStrategyVersion || "-"}
</small>
</div>
),
width: "180px",
},
{
key: "data_lag_ms",
label: "画像时效",
render: (profile) => (
<div className="ops-profile-time">
<strong>{formatDuration(profile.dataLagMs)}</strong>
<small>
{profile.failedDrawCount > 0
? `失败 ${formatNumber(profile.failedDrawCount)}`
: `已发 ${formatNumber(profile.grantedDrawCount)} · 待处理 ${formatNumber(profile.pendingDrawCount)}`}
</small>
</div>
),
width: "150px",
},
];
}
function windowColumn(key, label, selectMetric) {
return {
key,
label,
render: (profile) => <WindowMetricCell metric={selectMetric(profile)} />,
width: "205px",
};
}
function WindowMetricCell({ metric }) {
return (
<div className="ops-profile-window">
<div className="ops-profile-window__head">
<strong>{metric.hasRtp ? formatObservedRTP(metric.rtpPpm) : "-"}</strong>
<small>{formatNumber(metric.drawCount)} </small>
</div>
<dl>
<div>
<dt>消耗</dt>
<dd>{formatNumber(metric.wagerCoins)}</dd>
</div>
<div>
<dt>返奖</dt>
<dd>{formatNumber(metric.payoutCoins)}</dd>
</div>
<div className={metric.netCoins > 0 ? "is-profit" : metric.netCoins < 0 ? "is-loss" : ""}>
<dt>用户净输赢</dt>
<dd>{formatSignedNumber(metric.netCoins)}</dd>
</div>
</dl>
</div>
);
}
function UserIdentityCell({ expanded, profile }) {
return (
<div className="ops-profile-identity">
<span className="ops-profile-expand" aria-hidden="true">
{expanded ? (
<KeyboardArrowDownOutlined fontSize="small" />
) : (
<KeyboardArrowRightOutlined fontSize="small" />
)}
</span>
{profile.identityType === "external" ? (
<div className="ops-profile-external">
<span className="ops-profile-external__icon">
<PublicOutlined fontSize="small" />
</span>
<span>
<strong>{profile.externalUserId || "-"}</strong>
<small>external_user_id</small>
</span>
</div>
) : (
<div className="ops-profile-internal">
<AdminUserIdentity rows={internalIdentityRows(profile.user)} user={profile.user} />
<small>内部用户{profile.user.status ? ` · ${userStatusLabel(profile.user.status)}` : ""}</small>
</div>
)}
</div>
);
}
function ControlStateCell({ profile }) {
const remaining = profile.guaranteeDrawsRemaining;
return (
<div className="ops-profile-control">
<div>
<OpsStatusBadge label={stageLabel(profile.stage)} tone={stageTone(profile.stage)} />
<span>等价抽数 {formatNumber(profile.equivalentDraws)}</span>
</div>
<strong>连续未中 {formatNumber(profile.lossStreak)} </strong>
<small>{remaining > 0 ? `距连续未中保护 ${formatNumber(remaining)}` : "连续未中保护剩余 0 抽"}</small>
{profile.pendingCumulativeSpendTokens > 0 ? (
<small className="is-warning">
待兑累计消费大奖 {formatNumber(profile.pendingCumulativeSpendTokens)}
</small>
) : null}
</div>
);
}
function DownweightCell({ profile }) {
const threshold = profile.user24hRtpThresholdPpm;
const factor = profile.user24hOrdinaryWinFactorPpm;
return (
<div className="ops-profile-downweight">
{profile.downweightActive ? (
<OpsStatusBadge label={`已降权 · 保留 ${formatPercentFromPPM(factor)}`} tone="warning" />
) : (
<OpsStatusBadge label="未触发" tone="succeeded" />
)}
<span>
当前{" "}
{profile.hasCurrentRtp || profile.windows.twentyFourHours.hasRtp
? formatObservedRTP(
profile.hasCurrentRtp ? profile.currentRtpPpm : profile.windows.twentyFourHours.rtpPpm,
)
: "暂无消耗"}
</span>
<small>
{threshold > 0
? `严格高于 ${formatPercentFromPPM(threshold)} 时保留 ${formatPercentFromPPM(factor)}`
: profile.downweightActive
? `普通中奖机会当前按 ${formatPercentFromPPM(factor)} 发放`
: "当前普通中奖机会未被降权"}
</small>
</div>
);
}
function internalIdentityRows(user) {
const shortID = user.defaultDisplayUserId || user.displayUserId;
return [shortID ? `短号 ${shortID}` : "", user.userId ? `长号 ${user.userId}` : ""].filter(Boolean);
}
function userStatusLabel(status) {
const normalized = String(status || "").toLowerCase();
const labels = {
active: "正常",
banned: "已封禁",
disabled: "已停用",
inactive: "未激活",
};
return labels[normalized] || status;
}
function stageLabel(stage) {
const normalized = String(stage || "").toLowerCase();
if (normalized === "novice" || normalized === "newbie" || normalized === "new") {
return "新手阶段";
}
if (normalized === "normal" || normalized === "ordinary") {
return "普通阶段";
}
if (["advanced", "high", "high_value", "premium"].includes(normalized)) {
return "高价阶段";
}
return stage || "阶段未记录";
}
function stageTone(stage) {
return stage ? "succeeded" : "";
}
function formatSignedNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return "-";
}
return `${number > 0 ? "+" : ""}${formatNumber(number)}`;
}
// 0 ppm 0% 沿
function formatObservedRTP(value) {
const number = Number(value);
if (!Number.isFinite(number) || number < 0) {
return "-";
}
return `${(number / 10_000).toFixed(2)}%`;
}
function formatDuration(value) {
const milliseconds = Math.max(0, Number(value) || 0);
if (milliseconds < 1_000) {
return "实时";
}
if (milliseconds < 60_000) {
return `${Math.floor(milliseconds / 1_000)}`;
}
if (milliseconds < 3_600_000) {
return `${Math.floor(milliseconds / 60_000)} 分钟`;
}
return `${(milliseconds / 3_600_000).toFixed(1)} 小时`;
}

View File

@ -0,0 +1,456 @@
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
AdminFilterResetButton,
AdminFilterSelect,
AdminListToolbar,
AdminSearchBox,
} from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { fetchLuckyGiftUserProfile, fetchLuckyGiftUserProfiles } from "../api.js";
import { UserProfileDetail } from "./UserProfileDetail.jsx";
import { buildUserProfileColumns } from "./UserProfileTable.jsx";
const PAGE_SIZE = 20;
const IDENTITY_OPTIONS = [
["all", "全部用户"],
["internal", "内部用户"],
["external", "外部接入用户"],
];
const STAGE_OPTIONS = [
["", "全部最近开奖阶段"],
["novice", "最近为新手阶段"],
["normal", "最近为普通阶段"],
["advanced", "最近为高阶阶段"],
];
const STATUS_OPTIONS = [
["", "全部状态"],
["jackpot", "中出过大奖"],
["loss_streak", "连续未中奖"],
["pending_token", "有待兑消费大奖"],
];
const SORT_OPTIONS = [
["last_draw_at", "最近抽奖"],
["lifetime_rtp", "累计 RTP"],
["lifetime_wager", "累计消耗"],
["lifetime_payout", "累计返奖"],
["jackpot_count", "大奖次数"],
["loss_streak", "连续未中奖"],
];
const SORT_DIRECTION_OPTIONS = [
["desc", "从高到低 / 从新到旧"],
["asc", "从低到高 / 从旧到新"],
];
export function UserProfilesView({ apps, defaultAppCode, poolIDsByApp }) {
const [appCode, setAppCode] = useState(defaultAppCode);
const [poolId, setPoolId] = useState("");
const [identityType, setIdentityType] = useState("all");
const [stage, setStage] = useState("");
const [status, setStatus] = useState("");
const [sortBy, setSortBy] = useState("last_draw_at");
const [sortDirection, setSortDirection] = useState("desc");
const [keywordDraft, setKeywordDraft] = useState("");
const [keyword, setKeyword] = useState("");
const [page, setPage] = useState(1);
const [reloadKey, setReloadKey] = useState(0);
const [expandedKey, setExpandedKey] = useState("");
const [details, setDetails] = useState({});
// DataTable 使 owner cursor
// OFFSET
const cursorsByPage = useRef({ 1: "" });
const [state, setState] = useState({
error: "",
hasMore: false,
items: [],
loading: false,
snapshotAtMs: 0,
total: 0,
});
const appOptions = useMemo(
() =>
apps.map((item) => [
item.app_code ?? item.appCode,
`${item.app_name ?? item.appName ?? ""} (${item.app_code ?? item.appCode})`,
]),
[apps],
);
const poolIDs = useMemo(() => poolIDsByApp.get(appCode) || [], [appCode, poolIDsByApp]);
const poolOptions = useMemo(() => poolIDs.map((value) => [value, value]), [poolIDs]);
useEffect(() => {
setAppCode((current) => current || defaultAppCode);
}, [defaultAppCode]);
useEffect(() => {
//
// pool_id 退
setPoolId((current) => (current && poolIDs.includes(current) ? current : poolIDs[0] || ""));
}, [poolIDs]);
useEffect(() => {
if (!appCode || !poolId) {
setState({
error: "",
hasMore: false,
items: [],
loading: false,
snapshotAtMs: 0,
total: 0,
});
return undefined;
}
let cancelled = false;
setState((current) => ({ ...current, error: "", loading: true }));
fetchLuckyGiftUserProfiles({
appCode,
cursor: page > 1 ? cursorsByPage.current[page] || "" : "",
identityType,
keyword,
page,
pageSize: PAGE_SIZE,
poolId,
sortBy,
sortDirection,
stage,
status,
})
.then((result) => {
if (cancelled) {
return;
}
if (result.hasMore && result.nextCursor) {
cursorsByPage.current[page + 1] = result.nextCursor;
} else {
delete cursorsByPage.current[page + 1];
}
setState((current) => ({
error: "",
hasMore: result.hasMore && Boolean(result.nextCursor),
// cursor React row key
items: page > 1 ? mergeProfiles(current.items, result.items) : result.items,
loading: false,
snapshotAtMs: result.snapshotAtMs,
total: result.total,
}));
})
.catch((error) => {
if (cancelled) {
return;
}
setState((current) => ({
...current,
error: error.message || "获取用户画像失败",
loading: false,
}));
});
return () => {
cancelled = true;
};
}, [appCode, identityType, keyword, page, poolId, reloadKey, sortBy, sortDirection, stage, status]);
const resetQueryState = useCallback(() => {
cursorsByPage.current = { 1: "" };
setPage(1);
setExpandedKey("");
setDetails({});
}, []);
const reloadProfiles = useCallback(() => {
cursorsByPage.current = { 1: "" };
setPage(1);
setExpandedKey("");
setDetails({});
setReloadKey((current) => current + 1);
}, []);
const changeAppCode = useCallback(
(nextAppCode) => {
setAppCode(nextAppCode);
setPoolId((poolIDsByApp.get(nextAppCode) || [])[0] || "");
resetQueryState();
},
[poolIDsByApp, resetQueryState],
);
const changePoolId = useCallback(
(nextPoolId) => {
setPoolId(nextPoolId);
resetQueryState();
},
[resetQueryState],
);
const changeIdentityType = useCallback(
(nextIdentityType) => {
setIdentityType(nextIdentityType);
setKeywordDraft("");
setKeyword("");
resetQueryState();
},
[resetQueryState],
);
const submitSearch = useCallback(
(event) => {
event.preventDefault();
setKeyword(keywordDraft.trim());
resetQueryState();
},
[keywordDraft, resetQueryState],
);
const resetFilters = useCallback(() => {
setIdentityType("all");
setStage("");
setStatus("");
setSortBy("last_draw_at");
setSortDirection("desc");
setKeywordDraft("");
setKeyword("");
resetQueryState();
}, [resetQueryState]);
const loadDetail = useCallback(async (profile, jackpotCursor = "", append = false) => {
const key = profile.key;
setDetails((current) => ({
...current,
[key]: {
data: current[key]?.data,
error: "",
loading: !append,
loadingMore: append,
},
}));
try {
const data = await fetchLuckyGiftUserProfile({
appCode: profile.appCode,
externalUserId: profile.externalUserId,
identityType: profile.identityType,
jackpotCursor,
poolId: profile.poolId,
userId: profile.user.userId,
});
setDetails((current) => {
const previous = current[key]?.data;
const nextData =
append && previous
? {
...data,
// owner cursor
// request_id
jackpotHits: mergeJackpotHits(previous.jackpotHits, data.jackpotHits),
multiplierDistribution: previous.multiplierDistribution,
profile: previous.profile,
}
: data;
return { ...current, [key]: { data: nextData, error: "", loading: false, loadingMore: false } };
});
} catch (error) {
setDetails((current) => ({
...current,
[key]: {
data: current[key]?.data,
error: error.message || "获取画像明细失败",
loading: false,
loadingMore: false,
},
}));
}
}, []);
const loadMoreDetail = useCallback(
(profile) => {
const detail = details[profile.key]?.data;
if (!detail?.jackpotHasMore || !detail.nextJackpotCursor) {
return;
}
loadDetail(profile, detail.nextJackpotCursor, true);
},
[details, loadDetail],
);
const toggleProfile = useCallback(
(profile) => {
if (expandedKey === profile.key) {
setExpandedKey("");
return;
}
setExpandedKey(profile.key);
if (!details[profile.key]?.data && !details[profile.key]?.loading) {
loadDetail(profile);
}
},
[details, expandedKey, loadDetail],
);
const columns = useMemo(() => buildUserProfileColumns(expandedKey), [expandedKey]);
const loadMoreProfiles = useCallback(() => {
if (!state.loading && state.hasMore) {
setPage((current) => current + 1);
}
}, [state.hasMore, state.loading]);
const hasActiveFilters =
identityType !== "all" ||
Boolean(stage || status || keywordDraft || keyword) ||
sortBy !== "last_draw_at" ||
sortDirection !== "desc";
const searchPlaceholder =
identityType === "external"
? "只支持 external_user_id"
: identityType === "internal"
? "短号 / 长号 / 靓号"
: "短号 / 长号 / 靓号 / external_user_id";
return (
<div className="ops-view ops-user-profiles">
<AdminListToolbar
filters={
<>
<AdminFilterSelect label="应用" options={appOptions} value={appCode} onChange={changeAppCode} />
<AdminFilterSelect label="奖池" options={poolOptions} value={poolId} onChange={changePoolId} />
<AdminFilterSelect
label="用户来源"
options={IDENTITY_OPTIONS}
value={identityType}
onChange={changeIdentityType}
/>
<AdminFilterSelect
label="最近开奖阶段"
options={STAGE_OPTIONS}
value={stage}
onChange={(value) => {
setStage(value);
resetQueryState();
}}
/>
<AdminFilterSelect
label="问题定位"
options={STATUS_OPTIONS}
value={status}
onChange={(value) => {
setStatus(value);
resetQueryState();
}}
/>
<AdminFilterSelect
label="排序指标"
options={SORT_OPTIONS}
value={sortBy}
onChange={(value) => {
setSortBy(value);
resetQueryState();
}}
/>
<AdminFilterSelect
label="排序方向"
options={SORT_DIRECTION_OPTIONS}
value={sortDirection}
onChange={(value) => {
setSortDirection(value);
resetQueryState();
}}
/>
<form className="ops-user-profile-search" onSubmit={submitSearch}>
<AdminSearchBox
label="用户"
placeholder={searchPlaceholder}
value={keywordDraft}
onChange={setKeywordDraft}
/>
<Button startIcon={<SearchOutlined fontSize="small" />} type="submit" variant="primary">
查询
</Button>
</form>
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
</>
}
/>
{state.error && state.items.length ? (
<div className="ops-alert" role="alert">
<span>{state.error}</span>
<Button size="small" onClick={reloadProfiles}>
重新加载
</Button>
</div>
) : null}
<DataState
error={state.items.length ? "" : state.error}
loading={state.loading && !state.items.length}
onRetry={reloadProfiles}
>
<DataTable
className="ops-user-profile-table"
columns={columns}
emptyLabel="当前无数据"
getRowProps={(profile) => ({
"aria-expanded": expandedKey === profile.key,
className:
expandedKey === profile.key ? "ops-user-profile-row is-expanded" : "ops-user-profile-row",
role: "button",
tabIndex: 0,
onClick: () => toggleProfile(profile),
onKeyDown: (event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
toggleProfile(profile);
},
})}
items={state.items}
infiniteScroll={{
doneLabel: state.items.length ? "已加载全部用户" : "",
hasMore: !state.error && state.hasMore,
loading: state.loading && state.items.length > 0,
onLoadMore: loadMoreProfiles,
}}
minWidth="1830px"
renderRowDetail={(profile) =>
expandedKey === profile.key ? (
<UserProfileDetail
detailState={details[profile.key]}
key={`${profile.key}:detail`}
onLoadMore={() => loadMoreDetail(profile)}
profile={profile}
onRetry={() => loadDetail(profile)}
/>
) : null
}
rowKey={(profile) => profile.key}
tableClassName="ops-user-profile-grid"
title={`${appCode || "-"} / ${poolId || "-"} 用户画像${state.loading ? "(更新中)" : ""}`}
total={state.total}
/>
</DataState>
</div>
);
}
function mergeProfiles(previous = [], next = []) {
const byKey = new Map(previous.map((profile) => [profile.key, profile]));
next.forEach((profile) => byKey.set(profile.key, profile));
return [...byKey.values()];
}
function mergeJackpotHits(previous = [], next = []) {
const seen = new Set(previous.map(jackpotEventKey));
return [
...previous,
...next.filter((hit) => {
const key = jackpotEventKey(hit);
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
}),
];
}
function jackpotEventKey(hit) {
return hit.eventKey || `${hit.drawId}:${hit.requestId}:${hit.occurredAtMs}:${hit.mechanism}`;
}

View File

@ -808,6 +808,479 @@ body {
padding: var(--space-2) var(--space-6);
}
/* ---- 用户画像:四个统计窗口在单元格内纵向收敛,横向只保留控制链路需要对比的维度 ---- */
.ops-user-profile-search {
display: flex;
min-width: 360px;
align-items: center;
gap: var(--space-2);
}
.ops-user-profile-search .search-box {
flex: 1;
}
.ops-user-profile-table {
min-height: 560px;
}
.ops-user-profile-grid {
--table-row-height: 112px;
}
.ops-user-profile-row {
transition:
background var(--motion-fast) var(--ease-standard),
box-shadow var(--motion-fast) var(--ease-standard);
}
.ops-user-profile-row:hover,
.ops-user-profile-row.is-expanded {
background: var(--primary-surface);
}
.ops-user-profile-row:focus-visible {
z-index: 2;
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.ops-profile-identity {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.ops-profile-expand {
display: inline-flex;
flex: 0 0 22px;
align-items: center;
justify-content: center;
color: var(--text-tertiary);
}
.ops-profile-internal,
.ops-profile-external {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.ops-profile-internal {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.ops-profile-internal > small {
color: var(--text-tertiary);
font-size: 10px;
font-weight: 700;
padding-left: 42px;
}
.ops-profile-external__icon {
display: inline-flex;
width: 34px;
height: 34px;
flex: 0 0 34px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: var(--info-surface);
color: var(--info);
}
.ops-profile-external > span:last-child {
display: grid;
min-width: 0;
gap: 2px;
}
.ops-profile-external strong,
.ops-profile-external small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-profile-external strong {
color: var(--text-primary);
font-size: 13px;
}
.ops-profile-external small {
color: var(--text-tertiary);
font-size: 10px;
}
.ops-profile-window {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.ops-profile-window__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
}
.ops-profile-window__head strong {
color: var(--text-primary);
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.ops-profile-window__head small {
color: var(--text-tertiary);
font-size: 10px;
white-space: nowrap;
}
.ops-profile-window dl {
display: grid;
gap: 2px;
margin: 0;
}
.ops-profile-window dl > div {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: var(--space-2);
color: var(--text-secondary);
font-size: 11px;
}
.ops-profile-window dt,
.ops-profile-window dd {
margin: 0;
}
.ops-profile-window dd {
overflow: hidden;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-profile-window .is-profit dd {
color: var(--success);
}
.ops-profile-window .is-loss dd {
color: var(--danger);
}
.ops-profile-control,
.ops-profile-downweight,
.ops-profile-counts,
.ops-profile-time {
display: grid;
min-width: 0;
align-content: center;
gap: 3px;
}
.ops-profile-control > div {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.ops-profile-control > div > span:last-child,
.ops-profile-control small,
.ops-profile-downweight small,
.ops-profile-counts small,
.ops-profile-time small {
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
line-height: 1.35;
text-overflow: ellipsis;
}
.ops-profile-control strong,
.ops-profile-counts strong,
.ops-profile-time strong {
color: var(--text-primary);
font-size: 13px;
}
.ops-profile-control .is-warning {
color: var(--warning);
font-weight: 700;
}
.ops-profile-downweight > span,
.ops-profile-counts > span,
.ops-profile-time > span {
color: var(--text-secondary);
font-size: 11px;
}
.ops-user-profile-detail-row {
min-height: 0;
background: var(--fill-secondary);
}
.ops-user-profile-detail-cell {
min-width: 0;
grid-column: 1 / -1;
padding: 0 var(--space-4) var(--space-4) 44px;
}
.ops-user-profile-detail {
display: grid;
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.ops-user-profile-detail__summary {
display: grid;
grid-template-columns: repeat(6, minmax(140px, 1fr));
border-bottom: 1px solid var(--border);
}
.ops-user-profile-detail__summary dl {
min-width: 0;
margin: 0;
border-right: 1px solid var(--border);
padding: var(--space-3);
}
.ops-user-profile-detail__summary dl:last-child {
border-right: 0;
}
.ops-user-profile-detail__summary dt {
color: var(--text-tertiary);
font-size: 10px;
font-weight: 800;
}
.ops-user-profile-detail__summary dd {
margin: var(--space-1) 0 0;
color: var(--text-primary);
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.ops-user-profile-detail__section {
display: grid;
min-width: 0;
gap: var(--space-3);
border-bottom: 1px solid var(--border);
padding: var(--space-4) 0;
}
.ops-user-profile-detail__section:last-child {
border-bottom: 0;
}
.ops-user-profile-detail__section > header {
display: flex;
min-width: 0;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: 0 var(--space-3);
}
.ops-user-profile-detail__section h3,
.ops-user-profile-detail__section p {
margin: 0;
}
.ops-user-profile-detail__section h3 {
color: var(--text-primary);
font-size: 13px;
}
.ops-user-profile-detail__section p,
.ops-user-profile-detail__section > header > span {
color: var(--text-tertiary);
font-size: 10px;
}
.ops-user-profile-detail__section p {
margin-top: 2px;
}
.ops-profile-multiplier-table {
display: grid;
min-width: 820px;
overflow: hidden;
border-top: 1px solid var(--row-border);
border-bottom: 1px solid var(--row-border);
}
.ops-profile-multiplier-row {
display: grid;
min-height: 34px;
align-items: center;
grid-template-columns: repeat(7, minmax(112px, 1fr));
border-bottom: 1px solid var(--row-border);
padding: 0 var(--space-3);
color: var(--text-secondary);
font-size: 11px;
}
.ops-profile-multiplier-row:last-child {
border-bottom: 0;
}
.ops-profile-multiplier-row.is-head {
background: var(--bg-card-strong);
color: var(--text-tertiary);
font-weight: 800;
}
.ops-profile-multiplier-row strong {
color: var(--text-primary);
}
.ops-profile-jackpot-list {
display: grid;
}
.ops-profile-jackpot-more {
display: flex;
justify-content: center;
border-top: 1px solid var(--row-border);
padding: var(--space-3);
}
.ops-profile-jackpot-hit {
display: grid;
min-width: 0;
gap: var(--space-3);
border-top: 1px solid var(--row-border);
padding: var(--space-3);
}
.ops-profile-jackpot-hit__head,
.ops-profile-jackpot-hit__head > div,
.ops-profile-jackpot-hit footer {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.ops-profile-jackpot-hit__head {
justify-content: space-between;
}
.ops-profile-jackpot-hit__head strong {
color: var(--text-primary);
font-size: 15px;
}
.ops-profile-jackpot-hit__head > span,
.ops-profile-jackpot-hit__head div > span,
.ops-profile-jackpot-hit footer {
color: var(--text-tertiary);
font-size: 10px;
}
.ops-profile-jackpot-hit__reason {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
align-items: start;
gap: var(--space-3);
border-left: 3px solid var(--primary);
background: var(--primary-surface);
padding: var(--space-2) var(--space-3);
color: var(--text-secondary);
font-size: 11px;
line-height: 1.55;
}
.ops-profile-jackpot-hit__reason strong {
color: var(--primary);
}
.ops-profile-jackpot-conditions {
display: grid;
grid-template-columns: repeat(4, minmax(160px, 1fr));
gap: 1px;
margin: 0;
background: var(--border);
}
.ops-profile-jackpot-conditions > div {
min-width: 0;
background: var(--bg-card);
padding: var(--space-2) var(--space-3);
}
.ops-profile-jackpot-conditions dt {
overflow: hidden;
color: var(--text-tertiary);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-profile-jackpot-conditions dd {
overflow: hidden;
margin: 2px 0 0;
color: var(--text-primary);
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.ops-profile-jackpot-hit footer {
flex-wrap: wrap;
}
.ops-profile-jackpot-hit__no-conditions,
.ops-user-profile-detail__empty {
color: var(--text-tertiary);
font-size: 11px;
padding: var(--space-3);
}
.ops-user-profile-detail-skeleton {
display: grid;
gap: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-card);
padding: var(--space-4);
}
.ops-user-profile-detail-skeleton > div {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: var(--space-2);
}
.ops-user-profile-detail-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
border-top: 1px solid var(--danger-border);
background: var(--danger-surface);
color: var(--danger);
padding: var(--space-3) var(--space-4);
font-size: 12px;
font-weight: 700;
}
/* ---- 响应式 ---- */
@media (max-width: 1279px) {

View File

@ -148,6 +148,7 @@ export const API_OPERATIONS = {
getJob: "getJob",
getLuckyGiftConfig: "getLuckyGiftConfig",
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
getLuckyGiftUserProfile: "getLuckyGiftUserProfile",
getRechargeBillOverview: "getRechargeBillOverview",
getRechargeBillSummary: "getRechargeBillSummary",
getRedPacket: "getRedPacket",
@ -236,6 +237,7 @@ export const API_OPERATIONS = {
listLuckyGiftConfigs: "listLuckyGiftConfigs",
listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
listLuckyGiftUserProfiles: "listLuckyGiftUserProfiles",
listManagers: "listManagers",
listOperationLogs: "listOperationLogs",
listPermissions: "listPermissions",
@ -1358,6 +1360,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
getLuckyGiftUserProfile: {
method: "GET",
operationId: API_OPERATIONS.getLuckyGiftUserProfile,
path: "/v1/admin/ops-center/lucky-gifts/user-profile",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
getRechargeBillOverview: {
method: "GET",
operationId: API_OPERATIONS.getRechargeBillOverview,
@ -1972,6 +1981,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
listLuckyGiftUserProfiles: {
method: "GET",
operationId: API_OPERATIONS.listLuckyGiftUserProfiles,
path: "/v1/admin/ops-center/lucky-gifts/user-profiles",
permission: "lucky-gift:view",
permissions: ["lucky-gift:view"]
},
listManagers: {
method: "GET",
operationId: API_OPERATIONS.listManagers,

View File

@ -292,6 +292,38 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/user-profiles": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listLuckyGiftUserProfiles"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/user-profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getLuckyGiftUserProfile"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/ops-center/lucky-gifts/summary": {
parameters: {
query?: never;
@ -8329,6 +8361,30 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listLuckyGiftUserProfiles: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getLuckyGiftUserProfile: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getLuckyGiftDrawSummary: {
parameters: {
query?: never;