diff --git a/.env.example b/.env.example index fb81105..89fd9e0 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ LOTTERY_API_UPSTREAM=http://127.0.0.1:8000 # 线上(Laravel 在本机时用内网地址更快) # LOTTERY_API_UPSTREAM=http://127.0.0.1:8000 -# LOTTERY_API_UPSTREAM=https://lotterylaravel.tanumo.com +# LOTTERY_API_UPSTREAM=https://lottery-api.cjdhr.top # Next 开发:局域网 IP 访问(逗号分隔 host,无协议) # ALLOWED_DEV_ORIGINS=192.168.0.101 diff --git a/AGENTS.md b/AGENTS.md index a6d784c..60ab847 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ This version has breaking changes — APIs, conventions, and file structure may - 客户对外文档:`/docs`、`/docs/integration`、`/docs/admin` 公开免登录;读者为接入方与站点运营/代理;顶栏「管理后台」链 `/admin`;SSO 无登录换票、JWT+`player/me`、iframe `data.token`;Docs 侧栏 `--docs-sticky-top`;静态校验用 `document.body.innerText`。 - `SettlementBillRow` 无 `currency_code`;账单金额展示用玩家 `default_currency`。 - 浏览器 `/api/v1/*` 由 Next 转发到 `LOTTERY_API_UPSTREAM`;与本地 Postgres 对账前须确认 upstream 与所查库一致。 -- Tanumo 联调/生产默认:H5 `https://front.tanumo.com`、API `https://lotterylaravel.tanumo.com`、管理端/文档 `https://lotteryadmin.tanumo.com`。 +- 当前联调/生产默认:H5 `https://lottery-front.cjdhr.top`、API `https://lottery-api.cjdhr.top`、管理端/文档 `https://lottery-admin.cjdhr.top`。 - 风控页默认:风险池仅显示有占用/高风险;单号详情可看占用来源;组合明细在注单详情页。 - 模块边界:对账中心=主站↔彩票钱包划转;报表中心=钱包盘经营分析;结算中心=信用账期占成/收付。 - 报表中心:默认近 30 天;盈亏类按 `draws.business_date`;金额读 API `currency_code`;导出仅后端 `report-jobs` 全量(按筛选,无需先预览);注单盈亏≠信用账期结算。 diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index a3d70a6..dfa211b 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -14,7 +14,7 @@ const path = require("path"); -/** 改成服务器上的绝对路径,例如 /www/wwwroot/lotteryadmin.tanumo.com */ +/** 改成服务器上的绝对路径,例如 /www/wwwroot/lottery-admin.cjdhr.top */ const APP_CWD = path.resolve(__dirname); module.exports = { diff --git a/src/api/admin-config.ts b/src/api/admin-config.ts index 5407f8c..14c66a2 100644 --- a/src/api/admin-config.ts +++ b/src/api/admin-config.ts @@ -107,8 +107,10 @@ export async function postOddsVersion(body?: { export async function putOddsItems( id: number, items: Array<{ + provider_code?: string; play_code: string; prize_scope: string; + dimension?: number | null; odds_value: number; rebate_rate?: number; commission_rate?: number; diff --git a/src/i18n/locales/en/config.json b/src/i18n/locales/en/config.json index 9050896..111096c 100644 --- a/src/i18n/locales/en/config.json +++ b/src/i18n/locales/en/config.json @@ -245,6 +245,11 @@ "readOnlyHint": "This version is read-only. Create a draft before editing odds.", "rebateRate": "Base rebate rate (%)", "rebateRateHint": "This is the platform base rebate. It writes rebate_rate to all prize scopes under this play type; agent/player rebate is added on top of it.", + "scopeSource": { + "global": "Global", + "provider": "Provider", + "inherited": "Inherited" + }, "rollbackDialog": { "confirm": "Confirm rollback", "description": "A new draft will be cloned from version v{{version}}. The active version will not be overwritten directly.", diff --git a/src/i18n/locales/en/tickets.json b/src/i18n/locales/en/tickets.json index 131ba55..92bb4ec 100644 --- a/src/i18n/locales/en/tickets.json +++ b/src/i18n/locales/en/tickets.json @@ -54,6 +54,13 @@ "backToList": "Back to ticket list", "viewTicketDetail": "View ticket detail", "combinationCount": "Expanded combinations", + "oddsSnapshotTitle": "Odds snapshot", + "oddsSnapshotHint": "Odds rows locked when the ticket was placed. Provider-specific tiers show that provider; inherited tiers show GLOBAL.", + "oddsSnapshotEmpty": "No odds snapshot", + "prizeScope": "Prize tier", + "oddsValue": "Odds", + "rebateRate": "Rebate", + "commissionRate": "Commission", "combinationsTitle": "Combination breakdown", "combinationsHint": "One bet line may expand into multiple 4D numbers for risk and settlement.", "number4d": "4D number", diff --git a/src/i18n/locales/ne/config.json b/src/i18n/locales/ne/config.json index 0b48fc2..fabb0ba 100644 --- a/src/i18n/locales/ne/config.json +++ b/src/i18n/locales/ne/config.json @@ -245,6 +245,11 @@ "readOnlyHint": "यो संस्करण केवल पढ्न मिल्ने छ। अड्स सम्पादन गर्नुअघि ड्राफ्ट बनाउनुहोस्।", "rebateRate": "रिबेट दर (%)", "rebateRateHint": "यसले यो खेल प्रकारअन्तर्गत सबै prize scope मा rebate_rate लेख्छ।", + "scopeSource": { + "global": "Global", + "provider": "Provider", + "inherited": "Inherited" + }, "rollbackDialog": { "confirm": "रोलब्याक पुष्टि गर्नुहोस्", "description": "संस्करण v{{version}} बाट नयाँ ड्राफ्ट क्लोन हुनेछ। हाल सक्रिय संस्करण प्रत्यक्ष रूपमा ओभरराइट हुँदैन।", diff --git a/src/i18n/locales/ne/tickets.json b/src/i18n/locales/ne/tickets.json index 2c0eb71..194f0a9 100644 --- a/src/i18n/locales/ne/tickets.json +++ b/src/i18n/locales/ne/tickets.json @@ -9,6 +9,7 @@ "combinationsTitle": "संयोजन विवरण", "comboBetAmount": "संयोजन बेट", "comboEstimatedPayout": "अनुमानित भुक्तानी रिजर्भ", + "commissionRate": "Commission", "detailLoadFailed": "टिकट विवरण लोड असफल", "detailTitle": "टिकट विवरण", "drawNo": "ड्रअ नं.", @@ -24,15 +25,21 @@ "number4d": "4D नम्बर", "numberKeyword": "नम्बर / टिकट / अर्डर", "numberKeywordPlaceholder": "नम्बर, टिकट नं. वा अर्डर नं. बाट खोज्नुहोस्", + "oddsSnapshotEmpty": "Odds snapshot छैन", + "oddsSnapshotHint": "टिकट राख्दा लक भएको odds पंक्ति। Provider-specific tier मा provider देखिन्छ; inherited tier मा GLOBAL देखिन्छ।", + "oddsSnapshotTitle": "Odds snapshot", + "oddsValue": "Odds", "orderNo": "अर्डर नं.", "placedAt": "बेट समय", "placedDateRange": "बेट गरिएको मिति दायरा", "playCode": "प्ले", "player": "खेलाडी", + "prizeScope": "Prize tier", "playerId": "खेलाडी ID / खाता", "playerIdPlaceholder": "सबै देखाउन खाली छोड्नुहोस्; ID वा खाता लेख्नुहोस्", "playerTicketQuery": "टिकट खोज", "query": "खोज", + "rebateRate": "Rebate", "refreshCurrentPage": "हालको पृष्ठ रिफ्रेस", "resetFilters": "फिल्टर रिसेट", "status": "स्थिति", diff --git a/src/i18n/locales/zh/config.json b/src/i18n/locales/zh/config.json index bba28d5..bc9b559 100644 --- a/src/i18n/locales/zh/config.json +++ b/src/i18n/locales/zh/config.json @@ -542,6 +542,11 @@ "missingScopeRow": "缺少 {{scope}} 对应行,请检查种子或版本数据。", "rebateRate": "基础回水 (%)", "rebateRateHint": "输入基础回水比例,如 5 表示 5%。", + "scopeSource": { + "global": "通用", + "provider": "专属", + "inherited": "继承通用" + }, "placeholders": { "multiplier": "请输入赔率倍数", "rebateRate": "请输入基础回水比例" diff --git a/src/i18n/locales/zh/tickets.json b/src/i18n/locales/zh/tickets.json index 49b0a92..2588531 100644 --- a/src/i18n/locales/zh/tickets.json +++ b/src/i18n/locales/zh/tickets.json @@ -54,6 +54,13 @@ "backToList": "返回注单列表", "viewTicketDetail": "查看注单详情", "combinationCount": "展开组合数", + "oddsSnapshotTitle": "赔率快照", + "oddsSnapshotHint": "下注成功时锁定的赔率行;开注商专属档位显示对应开注商,未单独配置的档位显示 GLOBAL。", + "oddsSnapshotEmpty": "暂无赔率快照", + "prizeScope": "奖项档位", + "oddsValue": "赔率", + "rebateRate": "回水", + "commissionRate": "佣金", "combinationsTitle": "展开组合明细", "combinationsHint": "系统按玩法规则将一注展开为多个 4D 号码;此处为风控与结算用的组合列表。", "number4d": "4D 号码", diff --git a/src/modules/config/doc/odds-config-dirty.ts b/src/modules/config/doc/odds-config-dirty.ts index 6081064..b4e7d1a 100644 --- a/src/modules/config/doc/odds-config-dirty.ts +++ b/src/modules/config/doc/odds-config-dirty.ts @@ -2,6 +2,7 @@ import type { OddsItemRow } from "@/types/api/admin-config"; function oddsItemFingerprint(row: OddsItemRow): string { return [ + row.provider_code ?? "GLOBAL", row.play_code, row.prize_scope, row.odds_value, @@ -17,10 +18,10 @@ export function oddsDraftIsDirty(draftRows: OddsItemRow[], savedRows: OddsItemRo return true; } - const saved = new Map(savedRows.map((row) => [`${row.play_code}|${row.prize_scope}`, row])); + const saved = new Map(savedRows.map((row) => [`${row.provider_code ?? "GLOBAL"}|${row.play_code}|${row.prize_scope}`, row])); for (const draft of draftRows) { - const key = `${draft.play_code}|${draft.prize_scope}`; + const key = `${draft.provider_code ?? "GLOBAL"}|${draft.play_code}|${draft.prize_scope}`; const baseline = saved.get(key); if (!baseline) { return true; diff --git a/src/modules/config/doc/odds-config-doc-screen.tsx b/src/modules/config/doc/odds-config-doc-screen.tsx index ba7da73..c561908 100644 --- a/src/modules/config/doc/odds-config-doc-screen.tsx +++ b/src/modules/config/doc/odds-config-doc-screen.tsx @@ -14,6 +14,7 @@ import { publishOddsVersion, putOddsItems, } from "@/api/admin-config"; +import { getAdminBetProviders } from "@/api/admin-bet-providers"; import { Button } from "@/components/ui/button"; import { ConfigChip, ConfigChipGroup } from "@/modules/config/config-chip-group"; import { ConfigDocPage, ConfigDocToolbar } from "@/modules/config/config-doc-page"; @@ -50,6 +51,7 @@ import type { OddsItemRow, OddsVersionDetail, } from "@/types/api/admin-config"; +import type { AdminBetProviderRow } from "@/types/api/admin-bet-provider"; import { OddsConfigDraftBar } from "@/modules/config/doc/odds-config-draft-bar"; import { OddsConfigPlayNav } from "@/modules/config/doc/odds-config-play-nav"; @@ -74,6 +76,7 @@ import { } from "@/modules/config/doc/prize-scopes"; type CatTab = OddsCategoryTab; +const GLOBAL_PROVIDER_CODE = "GLOBAL"; function oddsMultiplierLabel(oddsValue: number): string { return (oddsValue / 10000).toFixed(4); @@ -112,6 +115,7 @@ export function OddsConfigDocScreen({ const profile = useAdminProfile(); const canManage = adminHasAnyPermission(profile?.permissions, [PRD_ODDS_MANAGE, PRD_REBATE_MANAGE]); const [types, setTypes] = useState([]); + const [betProviders, setBetProviders] = useState([]); const [list, setList] = useState([]); const [internalSelectedId, setInternalSelectedId] = useState(""); const selectedId = workspace?.selectedId ?? controlledVersionId ?? internalSelectedId; @@ -135,6 +139,7 @@ export function OddsConfigDocScreen({ const resolvedError = workspace?.error ?? error; const [catTab, setCatTab] = useState("all"); + const [providerCode, setProviderCode] = useState(GLOBAL_PROVIDER_CODE); /** User-selected play type. Empty means none selected yet and falls back to the first item in the category. */ const [playCode, setPlayCode] = useState(""); @@ -157,6 +162,15 @@ export function OddsConfigDocScreen({ } }, [tRef]); + const refreshBetProviders = useCallback(async () => { + try { + const data = await getAdminBetProviders(); + setBetProviders(data.items); + } catch { + setBetProviders([]); + } + }, []); + const refreshList = useCallback(async () => { setLoadingList(true); setError(null); @@ -177,8 +191,15 @@ export function OddsConfigDocScreen({ if (workspace) { return; } - void Promise.all([refreshTypes(), refreshList()]); - }, [workspace]); + void Promise.all([refreshTypes(), refreshList(), refreshBetProviders()]); + }, [refreshBetProviders, workspace]); + + useAsyncEffect(() => { + if (!workspace) { + return; + } + void refreshBetProviders(); + }, [refreshBetProviders, workspace]); const loadDetail = useCallback(async (id: number) => { setLoadingDetail(true); @@ -284,13 +305,32 @@ export function OddsConfigDocScreen({ if (row.play_code !== resolvedPlayCode) { continue; } + const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE; + if (rowProviderCode !== providerCode) { + continue; + } const scope = row.prize_scope as PrizeScopeCode; if (PRIZE_SCOPE_ORDER.includes(scope)) { rows[scope] = row; } } + if (providerCode !== GLOBAL_PROVIDER_CODE) { + for (const row of resolvedDraftRows) { + if (row.play_code !== resolvedPlayCode) { + continue; + } + const rowProviderCode = row.provider_code ?? GLOBAL_PROVIDER_CODE; + if (rowProviderCode !== GLOBAL_PROVIDER_CODE) { + continue; + } + const scope = row.prize_scope as PrizeScopeCode; + if (PRIZE_SCOPE_ORDER.includes(scope) && !rows[scope]) { + rows[scope] = { ...row, id: 0, provider_code: providerCode }; + } + } + } return rows; - }, [resolvedDraftRows, resolvedPlayCode]); + }, [providerCode, resolvedDraftRows, resolvedPlayCode]); const rebatePercentUi = useMemo(() => { const first = PRIZE_SCOPE_ORDER.map((s) => scopeRows[s]).find(Boolean); @@ -300,8 +340,13 @@ export function OddsConfigDocScreen({ return ratioToPercentUi(String(first.rebate_rate)); }, [scopeRows]); - function rowIndex(play_code: string, prize_scope: string): number { - return resolvedDraftRows.findIndex((r) => r.play_code === play_code && r.prize_scope === prize_scope); + function rowIndex(play_code: string, prize_scope: string, nextProviderCode = providerCode): number { + return resolvedDraftRows.findIndex( + (r) => + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === nextProviderCode && + r.play_code === play_code && + r.prize_scope === prize_scope, + ); } function updateOddsRow(idx: number, patch: Partial) { @@ -312,35 +357,73 @@ export function OddsConfigDocScreen({ const idx = rowIndex(resolvedPlayCode, scope); if (idx >= 0) { updateOddsRow(idx, patch); + return; } + + const globalIdx = rowIndex(resolvedPlayCode, scope, GLOBAL_PROVIDER_CODE); + const globalRow = globalIdx >= 0 ? resolvedDraftRows[globalIdx] : null; + if (!globalRow) { + return; + } + setResolvedDraftRows((prev) => [ + ...prev, + { ...globalRow, id: 0, provider_code: providerCode, ...patch }, + ]); } function setRebateForPlayPercent(percentStr: string) { const p = Number.parseFloat(percentStr); const rate = Number.isFinite(p) ? p / 100 : 0; setResolvedDraftRows((prev) => - prev.map((r) => - r.play_code === resolvedPlayCode ? { ...r, rebate_rate: String(rate) } : r, - ), + { + let rows = prev.map((r) => + r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode + ? { ...r, rebate_rate: String(rate) } + : r, + ); + + if (providerCode !== GLOBAL_PROVIDER_CODE) { + const hasProviderRows = rows.some( + (r) => r.play_code === resolvedPlayCode && (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode, + ); + if (!hasProviderRows) { + const copies = rows + .filter( + (r) => + r.play_code === resolvedPlayCode && + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE, + ) + .map((r) => ({ ...r, id: 0, provider_code: providerCode, rebate_rate: String(rate) })); + rows = [...rows, ...copies]; + } + } + + return rows; + }, ); } + function buildOddsItemsPayload(rows: OddsItemRow[]) { + return rows.map((r) => ({ + play_code: r.play_code, + provider_code: r.provider_code ?? GLOBAL_PROVIDER_CODE, + prize_scope: r.prize_scope, + dimension: r.dimension ?? null, + odds_value: r.odds_value, + rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0, + commission_rate: Number.parseFloat(String(r.commission_rate)) || 0, + currency_code: r.currency_code, + extra_config_json: r.extra_config_json, + })); + } + async function handleSave() { if (!resolvedDetail || !canEditDraft) { return; } setSaving(true); try { - const payload = resolvedDraftRows.map((r) => ({ - play_code: r.play_code, - prize_scope: r.prize_scope, - odds_value: r.odds_value, - rebate_rate: Number.parseFloat(String(r.rebate_rate)) || 0, - commission_rate: Number.parseFloat(String(r.commission_rate)) || 0, - currency_code: r.currency_code, - extra_config_json: r.extra_config_json, - })); - const d = await putOddsItems(resolvedDetail.id, payload); + const d = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows)); if (workspace) { workspace.applyDetail(d); } else { @@ -362,7 +445,8 @@ export function OddsConfigDocScreen({ } setSaving(true); try { - const d = await publishOddsVersion(resolvedDetail.id); + const saved = await putOddsItems(resolvedDetail.id, buildOddsItemsPayload(resolvedDraftRows)); + const d = await publishOddsVersion(saved.id); if (workspace) { workspace.applyDetail(d); } else { @@ -479,8 +563,28 @@ export function OddsConfigDocScreen({ const selectedPlay = resolvedPlayCode; return PRIZE_SCOPE_ORDER.map((scope) => { - const next = resolvedDraftRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope); - const old = activeCompareRows.find((r) => r.play_code === selectedPlay && r.prize_scope === scope); + const next = resolvedDraftRows.find( + (r) => + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode && + r.play_code === selectedPlay && + r.prize_scope === scope, + ) ?? resolvedDraftRows.find( + (r) => + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE && + r.play_code === selectedPlay && + r.prize_scope === scope, + ); + const old = activeCompareRows.find( + (r) => + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === providerCode && + r.play_code === selectedPlay && + r.prize_scope === scope, + ) ?? activeCompareRows.find( + (r) => + (r.provider_code ?? GLOBAL_PROVIDER_CODE) === GLOBAL_PROVIDER_CODE && + r.play_code === selectedPlay && + r.prize_scope === scope, + ); return { scope, label: prizeScopeLabel(scope, t), @@ -488,7 +592,7 @@ export function OddsConfigDocScreen({ newValue: next?.odds_value ?? null, }; }); - }, [activeCompareRows, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]); + }, [activeCompareRows, providerCode, resolvedDetail, resolvedDraftRows, resolvedPlayCode, t]); const catTabs: { id: CatTab; label: string }[] = [ { id: "all", label: t("odds.tabs.all", { ns: "config" }) }, @@ -500,9 +604,29 @@ export function OddsConfigDocScreen({ const activePlayLabel = resolvedPlayCode ? resolveAdminPlayTypeDisplayName(resolvedPlayCode, i18n.language, sortedTypes.find((t) => t.play_code === resolvedPlayCode)) : "—"; + const activeProviderLabel = providerCode === GLOBAL_PROVIDER_CODE + ? t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" }) + : (betProviders.find((provider) => provider.code === providerCode)?.name ?? providerCode); const filtersInner = ( <> + + setProviderCode(GLOBAL_PROVIDER_CODE)} + > + {t("odds.globalProvider", { ns: "config", defaultValue: "通用赔率" })} + + {betProviders.map((provider) => ( + setProviderCode(provider.code)} + > + {provider.name} + + ))} + {catTabs.map((tab) => ( { const row = scopeRows[scope]; const hint = mergedLayout ? null : PRIZE_SCOPE_MULTIPLIER_HINT[scope]; - const idx = row ? rowIndex(resolvedPlayCode, scope) : -1; - return { scope, row, hint, idx }; + const hasProviderRow = providerCode === GLOBAL_PROVIDER_CODE || rowIndex(resolvedPlayCode, scope) >= 0; + const sourceLabel = providerCode === GLOBAL_PROVIDER_CODE + ? t("odds.scopeSource.global", { ns: "config" }) + : hasProviderRow + ? t("odds.scopeSource.provider", { ns: "config" }) + : t("odds.scopeSource.inherited", { ns: "config" }); + const sourceClassName = providerCode === GLOBAL_PROVIDER_CODE + ? "border-slate-200 bg-slate-50 text-slate-600" + : hasProviderRow + ? "border-emerald-200 bg-emerald-50 text-emerald-700" + : "border-amber-200 bg-amber-50 text-amber-700"; + return { scope, row, hint, sourceLabel, sourceClassName }; }); const mergedOddsTable = ( @@ -621,14 +755,21 @@ export function OddsConfigDocScreen({ - {scopeEditorRows.map(({ scope, row, hint, idx }) => ( + {scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => ( - {prizeScopeLabel(scope, t)} - {hint ? {hint} : null} +
+ {prizeScopeLabel(scope, t)} + {hint ? {hint} : null} + {row ? ( + + {sourceLabel} + + ) : null} +
- {row && idx >= 0 ? ( + {row ? ( canEditDraft ? ( - {scopeEditorRows.map(({ scope, row, hint, idx }) => ( + {scopeEditorRows.map(({ scope, row, hint, sourceLabel, sourceClassName }) => (
- - {row && idx >= 0 ? ( +
+ + {row ? ( + + {sourceLabel} + + ) : null} +
+ {row ? ( canEditDraft ? ( {toolbarBlock}
-