diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fdc7c4b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: lotteryfront CI + +on: + push: + branches: [main, master, develop] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run build diff --git a/AGENTS.md b/AGENTS.md index ec133f5..402b55d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,3 +12,4 @@ This version has breaking changes — APIs, conventions, and file structure may - 信用盘流水对外类型:`win_credit`(中奖释额)、`bill_settlement`(账期收付),与钱包 `prize`/派彩文案分离。 - 信用流水结余:`game_settlement_win`/`settlement_confirm`/`bet_hold_release` 会减 `used_credit` 并倒推结余;`settlement_payout`(账期收付)仅记账,`affects_available_credit: false`。 +- Token 进场(`EntryGate`):`entryLifecycleRef`(idle→running→done)防 URL token 剥离或 store 写入后重复触发 `doEntry` 导致成功/失败页闪现;成功后直接 `router.replace('/hall')`。 diff --git a/middleware.ts b/middleware.ts index 699f15d..643ca86 100644 --- a/middleware.ts +++ b/middleware.ts @@ -10,7 +10,15 @@ type RuntimeOriginsEnvelope = { }; }; +const RUNTIME_ORIGINS_TTL_MS = 60_000; +let cachedRuntimeOrigins: string[] | null = null; +let cachedAt = 0; + async function loadRuntimeOrigins(): Promise { + if (cachedRuntimeOrigins !== null && Date.now() - cachedAt < RUNTIME_ORIGINS_TTL_MS) { + return cachedRuntimeOrigins; + } + try { const url = `${lotteryApiOrigin()}/api/v1/integration/runtime-origins`; const response = await fetch(url, { @@ -25,9 +33,12 @@ async function loadRuntimeOrigins(): Promise { if (!Array.isArray(origins)) return []; - return origins.filter((origin): origin is string => typeof origin === "string"); + cachedRuntimeOrigins = origins.filter((origin): origin is string => typeof origin === "string"); + cachedAt = Date.now(); + + return cachedRuntimeOrigins; } catch { - return []; + return cachedRuntimeOrigins ?? []; } } diff --git a/package.json b/package.json index 1f6718a..1842470 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/dompurify": "^3.2.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/src/components/iframe-bridge.tsx b/src/components/iframe-bridge.tsx index de580ea..44065b7 100644 --- a/src/components/iframe-bridge.tsx +++ b/src/components/iframe-bridge.tsx @@ -28,6 +28,9 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { (type: string, payload?: Record): void => { if (typeof window === "undefined" || window.parent === window) return; + const targetOrigin = resolvePostMessageTargetOrigin(); + if (!targetOrigin) return; + window.parent.postMessage( { type: `LOTTERY_${type}`, @@ -35,7 +38,7 @@ export function IframeBridge({ children }: { children: ReactNode }): ReactNode { timestamp: Date.now(), source: "lottery-iframe", }, - resolvePostMessageTargetOrigin(), + targetOrigin, ); }, [], diff --git a/src/hooks/use-token-refresh.ts b/src/hooks/use-token-refresh.ts index bf84373..84466a1 100644 --- a/src/hooks/use-token-refresh.ts +++ b/src/hooks/use-token-refresh.ts @@ -87,12 +87,15 @@ export function useTokenRefresh(): { if (typeof window === "undefined") return; // 向主站请求新 Token + const targetOrigin = resolvePostMessageTargetOrigin(); + if (!targetOrigin) return; + window.parent.postMessage( { type: "LOTTERY_TOKEN_REFRESH_REQUEST", timestamp: Date.now(), }, - resolvePostMessageTargetOrigin(), + targetOrigin, ); }, []); diff --git a/src/i18n/locales/en/player.json b/src/i18n/locales/en/player.json index ae9b547..17f4431 100644 --- a/src/i18n/locales/en/player.json +++ b/src/i18n/locales/en/player.json @@ -336,6 +336,7 @@ "2008": "Odds or play configuration has changed. Close the preview and try again.", "2009": "This order was refunded or cannot be resubmitted. Close the preview and place a new bet.", "1003": "Stake amount is outside the allowed range for this play type.", + "1007": "The lottery wallet is frozen. Betting is temporarily unavailable.", "fallback": "Bet failed. Please try again later." }, "amountHint": { diff --git a/src/lib/csp-config.ts b/src/lib/csp-config.ts index 785f827..8b9b407 100644 --- a/src/lib/csp-config.ts +++ b/src/lib/csp-config.ts @@ -4,18 +4,19 @@ * 支持 iframe 嵌入场景,允许主站加载彩票系统 */ -// 允许的主站来源 +// 允许的主站来源(生产须通过环境变量配置) const ALLOWED_PARENT_ORIGINS: string[] = [ process.env.NEXT_PUBLIC_MAIN_SITE_URL, process.env.NEXT_PUBLIC_PARENT_ORIGIN, // 开发环境 - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://192.168.0.101:5173", - "http://localhost:3801", - "http://127.0.0.1:3801", - "http://192.168.0.101:3801", - // 生产环境应从环境变量读取 + ...(process.env.NODE_ENV === "development" + ? [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:3801", + "http://127.0.0.1:3801", + ] + : []), ].filter((o): o is string => Boolean(o)); function normalizeOrigin(value: string): string | null { @@ -104,10 +105,9 @@ export function generateCSP(extraParentOrigins: string[] = []): string { * @param parentOrigin 父窗口来源 */ export function isAllowedParent(parentOrigin: string): boolean { - if (ALLOWED_PARENT_ORIGINS.length === 0) return true; // 未配置时允许所有 - return ALLOWED_PARENT_ORIGINS.some( - (origin) => origin && parentOrigin.startsWith(origin), - ); + const origins = staticAllowedParentOrigins(); + if (origins.length === 0) return false; + return origins.some((origin) => parentOrigin.startsWith(origin)); } /** diff --git a/src/lib/iframe-origins.ts b/src/lib/iframe-origins.ts index 07276c1..a9395d5 100644 --- a/src/lib/iframe-origins.ts +++ b/src/lib/iframe-origins.ts @@ -111,11 +111,11 @@ export function isIframeOriginAllowed(origin: string): boolean { return allowedOrigins.includes(normalized); } -/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin */ -export function resolvePostMessageTargetOrigin(): string { +/** postMessage 目标:优先 referrer 对应白名单,否则取首个白名单 origin;无白名单时拒绝发送 */ +export function resolvePostMessageTargetOrigin(): string | null { const allowedOrigins = getKnownIframeAllowedOrigins(); if (allowedOrigins.length === 0) { - return "*"; + return null; } if (typeof document !== "undefined" && document.referrer) {