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 || "" })) : [] })); }