455 lines
21 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";
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] = await Promise.all([
fetchAppLuckyGiftConfigs(appCode),
opsRequest("/lucky-gifts/pools", { query: { app_code: appCode } }).then(normalizeItems),
opsRequest("/lucky-gifts/summary", { query: { app_code: appCode } }),
]);
return { appCode, drawSummary, 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 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 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 || []);
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, 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);
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),
};
}
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 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 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();
// 规则版本是不可变快照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,
user_daily_payout_cap: numberValue(config.user_daily_payout_cap ?? config.userDailyPayoutCap),
user_hourly_payout_cap: numberValue(config.user_hourly_payout_cap ?? config.userHourlyPayoutCap),
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent),
};
}
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 || "",
}))
: [],
}));
}