2026-07-10 14:31:56 +08:00

265 lines
10 KiB
JavaScript
Raw Permalink 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 { 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 || ""
}))
: []
}));
}