import { API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints"; 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 扇出拉取奖池配置、余额和抽奖汇总。 // lucky-gift-service 的所有后台接口都以 app_code 作为租户维度过滤,没有"全部应用"一次拉取的入口。 const appsPayload = await opsRequest("/apps", { query }); const apps = normalizeItems(appsPayload); const appCodes = uniqueAppCodes(apps); const perApp = await Promise.all( appCodes.map(async (appCode) => { const [luckyGifts, poolBalances, drawSummary, experiments] = await Promise.all([ fetchAppLuckyGiftConfigs(appCode), opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems), opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }), // 实验列表驱动配置行的实验徽章和发布禁用;该接口比面板后上线,请求失败时降级为空列表 // 而不是整页白屏——活跃实验期间的常规发布仍由 owner 以 409 兜底拒绝,降级是安全的。 fetchLuckyGiftExperiments(appCode).catch(() => []), ]); return { appCode, drawSummary, experiments, luckyGifts, poolBalances }; }), ); return normalizeDashboard({ apps, perApp }); } async function fetchAppLuckyGiftConfigs(appCode) { // 必须用复数 /configs:它返回该应用每个奖池的最新已发布规则版本。 // 单数 /config 只回默认奖池(无配置时是服务端草稿),会把 lalu 的 lucky/super_lucky 等已发布奖池整体漏掉。 const published = normalizeItems(await opsRequest("/lucky-gifts/configs", { query: { app_code: appCode } })) .map((config) => normalizeLuckyGiftConfig(config, appCode)) .filter((config) => config.app_code); if (published.length) { return published; } // 一个已发布配置都没有的应用仍要展示一行可编辑草稿,让运营从这里发布第一版; // 草稿的默认参数(RTP、回收率、结算窗口)以服务端 DefaultRuleConfig 为准,不在前端伪造。 const draft = normalizeLuckyGiftConfig( await opsRequest("/lucky-gifts/config", { query: { app_code: appCode } }), appCode, ); return draft.app_code ? [draft] : []; } export async function fetchLuckyGiftDraws({ appCode, page = 1, pageSize = 20, poolId = "", status = "", userId = "", } = {}) { const query = { app_code: appCode, page, page_size: pageSize, pool_id: poolId, status, }; // 同一个输入框同时支持内部数字 UID 和外部接入方的字符串 ID:纯数字走 user_id 精确匹配, // 其余走 external_user_id,避免运营要先分辨用户来源再选字段。 const trimmedUserID = String(userId || "").trim(); if (/^\d+$/.test(trimmedUserID)) { query.user_id = trimmedUserID; } else if (trimmedUserID) { query.external_user_id = trimmedUserID; } const payload = await opsRequest("/lucky-gifts/draws", { query }); return { items: normalizeItems(payload), page: numberValue(payload.page) || page, pageSize: numberValue(payload.pageSize ?? payload.page_size) || pageSize, total: numberValue(payload.total), }; } 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", { body: payload, method: "PUT", query: { app_code: payload.app_code, pool_id: payload.pool_id, }, }); } export async function adjustLuckyGiftPool({ adjustmentId, amountCoins, appCode, direction, poolId, reason, strategyVersion, } = {}) { if (direction !== "in" && direction !== "out") { throw new Error("奖池水位调整方向不正确"); } const operation = direction === "out" ? API_OPERATIONS.debitLuckyGiftPool : API_OPERATIONS.creditLuckyGiftPool; const operationPath = apiEndpointPath(operation); // adjustment_id 是 owner 侧资金变更的幂等键,同一个弹窗失败重试必须原样复用;request_id 只做链路追踪, // 不能替代业务幂等。方向映射到两条独立路由,使前后端权限也能按补水、扣水分别收敛。 const payload = await opsRequest(opsRelativePath(operationPath), { body: { adjustment_id: String(adjustmentId || "").trim(), amount_coins: numberValue(amountCoins), reason: String(reason || "").trim(), }, method: "POST", query: { app_code: String(appCode || "") .trim() .toLowerCase(), pool_id: String(poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID, strategy_version: normalizeStrategyVersion(strategyVersion), }, }); return normalizePoolAdjustmentResult(payload); } export async function fetchLuckyGiftExperiments(appCode, { poolId = "", status = "", withStats = false } = {}) { const normalizedAppCode = normalizeAppCodeValue(appCode); const payload = await opsRequest("/lucky-gifts/experiments", { query: { app_code: normalizedAppCode, pool_id: String(poolId || "").trim(), status: String(status || "").trim(), // with_stats=1 让 owner 按 control/treatment 聚合抽奖统计,代价较高; // dashboard 扇出保持轻量不带该参数,只有实验管理弹窗需要统计。 with_stats: withStats ? 1 : "", }, }); return normalizeItems(payload).map((item) => normalizeLuckyGiftExperiment(item, normalizedAppCode)); } export async function createLuckyGiftExperiment(appCode, request = {}) { const config = luckyGiftConfigPayload(request.config || {}); const normalizedAppCode = normalizeAppCodeValue(appCode || config.app_code); // 实验组配置由 owner 原子发布为新规则版本并立即参与开奖,enabled 必须为 true; // 表单层已强制启用,这里再收敛一次,防止其他调用方把停用草稿提交成实验组。 const payload = await opsRequest("/lucky-gifts/experiments", { body: { config: { ...config, enabled: true }, name: String(request.name || "").trim(), overrides: normalizeExperimentOverrides(request.overrides), treatment_traffic_percent: numberValue(request.treatment_traffic_percent), }, method: "POST", query: { app_code: normalizedAppCode }, }); return { ...payload, experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, normalizedAppCode), }; } export async function updateLuckyGiftExperiment(appCode, experimentId, patch = {}) { const body = {}; // PATCH 只回传明确要修改的字段:同时携带 status 和 treatment_traffic_percent 会让 // “暂停”误把放量重置,或“放量”误改实验状态。 if (patch.treatment_traffic_percent !== undefined && patch.treatment_traffic_percent !== null) { body.treatment_traffic_percent = numberValue(patch.treatment_traffic_percent); } if (patch.status) { body.status = String(patch.status); } const payload = await opsRequest(luckyGiftExperimentPath(experimentId), { body, method: "PATCH", query: { app_code: normalizeAppCodeValue(appCode) }, }); return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode); } export async function setLuckyGiftExperimentOverrides(appCode, experimentId, { removes, upserts } = {}) { const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/overrides`, { body: { removes: (Array.isArray(removes) ? removes : []) .map((userKey) => String(userKey ?? "").trim()) .filter(Boolean), upserts: normalizeExperimentOverrides(upserts), }, method: "PUT", query: { app_code: normalizeAppCodeValue(appCode) }, }); return normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode); } export async function endLuckyGiftExperiment(appCode, experimentId, winnerArm) { // 结束实验没有默认获胜方:获胜配置会被重新发布为最新版本并全量生效,误传空值的后果不可回滚。 if (winnerArm !== "control" && winnerArm !== "treatment") { throw new Error("结束实验必须选择获胜方"); } const payload = await opsRequest(`${luckyGiftExperimentPath(experimentId)}/end`, { body: { winner_arm: winnerArm }, method: "POST", query: { app_code: normalizeAppCodeValue(appCode) }, }); return { ...payload, experiment: normalizeLuckyGiftExperiment(payload?.experiment || {}, appCode), }; } export function normalizeLuckyGiftExperiment(experiment = {}, fallbackAppCode = "") { const armStats = experiment.arm_stats ?? experiment.armStats; return { ...experiment, app_code: normalizeAppCodeValue(experiment.app_code || experiment.appCode || fallbackAppCode), arm_stats: (Array.isArray(armStats) ? armStats : []).map((stat) => ({ actual_rtp_percent: numberValue(stat.actual_rtp_percent ?? stat.actualRtpPercent), arm: String(stat.arm || ""), rule_version: numberValue(stat.rule_version ?? stat.ruleVersion), strategy_version: normalizeStrategyVersion(stat.strategy_version ?? stat.strategyVersion), total_draws: numberValue(stat.total_draws ?? stat.totalDraws), total_reward_coins: numberValue(stat.total_reward_coins ?? stat.totalRewardCoins), total_spent_coins: numberValue(stat.total_spent_coins ?? stat.totalSpentCoins), unique_users: numberValue(stat.unique_users ?? stat.uniqueUsers), })), control_rule_version: numberValue(experiment.control_rule_version ?? experiment.controlRuleVersion), ended_at_ms: numberValue(experiment.ended_at_ms ?? experiment.endedAtMs), experiment_id: String(experiment.experiment_id ?? experiment.experimentId ?? ""), final_rule_version: numberValue(experiment.final_rule_version ?? experiment.finalRuleVersion), name: String(experiment.name || ""), overrides: (Array.isArray(experiment.overrides) ? experiment.overrides : []).map((item) => ({ // arm 只可能是两组之一;未知值按 treatment 处理会放大实验组,按 control 处理保持保守。 arm: item?.arm === "treatment" ? "treatment" : "control", user_key: String(item?.user_key ?? item?.userKey ?? ""), })), pool_id: String(experiment.pool_id || experiment.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID, started_at_ms: numberValue(experiment.started_at_ms ?? experiment.startedAtMs), status: String(experiment.status || "") .trim() .toLowerCase(), treatment_rule_version: numberValue(experiment.treatment_rule_version ?? experiment.treatmentRuleVersion), treatment_traffic_percent: numberValue( experiment.treatment_traffic_percent ?? experiment.treatmentTrafficPercent, ), updated_at_ms: numberValue(experiment.updated_at_ms ?? experiment.updatedAtMs), winner_arm: String(experiment.winner_arm ?? experiment.winnerArm ?? ""), winner_rule_version: numberValue(experiment.winner_rule_version ?? experiment.winnerRuleVersion), }; } function luckyGiftExperimentPath(experimentId) { const id = String(experimentId || "").trim(); if (!id) { throw new Error("缺少实验 ID"); } return `/lucky-gifts/experiments/${encodeURIComponent(id)}`; } function normalizeExperimentOverrides(overrides) { return (Array.isArray(overrides) ? overrides : []) .map((item) => ({ arm: item?.arm === "control" ? "control" : "treatment", user_key: String(item?.user_key ?? item?.userKey ?? "").trim(), })) .filter((item) => item.user_key); } export async function opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; // 复用主后台请求层才能共享 refreshOnce:运行期间 token 过期时先刷新再重放一次请求。 // 水位写接口携带稳定 adjustment_id,因此认证重放仍由 owner 幂等收敛;409 文案继续原样抛给弹窗保留表单。 return apiRequest(`/v1/admin/ops-center${normalizedPath}`, { body, method, query, }); } export function buildOpsUrl(path, query = {}) { const normalizedPath = path.startsWith("/") ? path : `/${path}`; const url = new URL(`${OPS_API_PREFIX}${normalizedPath}`, window.location.origin); Object.entries(query || {}).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== "") { url.searchParams.set(key, String(value)); } }); return url.toString(); } export function normalizeDashboard({ apps = [], perApp = [] } = {}) { const luckyGifts = perApp.flatMap((entry) => entry.luckyGifts || []); // 实验按 app+pool 独占;这里保持扁平列表,活跃实验与配置行的关联由展示层的 family key 完成。 const experiments = perApp.flatMap((entry) => entry.experiments || []); const normalizedPoolBalances = perApp .flatMap((entry) => (entry.poolBalances || []).map((item) => normalizePoolBalance(item, entry.appCode))) .filter((item) => item.app_code); const appSummaries = perApp .map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode)) .filter((summary) => summary.app_code); const publishedConfigs = luckyGifts.filter((config) => !config.is_default); const currentConfigByPool = new Map(); luckyGifts.forEach((config) => { const familyKey = luckyGiftPoolFamilyKey(config); const ruleVersion = numberValue(config.rule_version ?? config.ruleVersion); const current = currentConfigByPool.get(familyKey); // 正常接口只返回每个奖池的最新版本;这里仍按最大 rule_version 收敛,避免兼容接口返回多版本时 // 由数组顺序把旧策略误判为当前。版本相同则保留首条,确保结果不随返回顺序抖动。 if (!current || ruleVersion > current.ruleVersion) { currentConfigByPool.set(familyKey, { ruleVersion, strategyVersion: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion), }); } }); // 账本接口会同时返回同一 app+pool 的 V2/V3 独立水位;最新规则配置才决定哪口是当前奖池。 // 没有配置可关联的孤立账本继续按当前展示,避免前端误把仍需运营处理的资金静默藏进历史筛选。 const poolBalances = normalizedPoolBalances.map((pool) => { const currentStrategy = currentConfigByPool.get(luckyGiftPoolFamilyKey(pool))?.strategyVersion; return { ...pool, is_historical: Boolean(currentStrategy && currentStrategy !== pool.strategy_version), }; }); const currentPoolBalances = poolBalances.filter((pool) => !pool.is_historical); // KPI 一律跨应用聚合;奖池金额只汇总当前策略,避免历史 V2 与当前 V3 的独立账本重复计入运营水位。 const summary = { activeApps: apps.length, configuredPools: publishedConfigs.length, enabledPools: publishedConfigs.filter((config) => config.enabled).length, failedDraws: sumBy(appSummaries, "failed_draws"), grantedDraws: sumBy(appSummaries, "granted_draws"), pendingDraws: sumBy(appSummaries, "pending_draws"), poolAvailableTotal: sumBy(currentPoolBalances, "available_balance", "availableBalance"), poolBalanceTotal: sumBy(currentPoolBalances, "balance", "Balance"), poolReserveTotal: sumBy(currentPoolBalances, "reserve_floor", "reserveFloor"), totalDraws: sumBy(appSummaries, "total_draws"), totalRewardCoins: sumBy(appSummaries, "total_reward_coins"), totalSpentCoins: sumBy(appSummaries, "total_spent_coins"), }; return { apps, appSummaries, experiments, luckyGifts, poolBalances, summary }; } export function normalizePoolBalance(pool = {}, fallbackAppCode = "") { return { ...pool, app_code: String(pool.app_code || pool.appCode || fallbackAppCode) .trim() .toLowerCase(), available_balance: numberValue(pool.available_balance ?? pool.availableBalance), balance: numberValue(pool.balance ?? pool.Balance), manual_credit_total: numberValue(pool.manual_credit_total ?? pool.manualCreditTotal), manual_debit_total: numberValue(pool.manual_debit_total ?? pool.manualDebitTotal), materialized: Boolean(pool.materialized), pool_id: String(pool.pool_id || pool.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID, reserve_floor: numberValue(pool.reserve_floor ?? pool.reserveFloor), strategy_version: normalizeStrategyVersion(pool.strategy_version ?? pool.strategyVersion), total_in: numberValue(pool.total_in ?? pool.totalIn), total_out: numberValue(pool.total_out ?? pool.totalOut), updated_at_ms: numberValue(pool.updated_at_ms ?? pool.updatedAtMs), }; } export function luckyGiftPoolKey(item = {}) { const appCode = String(item.app_code || item.appCode || "") .trim() .toLowerCase(); const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; const strategyVersion = normalizeStrategyVersion(item.strategy_version ?? item.strategyVersion); // V2/V3 资金由 owner 按策略版本隔离;任何列表关联或 React rowKey 都必须包含 strategy_version, // 否则同一 app+pool 的两口奖池会在前端被覆盖成一行。 return `${appCode}:${poolID}:${strategyVersion}`; } function luckyGiftPoolFamilyKey(item = {}) { const appCode = String(item.app_code || item.appCode || "") .trim() .toLowerCase(); const poolID = String(item.pool_id || item.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; return `${appCode}:${poolID}`; } function normalizeDrawSummary(summary = {}, appCode = "") { return { actual_rtp_ppm: numberValue(summary.actual_rtp_ppm ?? summary.actualRtpPpm), app_code: String(appCode || summary.app_code || "") .trim() .toLowerCase(), failed_draws: numberValue(summary.failed_draws ?? summary.failedDraws), granted_draws: numberValue(summary.granted_draws ?? summary.grantedDraws), pending_draws: numberValue(summary.pending_draws ?? summary.pendingDraws), total_draws: numberValue(summary.total_draws ?? summary.totalDraws), total_reward_coins: numberValue(summary.total_reward_coins ?? summary.totalRewardCoins), total_spent_coins: numberValue(summary.total_spent_coins ?? summary.totalSpentCoins), unique_rooms: numberValue(summary.unique_rooms ?? summary.uniqueRooms), unique_users: numberValue(summary.unique_users ?? summary.uniqueUsers), }; } function uniqueAppCodes(apps) { const seen = new Set(); const codes = []; apps.forEach((item) => { const code = String(item?.app_code || item?.appCode || "") .trim() .toLowerCase(); if (!code || seen.has(code)) { return; } seen.add(code); codes.push(code); }); return codes; } function normalizeLuckyGiftConfig(config = {}, fallbackAppCode = "") { const appCode = String(config.app_code || config.appCode || 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, // rule_version <= 0 表示服务端返回的未发布草稿:列表要标成"默认草稿"并禁止当作已生效规则解读。 is_default: ruleVersion <= 0, pool_id: config.pool_id || config.poolId || DEFAULT_POOL_ID, 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) } : {}), }; } function normalizePoolAdjustmentResult(payload = {}) { const adjustment = payload.adjustment || {}; return { ...payload, adjustment: { ...adjustment, amount_coins: numberValue(adjustment.amount_coins ?? adjustment.amountCoins), balance_after: numberValue(adjustment.balance_after ?? adjustment.balanceAfter), balance_before: numberValue(adjustment.balance_before ?? adjustment.balanceBefore), strategy_version: normalizeStrategyVersion(adjustment.strategy_version ?? adjustment.strategyVersion), }, pool: normalizePoolBalance(payload.pool || {}), }; } 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)) { throw new Error(`运营接口不属于 ops-center: ${operationPath}`); } return operationPath.slice(prefix.length); } function normalizeStrategyVersion(value) { return String(value || "fixed_v2") .trim() .toLowerCase(); } function normalizeAppCodeValue(value) { return String(value || "") .trim() .toLowerCase(); } function luckyGiftConfigPayload(config = {}) { const appCode = String(config.app_code || config.appCode || "") .trim() .toLowerCase(); const poolID = String(config.pool_id || config.poolId || DEFAULT_POOL_ID).trim() || DEFAULT_POOL_ID; 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 因资金、水位、大奖字段归零而拒绝发布。 return { anchor_daily_payout_cap: numberValue(config.anchor_daily_payout_cap ?? config.anchorDailyPayoutCap), anchor_rate_percent: numberValue(config.anchor_rate_percent ?? config.anchorRatePercent), app_code: appCode, control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent), device_daily_payout_cap: numberValue(config.device_daily_payout_cap ?? config.deviceDailyPayoutCap), effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMs ?? config.effectiveFromMS), enabled: Boolean(config.enabled), gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference), high_water_nonzero_factor_percent: numberValue( config.high_water_nonzero_factor_percent ?? config.highWaterNonzeroFactorPercent, ), high_watermark_coins: numberValue(config.high_watermark_coins ?? config.highWatermarkCoins), // dynamic_v3 已改为通过带审计的 pool credit 接口注资;即使旧表单快照仍带 1m,也不能在发布新版本时继续隐式 seed。 initial_pool_coins: strategyVersion === "dynamic_v3" ? 0 : numberValue(config.initial_pool_coins ?? config.initialPoolCoins), jackpot_global_rtp_max_percent: numberValue( config.jackpot_global_rtp_max_percent ?? config.jackpotGlobalRTPMaxPercent, ), jackpot_multipliers: numberArray(config.jackpot_multipliers ?? config.jackpotMultipliers), jackpot_spend_threshold_coins: numberValue( config.jackpot_spend_threshold_coins ?? config.jackpotSpendThresholdCoins, ), jackpot_user_48h_rtp_max_percent: numberValue( config.jackpot_user_48h_rtp_max_percent ?? config.jackpotUser48hRTPMaxPercent ?? config.jackpotUser48hRtpMaxPercent ?? config.jackpot_user_72h_rtp_max_percent ?? config.jackpotUser72hRTPMaxPercent ?? config.jackpotUser72hRtpMaxPercent, ), jackpot_user_round_rtp_max_percent: numberValue( config.jackpot_user_round_rtp_max_percent ?? config.jackpotUserRoundRTPMaxPercent ?? config.jackpotUserRoundRtpMaxPercent ?? config.jackpot_user_day_rtp_max_percent ?? config.jackpotUserDayRTPMaxPercent ?? config.jackpotUserDayRtpMaxPercent, ), loss_streak_guarantee: numberValue(config.loss_streak_guarantee ?? config.lossStreakGuarantee), low_water_nonzero_factor_percent: numberValue( config.low_water_nonzero_factor_percent ?? config.lowWaterNonzeroFactorPercent, ), low_watermark_coins: numberValue(config.low_watermark_coins ?? config.lowWatermarkCoins), max_jackpot_hits_per_user_day: numberValue( config.max_jackpot_hits_per_user_day ?? config.maxJackpotHitsPerUserDay, ), max_single_payout: numberValue(config.max_single_payout ?? config.maxSinglePayout), normal_max_equivalent_draws: numberValue(config.normal_max_equivalent_draws ?? config.normalMaxEquivalentDraws), novice_max_equivalent_draws: numberValue(config.novice_max_equivalent_draws ?? config.noviceMaxEquivalentDraws), pool_id: poolID, pool_rate_percent: numberValue(config.pool_rate_percent ?? config.poolRatePercent), profit_rate_percent: numberValue(config.profit_rate_percent ?? config.profitRatePercent), recharge_boost_factor_percent: numberValue( config.recharge_boost_factor_percent ?? config.rechargeBoostFactorPercent, ), recharge_boost_window_ms: numberValue( config.recharge_boost_window_ms ?? config.rechargeBoostWindowMs ?? config.rechargeBoostWindowMS, ), room_hourly_payout_cap: numberValue(config.room_hourly_payout_cap ?? config.roomHourlyPayoutCap), 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), // V2 高水位加权是 fixed_v2 专属;dynamic_v3 有独立水位因子,owner 会拒绝混填,这里直接归零。 v2_high_water_boost_threshold_coins: strategyVersion === "dynamic_v3" ? 0 : numberValue(config.v2_high_water_boost_threshold_coins ?? config.v2HighWaterBoostThresholdCoins), v2_high_water_boost_factor_percent: strategyVersion === "dynamic_v3" ? 0 : numberValue(config.v2_high_water_boost_factor_percent ?? config.v2HighWaterBoostFactorPercent), v2_high_water_boost_recover_coins: strategyVersion === "dynamic_v3" ? 0 : numberValue(config.v2_high_water_boost_recover_coins ?? config.v2HighWaterBoostRecoverCoins), target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent), }; } function normalizeItems(value) { if (Array.isArray(value)) { return value; } if (Array.isArray(value?.items)) { return value.items; } if (Array.isArray(value?.list)) { return value.list; } return []; } function sumBy(items, key, fallbackKey) { return items.reduce( (total, item) => total + numberValue(item?.[key] ?? (fallbackKey ? item?.[fallbackKey] : 0)), 0, ); } function numberValue(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; } function numberArray(values) { if (!Array.isArray(values)) { return []; } // 不在传输层静默删除 0/负倍率;表单负责拦截,若有遗漏也应让 owner 返回明确校验错误, // 否则数组位置变化会掩盖运营实际提交的错误值。 return values.map(numberValue); } function normalizeStages(stages) { if (!Array.isArray(stages)) { return []; } return stages.map((stage) => ({ min_recharge_30d_coins: numberValue(stage.min_recharge_30d_coins ?? stage.minRecharge30DCoins), min_recharge_7d_coins: numberValue(stage.min_recharge_7d_coins ?? stage.minRecharge7DCoins), stage: stage.stage || stage.Stage || "", tiers: Array.isArray(stage.tiers) ? stage.tiers.map((tier) => ({ enabled: tier.enabled !== false, high_water_only: Boolean(tier.high_water_only ?? tier.highWaterOnly), multiplier: numberValue(tier.multiplier), probability_percent: numberValue(tier.probability_percent ?? tier.probabilityPercent), tier_id: tier.tier_id || tier.tierId || "", })) : [], })); }