265 lines
10 KiB
JavaScript
265 lines
10 KiB
JavaScript
import { getAccessToken } 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 opsRequest(path, { body, method = body ? "POST" : "GET", query } = {}) {
|
||
const response = await fetch(buildOpsUrl(path, query), {
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
credentials: "include",
|
||
headers: buildHeaders(body),
|
||
method
|
||
});
|
||
const payload = await readPayload(response);
|
||
|
||
if (!response.ok || payload.code !== 0) {
|
||
throw new Error(payload.message || response.statusText || "运营接口请求失败");
|
||
}
|
||
|
||
return payload.data ?? {};
|
||
}
|
||
|
||
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 poolBalances = perApp
|
||
.flatMap((entry) => entry.poolBalances || [])
|
||
.filter((item) => item?.app_code || item?.appCode);
|
||
const appSummaries = perApp
|
||
.map((entry) => normalizeDrawSummary(entry.drawSummary, entry.appCode))
|
||
.filter((summary) => summary.app_code);
|
||
|
||
const publishedConfigs = luckyGifts.filter((config) => !config.is_default);
|
||
|
||
// KPI 一律跨应用聚合。旧版只取第一个应用的汇总,导致 lalu 有大量抽奖时首屏"抽奖次数"仍显示 0。
|
||
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(poolBalances, "available_balance", "availableBalance"),
|
||
poolBalanceTotal: sumBy(poolBalances, "balance", "Balance"),
|
||
poolReserveTotal: sumBy(poolBalances, "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 };
|
||
}
|
||
|
||
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
|
||
};
|
||
}
|
||
|
||
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;
|
||
return {
|
||
app_code: appCode,
|
||
control_band_percent: numberValue(config.control_band_percent ?? config.controlBandPercent),
|
||
effective_from_ms: numberValue(config.effective_from_ms ?? config.effectiveFromMS),
|
||
enabled: Boolean(config.enabled),
|
||
gift_price_reference: numberValue(config.gift_price_reference ?? config.giftPriceReference),
|
||
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),
|
||
settlement_window_wager: numberValue(config.settlement_window_wager ?? config.settlementWindowWager),
|
||
stages: normalizeStages(config.stages),
|
||
target_rtp_percent: numberValue(config.target_rtp_percent ?? config.targetRtpPercent)
|
||
};
|
||
}
|
||
|
||
function buildHeaders(body) {
|
||
const headers = {};
|
||
const token = getAccessToken();
|
||
|
||
if (body !== undefined) {
|
||
headers["Content-Type"] = "application/json";
|
||
}
|
||
if (token) {
|
||
headers.Authorization = `Bearer ${token}`;
|
||
}
|
||
|
||
return headers;
|
||
}
|
||
|
||
async function readPayload(response) {
|
||
const text = await response.text();
|
||
if (!text) {
|
||
return { code: response.ok ? 0 : response.status, data: {} };
|
||
}
|
||
try {
|
||
return JSON.parse(text);
|
||
} catch {
|
||
return { code: response.ok ? 0 : response.status, data: text, message: text };
|
||
}
|
||
}
|
||
|
||
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 normalizeStages(stages) {
|
||
if (!Array.isArray(stages)) {
|
||
return [];
|
||
}
|
||
return stages.map((stage) => ({
|
||
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 || ""
|
||
}))
|
||
: []
|
||
}));
|
||
}
|