zhx 59fe249886 ops-center 幸运礼物 V2 高水位加权配置项
返奖与资金分配区块新增 fixed_v2 专属三字段:加权阈值/系数/恢复水位,
含帮助文案与表单校验;dynamic_v3 序列化强制归零,owner 拒绝混填。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 11:24:37 +08:00

1092 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_versionlucky-gift owner 明确按 fixed_v2 解释;列表和编辑表单必须沿用该兼容语义。
strategy_version: normalizeStrategyVersion(config.strategy_version ?? config.strategyVersion),
// 仅在 owner 真正返回新字段时补 canonical key缺字段代表历史禁用快照不能在读取阶段伪装成已启用。
...(user24hRTPThreshold !== undefined
? { user_24h_rtp_threshold_percent: numberValue(user24hRTPThreshold) }
: {}),
...(user24hOrdinaryWinFactor !== undefined
? { user_24h_ordinary_win_factor_percent: numberValue(user24hOrdinaryWinFactor) }
: {}),
};
}
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 || "",
}))
: [],
}));
}