merge: promote admin test features to main

This commit is contained in:
zhx 2026-07-17 00:59:54 +08:00
commit f120ffbc9b
14 changed files with 2429 additions and 109 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";
@ -31,9 +32,15 @@ import { OverviewView } from "./components/OverviewView.jsx";
import { EXPERIMENT_ARM_LABELS, formatTrafficPercent } from "./experimentForm.js";
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: "应用列表" },
];
@ -66,6 +73,7 @@ export function OpsCenterApp({ can = denyPermission }) {
const [dialog, setDialog] = useState(null);
const [poolDialog, setPoolDialog] = useState(null);
const [experimentManage, setExperimentManage] = useState(null);
const [profileRefreshKey, setProfileRefreshKey] = useState(0);
const [adjustingPool, setAdjustingPool] = useState(false);
const [savingConfig, setSavingConfig] = useState(false);
const [experimentBusy, setExperimentBusy] = useState(false);
@ -95,6 +103,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(
@ -387,7 +403,7 @@ export function OpsCenterApp({ can = denyPermission }) {
<Button
disabled={state.loading}
startIcon={<RefreshOutlined fontSize="small" />}
onClick={loadDashboard}
onClick={refreshCurrentView}
>
刷新
</Button>
@ -436,6 +452,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

@ -3,6 +3,7 @@ import { apiRequest } from "@/shared/api/request";
export const OPS_API_PREFIX = "/api/v1/admin/ops-center";
export const DEFAULT_POOL_ID = "default";
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
export async function fetchOpsDashboard(query = {}) {
// Ops Center 是跨 App 的运营面板:先拉应用主数据,再按 app_code 扇出拉取奖池配置、余额和抽奖汇总。
@ -79,6 +80,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", {
@ -424,6 +518,15 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
.trim()
.toLowerCase();
const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion);
const user24hRTPThreshold =
config.user_24h_rtp_threshold_percent ??
config.user24HRTPThresholdPercent ??
config.user24hRTPThresholdPercent ??
config.user24hRtpThresholdPercent;
const user24hOrdinaryWinFactor =
config.user_24h_ordinary_win_factor_percent ??
config.user24HOrdinaryWinFactorPercent ??
config.user24hOrdinaryWinFactorPercent;
return {
...config,
app_code: appCode,
@ -433,6 +536,13 @@ function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") {
rule_version: ruleVersion,
// 升级前规则没有 strategy_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
// 仅在 owner 真正返回新字段时补 canonical key缺字段代表历史禁用快照不能在读取阶段伪装成已启用。
...(user24hRTPThreshold !== undefined
? { user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold) }
: {}),
...(user24hOrdinaryWinFactor !== undefined
? { user_24h_ordinary_win_factor_percent: numberValue(user24hOrdinaryWinFactor) }
: {}),
};
}
@ -451,6 +561,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)) {
@ -479,6 +932,16 @@ function luckyGiftConfigPayload(config = {}) {
const strategyVersion = String(config.strategy_version || config.strategyVersion || "fixed_v2")
.trim()
.toLowerCase();
const user24hRTPThreshold =
config.user_24h_rtp_threshold_percent ??
config.user24HRTPThresholdPercent ??
config.user24hRTPThresholdPercent ??
config.user24hRtpThresholdPercent;
const user24hOrdinaryWinFactor =
config.user_24h_ordinary_win_factor_percent ??
config.user24HOrdinaryWinFactorPercent ??
config.user24hOrdinaryWinFactorPercent;
const hasUser24hRTPConfig = user24hRTPThreshold !== undefined || user24hOrdinaryWinFactor !== undefined;
// 规则版本是不可变快照PUT 必须完整回传 owner GET 返回的策略字段。只提交旧版 RTP/奖档字段会让
// 空 strategy_version 被服务端按 fixed_v2 发布,或让 dynamic_v3 因资金、水位、大奖字段归零而拒绝发布。
@ -545,6 +1008,15 @@ function luckyGiftConfigPayload(config = {}) {
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
stages: normalizeStages(config.stages),
strategy_version: strategyVersion,
// 旧调用方不带这组字段时保持旧 payload 形状;任一字段出现就成组发送,避免只发布一半策略。
...(hasUser24hRTPConfig
? {
user_24h_ordinary_win_factor_percent: numberValue(
user24hOrdinaryWinFactor ?? USER_24H_RTP_DISABLED_FACTOR_PERCENT,
),
user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold),
}
: {}),
user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),

View File

@ -30,12 +30,18 @@ test("keeps every dynamic_v3 control reachable while locking rule-time pool inje
].forEach((label) =>
expect(within(dialog).getByLabelText((accessibleName) => accessibleName.startsWith(label))).toBeInTheDocument(),
);
expect(within(dialog).getByLabelText(/用户日累计消费触发门槛/)).toBeEnabled();
expect(within(dialog).getAllByRole("tab")).toHaveLength(3);
expect(within(dialog).getByRole("columnheader", { name: "倍率" })).toBeInTheDocument();
expect(within(dialog).getByRole("columnheader", { name: "普通中奖机会 %" })).toBeInTheDocument();
expect(within(dialog).getByRole("heading", { name: "独立大奖路径" })).toBeInTheDocument();
expect(within(dialog).getByText("4. 未成功才走普通开奖")).toBeInTheDocument();
["共享开奖前提", "48 小时亏损补偿", "累计消费达标"].forEach((heading) => {
expect(within(dialog).getByRole("heading", { name: heading })).toBeInTheDocument();
});
expect(within(dialog).getByText(/实际 RTP 小于或等于配置上限才通过/)).toBeInTheDocument();
expect(within(dialog).getByText(/每满一次门槛就产生 1 个大奖资格/)).toBeInTheDocument();
expect(within(dialog).getByText(/大盘、水位、次数或限额不通过时资格会跨日保留/)).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 1 档倍率")).toBeInTheDocument();
expect(within(dialog).getByLabelText("新手阶段第 2 档概率 %")).toHaveAttribute("readonly");
expect(within(dialog).getByRole("button", { name: "删除新手阶段第 1 档" })).toBeInTheDocument();

View File

@ -360,6 +360,20 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户近 24 小时 RTP 触发线 (%)"
name="user_24h_rtp_threshold_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="高 RTP 时普通中奖机会保留 (%)"
name="user_24h_ordinary_win_factor_percent"
step="0.01"
onChange={onChange}
/>
</div>
</section>
@ -370,58 +384,83 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo
id="ops-config-section-jackpot"
>
<SectionHeading id="ops-config-heading-jackpot" title="独立大奖路径" />
<JackpotFlowSummary />
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写;可与普通奖档使用相同倍率"
label={
<LuckyGiftFieldLabel
field="jackpot_multipliers"
label="独立大奖倍率(可选倍数)"
<div className="ops-jackpot-rule-groups">
<JackpotRuleGroup
description="两种大奖策略都先检查大盘:实际 RTP 小于或等于配置上限才通过,等于也通过;还必须有足够奖池水位并同时通过全部返奖限额。每日大奖次数由两种策略合并计算,同一子抽最多发出一个大奖。"
id="ops-jackpot-shared-heading"
title="共享开奖前提"
>
<div className="ops-form-grid">
<TextField
className="ops-form-grid__wide"
helperText="按从小到大填写;可与普通奖档使用相同倍率"
label={
<LuckyGiftFieldLabel
field="jackpot_multipliers"
label="独立大奖倍率(可选倍数)"
/>
}
required
size="small"
value={form.jackpot_multipliers}
onChange={(event) =>
onChange("jackpot_multipliers", event.target.value)
}
/>
}
required
size="small"
value={form.jackpot_multipliers}
onChange={(event) => onChange("jackpot_multipliers", event.target.value)}
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户本轮返还触发线 (%)"
name="jackpot_user_round_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户近 48 小时返还触发线 (%)"
name="jackpot_user_48h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="每位用户每日独立大奖总次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
<NumberField
disabled
form={form}
helperText="仅兼容历史配置,当前规则不参与大奖资格"
label="用户日累计消费触发门槛(金币)— 已停用"
name="jackpot_spend_threshold_coins"
onChange={onChange}
required={false}
/>
<NumberField
form={form}
label="全局大奖 RTP 上限 (%)"
name="jackpot_global_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="每位用户每日独立大奖总次数"
name="max_jackpot_hits_per_user_day"
onChange={onChange}
/>
</div>
</JackpotRuleGroup>
<JackpotRuleGroup
description="大盘结算时,用户必须注册满 48 小时,并且本轮 RTP 与近 48 小时总 RTP 都严格低于各自触发线。两项缺一不可;资格只尝试一次。与消费策略同时有资格时,本策略先尝试:成功后消费资格留到下一子抽,未出奖则同一子抽继续尝试消费资格。"
id="ops-jackpot-rtp-heading"
title="48 小时亏损补偿"
>
<div className="ops-form-grid">
<NumberField
form={form}
label="用户本轮返还触发线 (%)"
name="jackpot_user_round_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
<NumberField
form={form}
label="用户近 48 小时返还触发线 (%)"
name="jackpot_user_48h_rtp_max_percent"
step="0.01"
onChange={onChange}
/>
</div>
</JackpotRuleGroup>
<JackpotRuleGroup
description="同一应用、奖池和用户按 UTC 自然日累计实际消费,每满一次门槛就产生 1 个大奖资格。资格从下一子抽开始尝试;批量送礼中本次达标,下一子抽即可使用。大盘、水位、次数或限额不通过时资格会跨日保留,直到成功支付;它与 48 小时策略独立,不要求注册满 48 小时,也不检查用户 RTP。"
id="ops-jackpot-spend-heading"
title="累计消费达标"
>
<div className="ops-form-grid">
<NumberField
form={form}
helperText="每个 UTC 自然日每满一次门槛,产生 1 个待用资格"
label="用户日累计消费触发门槛(金币)"
name="jackpot_spend_threshold_coins"
onChange={onChange}
/>
</div>
</JackpotRuleGroup>
</div>
</section>
@ -489,30 +528,15 @@ function SectionHeading({ detail, id, ok = false, title }) {
);
}
function JackpotFlowSummary() {
function JackpotRuleGroup({ children, description, id, title }) {
return (
<div className="ops-jackpot-flow" aria-label="大奖产生方式">
<div>
<strong>1. 检查独立大奖资格</strong>
<span>
大盘 RTP 通过后还要同时满足用户本轮 48 小时返还条件和当日大奖次数限制
</span>
<section aria-labelledby={id} className="ops-jackpot-rule-group">
<div className="ops-jackpot-rule-group__head">
<h4 id={id}>{title}</h4>
<p>{description}</p>
</div>
<div>
<strong>2. 尝试可支付大奖</strong>
<span>资格通过后从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档</span>
</div>
<div>
<strong>3. 大奖成功直接结束</strong>
<span>本抽直接采用独立大奖结果并累计一次大奖次数不再叠加或继续抽普通奖档</span>
</div>
<div>
<strong>4. 未成功才走普通开奖</strong>
<span>
大奖资格未通过或没有可支付倍率时才按当前阶段普通概率开奖普通同倍率不需要大奖资格也不计大奖次数
</span>
</div>
</div>
{children}
</section>
);
}

View File

@ -23,7 +23,7 @@ const FIELD_HELP = {
enabled: {
summary: "决定新版本发布后是否立即用于真实送礼。关闭时规则仍会保存,但不会触发幸运开奖。",
adjust: "首次配置或大幅调整时可先保持停用,核对资金比例和上限后再启用。",
constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线和所有返奖上限。",
constraint: "启用 dynamic_v3 前必须填完三项大奖 RTP 触发线、累计消费门槛和所有返奖上限。",
},
target_rtp_percent: {
summary: "表示基础奖档的配置目标。例如填 98代表各阶段的“倍率 × 基础概率”预计返还约为 98%。",
@ -115,36 +115,50 @@ const FIELD_HELP = {
adjust: "调高会增强充值后的中奖体验,也会增加奖池消耗;调低则更温和。",
constraint: "必须大于 100%,会与高低余额调整相乘;它只改变普通奖档,不会直接提高大奖触发机会。",
},
user_24h_rtp_threshold_percent: {
summary:
"每次普通开奖前,系统会把同一用户在当前应用和奖池近 24 小时的实际返奖除以实际消费。结果严格高于这里填写的比例时,才降低普通中奖机会。",
adjust: "填 150 表示近 24 小时返奖超过消费的 150% 后开始调整。调低会让更多用户更早进入调整,调高则只处理返还明显偏高的用户。",
constraint:
"结果刚好等于或低于触发线时立即恢复正常;近 24 小时没有消费时不调整。内部用户和外部接入用户都按各自稳定账号独立统计。",
},
user_24h_ordinary_win_factor_percent: {
summary:
"用户超过近 24 小时触发线后,普通非 0 倍奖项还保留多少中奖机会。填 70 表示保留原来的 70%,也就是降低 30%,不是降低 70%。",
adjust: "调低会把更多普通中奖机会转成未中奖,更快压低该用户的短期返还;填 100 表示不减少中奖机会。",
constraint:
"必须大于 0% 且不超过 100%。减少的部分全部进入 0 倍档;两种独立大奖不受影响,连续未中奖保护仍优先执行,奖池余额和返奖上限也仍会拦截无法支付的结果。",
},
jackpot_multipliers: {
summary:
"列出独立大奖路径可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率同一抽先尝试独立大奖未成功才进入普通开奖。",
"列出 48 小时亏损补偿和累计消费达标两种大奖策略可能采用的倍数,例如 200、500、1000。它不并入普通奖档概率大奖未成功才进入普通开奖。",
adjust: "增加或调高倍数会放大独立大奖金额和资金压力;删除倍数只会减少可选大奖,不会删除普通奖档。",
constraint:
"用逗号分隔,必须都大于 1 且从小到大同倍率可以同时出现在普通奖档中。这里的“独立”只指触发条件、中奖概率和每日大奖次数独立两条路径的真实返奖仍共用当前奖池、RTP 统计和六项返奖上限。普通命中不计入大奖次数。",
"用逗号分隔,必须都大于 1 且从小到大;同倍率可以同时出现在普通奖档中。这里的“独立大奖”是相对普通概率开奖而言48 小时补偿与累计消费两种大奖策略共享每日大奖总次数、当前奖池、RTP 统计和六项返奖上限。普通命中不计入大奖次数。",
},
jackpot_global_rtp_max_percent: {
summary:
"系统每次达到结算流水后,会结束并重置一轮大盘 RTP。刚结算的本轮大盘 RTP 不高于这条线时,才继续检查用户的两项返还条件。",
adjust: "调高会让补偿大奖更容易进入候选;调低会要求全局返还更低才触发。",
"两种大奖策略共同使用的大盘安全线。48 小时亏损补偿使用产生资格时的结算轮次;累计消费达标在送礼时读取最新一个已经关闭的大盘结算轮次。实际大盘 RTP 小于或等于这条线时才通过,等于也通过。",
adjust: "调高会让两种大奖更容易进入候选;调低会要求大盘返还更低才触发。",
constraint:
"必须大于 0% 且不超过 100%。大盘 RTP 等于本项配置值时可以通过;还必须同时满足用户本轮 RTP 严格低于本轮触发线、用户近 48 小时 RTP 严格低于长期触发线,且奖池和各项限额足够。它只控制独立大奖,不会阻止普通概率命中相同倍率。",
"必须大于 0% 且不超过 100%。没有可用的已结算大盘样本时不会发大奖;奖池余额、每日大奖次数和全部返奖上限也必须同时足够。用户两项 RTP 只属于 48 小时亏损补偿,不限制累计消费达标策略。它不会阻止普通概率命中相同倍率。",
},
jackpot_user_round_rtp_max_percent: {
summary:
"系统每次重置大盘 RTP 时,会按完全相同的起止时间结算这个用户的本轮 RTP。例如 9 点重置一次、10 点再次重置10 点结算时就检查用户在 9 点到 10 点这一轮的总返奖 ÷ 总消费。",
adjust: "调高会让更多本轮返还偏低的用户通过;调低会更严格。用户本轮 RTP 必须严格小于配置值,刚好等于也不通过。",
constraint:
"只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项。",
"只统计当前 App、当前奖池和同一结算轮次内的实际消费与返奖本轮没有消费样本时不通过。它必须与近 48 小时条件同时通过,不能只满足其中一项;该条件不限制累计消费达标策略。",
},
jackpot_user_48h_rtp_max_percent: {
summary:
"判断这个用户近 48 小时是否整体返还偏低。本轮结算时,用户注册已满 48 小时,系统才会把结算时点向前 48 小时内的返奖金币加总,再除以同期实际消费金币;同一 App、奖池下跨房间、主播和设备合并统计结果严格低于这条线才通过。",
adjust: "调高会更容易通过,调低会更严格;结果等于配置值也不通过。例如近 48 小时消费 10,000、返奖 9,500RTP 为 95%,配置 96% 时通过;返奖 9,600、RTP 为 96% 时不通过。",
constraint:
"先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制。",
"先看注册时间:注册未满 48 小时直接不通过;注册已满 48 小时后,不要求连续玩满 48 小时,只统计窗口内真实发生的消费和返奖。即使前 47 小时都没玩、最近只玩 5 分钟,也用这 5 分钟的数据计算;整段时间没有消费时不通过。它还必须与大盘条件、用户本轮条件同时通过,并受每日大奖次数、奖池余额和所有返奖上限限制;该条件不限制累计消费达标策略。",
},
max_jackpot_hits_per_user_day: {
summary: "限制同一用户在当前奖池的一个 UTC 自然日内,通过独立大奖路径合计最多真正拿到多少次大奖。",
summary: "限制同一用户在当前奖池的一个 UTC 自然日内,通过 48 小时亏损补偿和累计消费达标两种策略合计最多真正拿到多少次大奖。",
adjust:
"调高允许同一用户一天通过独立大奖路径获得更多奖励;调低会更早停止该用户当天的独立大奖,不保证转给其他用户。",
constraint:
@ -152,10 +166,10 @@ const FIELD_HELP = {
},
jackpot_spend_threshold_coins: {
summary:
"这是旧规则按用户日累计消费保存独立大奖资格时使用的历史字段。当前规则已停用这条路径,保留数值只为读取和回传旧版本配置。",
adjust: "当前不可编辑;无论原值是多少,都不会增加用户出大奖的机会。",
"同一应用、奖池和用户在一个 UTC 自然日内,每累计实际消费满这个金币数,就产生 1 个待用大奖资格。例如门槛 800,000消费达到 800,000、1,600,000、2,400,000 时会各产生 1 个。",
adjust: "调低会更频繁地产生消费大奖资格;调高会要求用户先累计更多消费。资格数量按每个 UTC 自然日独立计算。",
constraint:
"当前大奖必须经过大盘条件,并同时满足用户本轮 RTP 与近 48 小时 RTP 两项条件;这个历史字段不能替代或绕过任何一项。",
"启用 dynamic_v3 时必须大于 0。达标当次子抽不能立即使用新资格从下一子抽开始尝试大盘、水位、每日次数或任一返奖上限不满足时资格不会作废而是跨日保留到能够支付。该策略与 48 小时亏损补偿独立,不要求注册满 48 小时,也不检查用户本轮或近 48 小时 RTP。",
},
max_single_payout: {
summary: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。",

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

@ -12,6 +12,9 @@ import { DEFAULT_POOL_ID } from "./api.js";
const DYNAMIC_STRATEGY = "dynamic_v3";
const PPM_SCALE = 1_000_000;
const LEGACY_JACKPOT_RTP_MODE = "__legacy_jackpot_rtp_fields";
const LEGACY_USER_24H_RTP_MODE = "__legacy_user_24h_rtp_fields";
const USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT = 150;
const USER_24H_RTP_DISABLED_FACTOR_PERCENT = 100;
const legacyJackpotRTPAliases = {
jackpot_user_48h_rtp_max_percent: [
"jackpot_user_72h_rtp_max_percent",
@ -24,6 +27,10 @@ const legacyJackpotRTPAliases = {
"jackpotUserDayRtpMaxPercent",
],
};
const user24hRTPAliases = {
user_24h_ordinary_win_factor_percent: ["user24HOrdinaryWinFactorPercent"],
user_24h_rtp_threshold_percent: ["user24HRTPThresholdPercent", "user24hRTPThresholdPercent"],
};
// 只有跨 App 通用的算法参数才有默认值;六维金额上限必须由运营按 App 奖池的金币口径填写,
// 因而默认保持 0 且规则停用,避免把某个 App 的预算误发到其他 App。
@ -52,6 +59,8 @@ const dynamicDefaults = {
recharge_boost_window_ms: 300_000,
room_hourly_payout_cap: 0,
target_rtp_percent: 98,
user_24h_ordinary_win_factor_percent: 70,
user_24h_rtp_threshold_percent: USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT,
user_daily_payout_cap: 0,
user_hourly_payout_cap: 0,
};
@ -84,6 +93,8 @@ const numericFields = [
"room_hourly_payout_cap",
"settlement_window_wager",
"target_rtp_percent",
"user_24h_ordinary_win_factor_percent",
"user_24h_rtp_threshold_percent",
"user_daily_payout_cap",
"user_hourly_payout_cap",
];
@ -122,12 +133,16 @@ export function configToForm(config = {}) {
stages: normalizeFormStages(config.stages, targetRTPPercent, strategyVersion),
strategy_version: strategyVersion,
};
const newDynamicDraft = strategyVersion === DYNAMIC_STRATEGY && isNewDynamicDraft(config);
// 只在明确收到旧 day/72h 且缺少对应 canonical 字段时进入兼容模式。页面始终编辑 canonical 值,
// 这个标记仅让纯函数保留旧对象形状HTTP 层仍会归一成 round/48h 后再发送。
form[LEGACY_JACKPOT_RTP_MODE] = Object.entries(legacyJackpotRTPAliases).some(
([canonical, aliases]) =>
!hasConfigValue(config, [canonical, snakeToCamel(canonical)]) && hasConfigValue(config, aliases),
);
// 已发布的旧规则没有近 24 小时降权字段,或者以 0/100 明确表示关闭。编辑时把禁用态展示为
// 更容易理解的 150/100但保留来源形状运营不改这两个值就不会意外给历史版本启用 70% 降权。
form[LEGACY_USER_24H_RTP_MODE] = legacyUser24hRTPMode(config, strategyVersion);
for (const field of numericFields) {
if (field === "target_rtp_percent") {
form[field] = targetRTPPercent;
@ -138,10 +153,27 @@ export function configToForm(config = {}) {
form[field] = "0";
continue;
}
const legacyAliases = legacyJackpotRTPAliases[field] || [];
form[field] = hasConfigValue(config, [field, snakeToCamel(field)])
? formConfigNumber(config, field, snakeToCamel(field), defaultNumber(field, strategyVersion))
: formNumber(firstConfigValue(config, legacyAliases) ?? defaultNumber(field, strategyVersion));
if (newDynamicDraft && field === "user_24h_rtp_threshold_percent") {
// 兼容 admin-server 先于本字段升级时返回的显式 0/100 草稿;未发布版本始终采用新建默认。
form[field] = String(USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT);
continue;
}
if (newDynamicDraft && field === "user_24h_ordinary_win_factor_percent") {
form[field] = String(dynamicDefaults.user_24h_ordinary_win_factor_percent);
continue;
}
if (form[LEGACY_USER_24H_RTP_MODE] && field === "user_24h_rtp_threshold_percent") {
form[field] = String(USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT);
continue;
}
if (form[LEGACY_USER_24H_RTP_MODE] && field === "user_24h_ordinary_win_factor_percent") {
form[field] = String(USER_24H_RTP_DISABLED_FACTOR_PERCENT);
continue;
}
const aliases = [...(legacyJackpotRTPAliases[field] || []), ...(user24hRTPAliases[field] || [])];
form[field] = hasConfigValue(config, [field, snakeToCamel(field), ...aliases])
? formNumber(firstConfigValue(config, [field, snakeToCamel(field), ...aliases]))
: formNumber(defaultNumber(field, strategyVersion));
}
if (form[LEGACY_JACKPOT_RTP_MODE]) {
form.jackpot_user_72h_rtp_max_percent = form.jackpot_user_48h_rtp_max_percent;
@ -182,6 +214,21 @@ export function formToConfig(config, form) {
? 0
: numberFromForm(form[field]);
}
if (
form.strategy_version === DYNAMIC_STRATEGY &&
form[LEGACY_USER_24H_RTP_MODE] &&
numberFromForm(form.user_24h_rtp_threshold_percent) === USER_24H_RTP_DEFAULT_THRESHOLD_PERCENT &&
numberFromForm(form.user_24h_ordinary_win_factor_percent) === USER_24H_RTP_DISABLED_FACTOR_PERCENT
) {
// 缺字段的旧快照继续保持原始 shape显式 0/100 的旧快照则继续写回禁用值;两者再次发布都无效果。
if (form[LEGACY_USER_24H_RTP_MODE] === "missing") {
delete output.user_24h_rtp_threshold_percent;
delete output.user_24h_ordinary_win_factor_percent;
} else {
output.user_24h_rtp_threshold_percent = 0;
output.user_24h_ordinary_win_factor_percent = USER_24H_RTP_DISABLED_FACTOR_PERCENT;
}
}
if (form[LEGACY_JACKPOT_RTP_MODE]) {
// 旧输入的纯函数 round-trip 保持原 shape避免调用方只做本地比较时出现伪 diff真正保存时
// luckyGiftConfigPayload 会把这两个别名归一成 canonical HTTP 字段,绝不会双发两套字段。
@ -330,6 +377,9 @@ export function validateLuckyGiftForm(form) {
if (number("high_water_nonzero_factor_percent") <= 100) errors.push("高水位非零因子必须大于 100%");
if (number("recharge_boost_window_ms") <= 0 || number("recharge_boost_factor_percent") <= 100)
errors.push("充值加权窗口必须大于 0 且因子必须大于 100%");
if (number("user_24h_rtp_threshold_percent") <= 0) errors.push("用户近 24 小时 RTP 触发线必须大于 0%");
if (number("user_24h_ordinary_win_factor_percent") <= 0 || number("user_24h_ordinary_win_factor_percent") > 100)
errors.push("高 RTP 时普通中奖机会保留比例必须大于 0% 且不超过 100%");
const jackpotTokens = String(form.jackpot_multipliers || "")
.split(/[,\s]+/)
.filter(Boolean);
@ -337,7 +387,9 @@ export function validateLuckyGiftForm(form) {
if (
jackpotTokens.length !== jackpotMultiplierValues.length ||
!jackpotMultiplierValues.length ||
jackpotMultiplierValues.some((value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]))
jackpotMultiplierValues.some(
(value, index) => value <= 1 || (index > 0 && value <= jackpotMultiplierValues[index - 1]),
)
) {
errors.push("大奖倍率必须大于 1x 并严格递增");
}
@ -355,7 +407,9 @@ export function validateLuckyGiftForm(form) {
errors.push("用户 48 小时 RTP 触发线必须大于 0 且不超过全局上限");
if (number("max_jackpot_hits_per_user_day") <= 0) errors.push("用户每日大奖次数必须大于 0");
if (number("jackpot_spend_threshold_coins") < 0) errors.push("大奖消费门槛不能小于 0");
if (form[LEGACY_JACKPOT_RTP_MODE] && form.enabled && number("jackpot_spend_threshold_coins") <= 0)
// 历史版本可以继续读取和展示 0只有运营要发布为启用状态时才要求为重新启用的消费策略
// 提供正数门槛。该判断不依赖旧字段别名,避免 canonical 新版本绕过校验直接上线。
if (form.strategy_version === DYNAMIC_STRATEGY && form.enabled && number("jackpot_spend_threshold_coins") <= 0)
errors.push("启用前必须填写用户日累计消费触发门槛(金币)");
for (const [field, label] of payoutCapFields) {
if (number(field) < 0) errors.push(`${label}不能小于 0`);
@ -467,6 +521,45 @@ function defaultNumber(field, strategyVersion) {
return strategyVersion === DYNAMIC_STRATEGY ? (dynamicDefaults[field] ?? 0) : 0;
}
function legacyUser24hRTPMode(config, strategyVersion) {
if (strategyVersion !== DYNAMIC_STRATEGY) return "";
// rule_version=0 / is_default 是尚未发布的草稿,即使旧后端显式返回 0/100 也必须使用新建
// 默认;只有已发布版本的缺字段或 0/100 才属于历史禁用兼容。
if (isNewDynamicDraft(config)) return "";
const threshold = firstConfigValue(config, [
"user_24h_rtp_threshold_percent",
snakeToCamel("user_24h_rtp_threshold_percent"),
...user24hRTPAliases.user_24h_rtp_threshold_percent,
]);
const factor = firstConfigValue(config, [
"user_24h_ordinary_win_factor_percent",
snakeToCamel("user_24h_ordinary_win_factor_percent"),
...user24hRTPAliases.user_24h_ordinary_win_factor_percent,
]);
if (threshold === undefined && factor === undefined) return "missing";
if (Number(threshold || 0) === 0 && [undefined, 0, 100].includes(factor === undefined ? undefined : Number(factor)))
return "disabled";
return "";
}
function isNewDynamicDraft(config) {
if (config.is_default === true || config.isDefault === true) return true;
if (hasConfigValue(config, ["rule_version", "ruleVersion"])) {
return Number(firstConfigValue(config, ["rule_version", "ruleVersion"])) <= 0;
}
// 新建入口只传规则身份;若已经带有资金、概率或水位字段,就按旧发布快照处理,不能套 70% 新默认值。
const identityKeys = new Set([
"app_code",
"appCode",
"enabled",
"pool_id",
"poolId",
"strategy_version",
"strategyVersion",
]);
return Object.keys(config).every((key) => identityKeys.has(key));
}
function multiplierListToForm(value, strategyVersion) {
if (Array.isArray(value) && value.length) return value.map(formatDecimal).join(", ");
if (typeof value === "string" && value.trim()) return value;

View File

@ -72,6 +72,7 @@ describe("ops center lucky gift dynamic_v3 form", () => {
expect(errors).toContain("启用前普通阶段至少一个最低充值门槛必须大于 0");
expect(errors).toContain("高阶充值门槛必须逐维不低于正常阶段且至少一项更高");
expect(errors).toContain("启用前必须填写用户日累计消费触发门槛(金币)");
expect(errors).toContain("启用前必须填写单次返奖上限");
expect(errors).toContain("启用前必须填写主播每日上限");
});

View File

@ -445,30 +445,37 @@ body {
line-height: 1.5;
}
.ops-jackpot-flow {
.ops-jackpot-rule-groups {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--border);
gap: var(--space-4);
}
.ops-jackpot-flow > div {
.ops-jackpot-rule-group {
display: grid;
align-content: start;
gap: 4px;
background: var(--bg-card-strong, #f8fafc);
padding: var(--space-2) var(--space-3);
gap: var(--space-3);
margin: 0;
padding: 0;
}
.ops-jackpot-flow strong {
.ops-jackpot-rule-group + .ops-jackpot-rule-group {
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
.ops-jackpot-rule-group__head {
display: grid;
gap: 3px;
}
.ops-jackpot-rule-group__head h4 {
margin: 0;
color: var(--text-primary);
font-size: 12px;
font-size: 13px;
line-height: 1.4;
}
.ops-jackpot-flow span {
.ops-jackpot-rule-group__head p {
margin: 0;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
@ -882,6 +889,15 @@ body {
gap: var(--space-2);
}
/* ---- 用户画像:四个统计窗口在单元格内纵向收敛,横向只保留控制链路需要对比的维度 ---- */
.ops-user-profile-search {
display: flex;
min-width: 360px;
align-items: center;
gap: var(--space-2);
}
.ops-experiment-arm__head strong {
font-size: 14px;
}
@ -955,12 +971,476 @@ body {
white-space: nowrap;
}
.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-experiment-overrides li span {
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
}
.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;
}
/* 结束实验的二次确认层:不可回滚的全量发布动作,用 danger 面板与普通操作区分。 */
.ops-experiment-confirm {
display: grid;

View File

@ -151,6 +151,7 @@ export const API_OPERATIONS = {
getJob: "getJob",
getLuckyGiftConfig: "getLuckyGiftConfig",
getLuckyGiftDrawSummary: "getLuckyGiftDrawSummary",
getLuckyGiftUserProfile: "getLuckyGiftUserProfile",
getRechargeBillOverview: "getRechargeBillOverview",
getRechargeBillSummary: "getRechargeBillSummary",
getRedPacket: "getRedPacket",
@ -240,6 +241,7 @@ export const API_OPERATIONS = {
listLuckyGiftDraws: "listLuckyGiftDraws",
listLuckyGiftExperiments: "listLuckyGiftExperiments",
listLuckyGiftPoolBalances: "listLuckyGiftPoolBalances",
listLuckyGiftUserProfiles: "listLuckyGiftUserProfiles",
listManagers: "listManagers",
listOperationLogs: "listOperationLogs",
listOperationsWithdrawalApplications: "listOperationsWithdrawalApplications",
@ -1387,6 +1389,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,
@ -2008,6 +2017,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;
@ -8458,6 +8490,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;