diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index bcc5c0b..2e048cf 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/ops-center/src/OpsCenterApp.jsx b/ops-center/src/OpsCenterApp.jsx index 90a1d62..1300697 100644 --- a/ops-center/src/OpsCenterApp.jsx +++ b/ops-center/src/OpsCenterApp.jsx @@ -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 }) { @@ -436,6 +452,16 @@ export function OpsCenterApp({ can = denyPermission }) { {activeView === "draws" ? ( ) : null} + {activeView === "profiles" ? ( + }> + + + ) : null} {activeView === "apps" ? : null} {dialog ? ( diff --git a/ops-center/src/api.js b/ops-center/src/api.js index af0e115..f21e859 100644 --- a/ops-center/src/api.js +++ b/ops-center/src/api.js @@ -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_version,lucky-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), diff --git a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx index 1002ae1..1739fcd 100644 --- a/ops-center/src/components/LuckyGiftConfigDialog.test.jsx +++ b/ops-center/src/components/LuckyGiftConfigDialog.test.jsx @@ -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(); diff --git a/ops-center/src/components/LuckyGiftConfigSections.jsx b/ops-center/src/components/LuckyGiftConfigSections.jsx index ad7c49f..3df588e 100644 --- a/ops-center/src/components/LuckyGiftConfigSections.jsx +++ b/ops-center/src/components/LuckyGiftConfigSections.jsx @@ -360,6 +360,20 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo step="0.01" onChange={onChange} /> + + @@ -370,58 +384,83 @@ export function LuckyGiftConfigSections({ appOptions, experimentMode = false, fo id="ops-config-section-jackpot" > - -
- + +
+ + } + 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)} - /> - - - - - + + +
+
+ + +
+ + +
+
+ + +
+ +
+
@@ -489,30 +528,15 @@ function SectionHeading({ detail, id, ok = false, title }) { ); } -function JackpotFlowSummary() { +function JackpotRuleGroup({ children, description, id, title }) { return ( -
-
- 1. 检查独立大奖资格 - - 大盘 RTP 通过后,还要同时满足用户本轮、近 48 小时返还条件和当日大奖次数限制。 - +
+
+

{title}

+

{description}

-
- 2. 尝试可支付大奖 - 资格通过后,从奖池余额和各项返奖上限都付得起的独立大奖倍率中选择一档。 -
-
- 3. 大奖成功直接结束 - 本抽直接采用独立大奖结果并累计一次大奖次数,不再叠加或继续抽普通奖档。 -
-
- 4. 未成功才走普通开奖 - - 大奖资格未通过或没有可支付倍率时,才按当前阶段普通概率开奖;普通同倍率不需要大奖资格,也不计大奖次数。 - -
-
+ {children} + ); } diff --git a/ops-center/src/components/LuckyGiftFieldHelp.jsx b/ops-center/src/components/LuckyGiftFieldHelp.jsx index e8e94d9..f56b1d8 100644 --- a/ops-center/src/components/LuckyGiftFieldHelp.jsx +++ b/ops-center/src/components/LuckyGiftFieldHelp.jsx @@ -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,500,RTP 为 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: "限制一抽最多能返多少金币;超过这个金额的奖档当次不会发出。", diff --git a/ops-center/src/components/UserProfileDetail.jsx b/ops-center/src/components/UserProfileDetail.jsx new file mode 100644 index 0000000..3778a8f --- /dev/null +++ b/ops-center/src/components/UserProfileDetail.jsx @@ -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 ( +
+
+ {!detailState || detailState.loading ? : null} + {detailState?.error ? ( +
+ {detailState.error} + +
+ ) : null} + {detailState?.data && !detailState.loading ? ( + + ) : null} +
+
+ ); +} + +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 ( +
+
+ + + + 0 + ? `还差 ${formatNumber(profile.guaranteeDrawsRemaining)} 抽` + : "保护剩余 0 抽" + } + /> + + + + + + + } /> + +
+ +
+
+
+

倍率命中分布

+

普通随机和两条大奖通道分列,倍率相同时也不会混为一次命中。

+
+ {distribution.length} 个倍率 +
+ {distribution.length ? ( +
+
+ 倍率 + 未中奖 + 普通命中 + RTP 补偿大奖 + 累计消费大奖 + 合计命中 + 返奖金币 +
+ {distribution.map((metric) => ( +
+ {formatMultiplierFromPPM(metric.multiplierPpm)} + {formatNumber(metric.zeroDrawCount)} + {formatNumber(metric.ordinaryHitCount)} + {formatNumber(metric.rtpCompensationJackpotCount)} + {formatNumber(metric.cumulativeSpendJackpotCount)} + {formatNumber(metric.hitCount)} + {formatNumber(metric.payoutCoins)} +
+ ))} +
+ ) : ( +
当前没有倍率命中数据
+ )} +
+ +
+
+
+

大奖命中依据

+

逐次展示走哪条大奖通道、命中时的关键门槛和实际判断结果。

+
+ + 展示 {jackpotHits.length} / 共 {formatNumber(data.jackpotHitTotal || jackpotHits.length)} 次 + +
+ {jackpotHits.length ? ( + <> +
+ {jackpotHits.map((hit, index) => ( + + ))} +
+ {data.jackpotHasMore && data.nextJackpotCursor ? ( +
+ +
+ ) : null} + + ) : ( +
该用户当前没有大奖命中记录
+ )} +
+
+ ); +} + +function DetailMetric({ label, value }) { + return ( +
+
{label}
+
{value || "-"}
+
+ ); +} + +function JackpotHit({ hit }) { + const conditions = flattenConditions(hit.conditions); + const mechanism = mechanismLabel(hit.mechanism); + return ( +
+
+
+ + {formatMultiplierFromPPM(hit.multiplierPpm)} + 返奖 {formatNumber(hit.payoutCoins)} 金币 +
+ +
+
+ 为什么命中 + {reasonLabel(hit.reason, hit.reasonCode, hit.mechanism)} +
+ {conditions.length ? ( +
+ {conditions.map((condition) => ( +
+
{condition.value?.label || conditionLabel(condition.key)}
+
{conditionValue(condition.key, condition.value)}
+
+ ))} +
+ ) : ( +
该历史记录没有保存更多判断快照
+ )} +
+ 阶段:{stageLabel(hit.stage)} + 规则:v{hit.ruleVersion || "-"} + 消耗:{formatNumber(hit.wagerCoins)} 金币 + {hit.tierId ? 奖档:{hit.tierId} : null} + {hit.drawId ? 抽奖单:{hit.drawId} : null} + {hit.requestId ? 请求号:{hit.requestId} : null} +
+
+ ); +} + +function DetailSkeleton() { + return ( + + ); +} + +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 hash、strategy 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 || "未记录"; +} diff --git a/ops-center/src/components/UserProfileTable.jsx b/ops-center/src/components/UserProfileTable.jsx new file mode 100644 index 0000000..4029e1d --- /dev/null +++ b/ops-center/src/components/UserProfileTable.jsx @@ -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) => , + 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) => , + width: "190px", + }, + { + key: "downweight", + label: "24 小时 RTP 降权", + render: (profile) => , + width: "190px", + }, + { + key: "ordinary_wins", + label: "普通中奖", + render: (profile) => ( +
+ {formatNumber(profile.ordinaryWinCount)} 次 + 其中高倍率 {formatNumber(profile.highMultiplierOrdinaryWinCount)} 次 + 未中奖 {formatNumber(profile.zeroDrawCount)} 次 + 普通奖与大奖通道独立统计 +
+ ), + width: "170px", + }, + { + key: "jackpots", + label: "两类大奖", + render: (profile) => ( +
+ + {formatNumber(profile.rtpCompensationJackpotCount + profile.cumulativeSpendJackpotCount)} 次 + + RTP 补偿 {formatNumber(profile.rtpCompensationJackpotCount)} + 累计消费 {formatNumber(profile.cumulativeSpendJackpotCount)} + 点击行查看每次命中依据 +
+ ), + width: "180px", + }, + { + key: "last_draw_at_ms", + label: "最近抽奖", + render: (profile) => ( +
+ + + v{profile.latestRuleVersion || "-"} · {profile.latestStrategyVersion || "-"} + +
+ ), + width: "180px", + }, + { + key: "data_lag_ms", + label: "画像时效", + render: (profile) => ( +
+ {formatDuration(profile.dataLagMs)} + + {profile.failedDrawCount > 0 + ? `失败 ${formatNumber(profile.failedDrawCount)} 抽` + : `已发 ${formatNumber(profile.grantedDrawCount)} · 待处理 ${formatNumber(profile.pendingDrawCount)}`} + +
+ ), + width: "150px", + }, + ]; +} + +function windowColumn(key, label, selectMetric) { + return { + key, + label, + render: (profile) => , + width: "205px", + }; +} + +function WindowMetricCell({ metric }) { + return ( +
+
+ {metric.hasRtp ? formatObservedRTP(metric.rtpPpm) : "-"} + {formatNumber(metric.drawCount)} 抽 +
+
+
+
消耗
+
{formatNumber(metric.wagerCoins)}
+
+
+
返奖
+
{formatNumber(metric.payoutCoins)}
+
+
0 ? "is-profit" : metric.netCoins < 0 ? "is-loss" : ""}> +
用户净输赢
+
{formatSignedNumber(metric.netCoins)}
+
+
+
+ ); +} + +function UserIdentityCell({ expanded, profile }) { + return ( +
+ + {profile.identityType === "external" ? ( +
+ + + + + {profile.externalUserId || "-"} + external_user_id + +
+ ) : ( +
+ + 内部用户{profile.user.status ? ` · ${userStatusLabel(profile.user.status)}` : ""} +
+ )} +
+ ); +} + +function ControlStateCell({ profile }) { + const remaining = profile.guaranteeDrawsRemaining; + return ( +
+
+ + 等价抽数 {formatNumber(profile.equivalentDraws)} +
+ 连续未中 {formatNumber(profile.lossStreak)} 次 + {remaining > 0 ? `距连续未中保护 ${formatNumber(remaining)} 抽` : "连续未中保护剩余 0 抽"} + {profile.pendingCumulativeSpendTokens > 0 ? ( + + 待兑累计消费大奖 {formatNumber(profile.pendingCumulativeSpendTokens)} 次 + + ) : null} +
+ ); +} + +function DownweightCell({ profile }) { + const threshold = profile.user24hRtpThresholdPpm; + const factor = profile.user24hOrdinaryWinFactorPpm; + return ( +
+ {profile.downweightActive ? ( + + ) : ( + + )} + + 当前{" "} + {profile.hasCurrentRtp || profile.windows.twentyFourHours.hasRtp + ? formatObservedRTP( + profile.hasCurrentRtp ? profile.currentRtpPpm : profile.windows.twentyFourHours.rtpPpm, + ) + : "暂无消耗"} + + + {threshold > 0 + ? `严格高于 ${formatPercentFromPPM(threshold)} 时保留 ${formatPercentFromPPM(factor)}` + : profile.downweightActive + ? `普通中奖机会当前按 ${formatPercentFromPPM(factor)} 发放` + : "当前普通中奖机会未被降权"} + +
+ ); +} + +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)} 小时`; +} diff --git a/ops-center/src/components/UserProfilesView.jsx b/ops-center/src/components/UserProfilesView.jsx new file mode 100644 index 0000000..f5c94c1 --- /dev/null +++ b/ops-center/src/components/UserProfilesView.jsx @@ -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 ( +
+ + + + + { + setStage(value); + resetQueryState(); + }} + /> + { + setStatus(value); + resetQueryState(); + }} + /> + { + setSortBy(value); + resetQueryState(); + }} + /> + { + setSortDirection(value); + resetQueryState(); + }} + /> +
+ + + + + + } + /> + {state.error && state.items.length ? ( +
+ {state.error} + +
+ ) : null} + + ({ + "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 ? ( + 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} + /> + +
+ ); +} + +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}`; +} diff --git a/ops-center/src/configForm.js b/ops-center/src/configForm.js index 1c8de77..84d24c2 100644 --- a/ops-center/src/configForm.js +++ b/ops-center/src/configForm.js @@ -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; diff --git a/ops-center/src/configForm.test.js b/ops-center/src/configForm.test.js index 787fb84..c5792ec 100644 --- a/ops-center/src/configForm.test.js +++ b/ops-center/src/configForm.test.js @@ -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("启用前必须填写主播每日上限"); }); diff --git a/ops-center/src/styles/index.css b/ops-center/src/styles/index.css index c8f40ac..602db70 100644 --- a/ops-center/src/styles/index.css +++ b/ops-center/src/styles/index.css @@ -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; diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 7a6d7dc..de7529c 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 7df3f11..867f4d1 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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;