返奖与资金分配区块新增 fixed_v2 专属三字段:加权阈值/系数/恢复水位, 含帮助文案与表单校验;dynamic_v3 序列化强制归零,owner 拒绝混填。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1092 lines
51 KiB
JavaScript
1092 lines
51 KiB
JavaScript
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 || "",
|
||
}))
|
||
: [],
|
||
}));
|
||
}
|