From 20c8395c0b3ff3f0e4079869113250072fa7126a Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 24 May 2026 18:33:47 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=A8=E5=B1=80=E9=85=8D=E7=BD=AE=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=EF=BC=9AOAuth=20=E5=9B=9E=E8=B0=83=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E3=80=81=E6=94=AF=E4=BB=98=E5=AE=89=E5=85=A8=E5=8A=A0=E5=9B=BA?= =?UTF-8?q?=E3=80=81=E7=AB=99=E7=82=B9=20URL=20=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块 - 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL - 支付 API 路由新增收款地址校验 - 后端支付服务更新 - middleware 清理 - 新增 paymentSecurity 测试 --- frontend/.env.example | 5 + frontend/.env.local | 2 +- .../intents/[intentId]/confirm/route.ts | 3 + .../intents/[intentId]/submit/route.ts | 3 + .../intents/[intentId]/validate/route.ts | 3 + frontend/app/api/payments/intents/route.ts | 3 + .../api/payments/reconcile-latest/route.ts | 3 + .../api/payments/wallets/challenge/route.ts | 3 + frontend/app/api/payments/wallets/route.ts | 3 + .../app/api/payments/wallets/verify/route.ts | 3 + frontend/app/auth/callback/route.ts | 12 +- frontend/components/account/AccountCenter.tsx | 27 ++-- .../account/__tests__/paymentSecurity.test.ts | 76 ++++++++++ frontend/components/auth/LoginClient.tsx | 11 +- frontend/lib/backend-auth.ts | 11 +- frontend/lib/payment-receiver.ts | 19 +++ frontend/lib/site-url.ts | 7 + frontend/middleware.ts | 6 - src/bot/io_layer.py | 1 - src/bot/orchestrator.py | 6 +- web/routers/city.py | 134 ++++++++++-------- web/services/payment_api.py | 6 +- 22 files changed, 259 insertions(+), 88 deletions(-) create mode 100644 frontend/components/account/__tests__/paymentSecurity.test.ts create mode 100644 frontend/lib/payment-receiver.ts create mode 100644 frontend/lib/site-url.ts diff --git a/frontend/.env.example b/frontend/.env.example index e720d351..449d8f7f 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -15,6 +15,11 @@ NEXT_PUBLIC_POLYWEATHER_API_BASE_URL= NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= +# 必填:生产环境站点 URL(OAuth 回调强制使用此域名) +# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。 +# 生产环境必须设为 https://polyweather-pro.vercel.app +NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app + # 常用:前端鉴权开关 # true: 启用 Supabase 登录 # false: 关闭登录能力,访客模式 diff --git a/frontend/.env.local b/frontend/.env.local index ced57054..449d8f7f 100644 --- a/frontend/.env.local +++ b/frontend/.env.local @@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY= # 必填:生产环境站点 URL(OAuth 回调强制使用此域名) # 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。 # 生产环境必须设为 https://polyweather-pro.vercel.app -NEXT_PUBLIC_SITE_URL= +NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app # 常用:前端鉴权开关 # true: 启用 Supabase 登录 diff --git a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts index 0a0a2fe1..e686a564 100644 --- a/frontend/app/api/payments/intents/[intentId]/confirm/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/confirm/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -24,6 +25,8 @@ export async function POST( try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch( diff --git a/frontend/app/api/payments/intents/[intentId]/submit/route.ts b/frontend/app/api/payments/intents/[intentId]/submit/route.ts index 1f988734..624ff527 100644 --- a/frontend/app/api/payments/intents/[intentId]/submit/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/submit/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -24,6 +25,8 @@ export async function POST( try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch( diff --git a/frontend/app/api/payments/intents/[intentId]/validate/route.ts b/frontend/app/api/payments/intents/[intentId]/validate/route.ts index 7142c129..ca66bff0 100644 --- a/frontend/app/api/payments/intents/[intentId]/validate/route.ts +++ b/frontend/app/api/payments/intents/[intentId]/validate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -24,6 +25,8 @@ export async function POST( try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch( diff --git a/frontend/app/api/payments/intents/route.ts b/frontend/app/api/payments/intents/route.ts index 11f6d478..48b0d7f0 100644 --- a/frontend/app/api/payments/intents/route.ts +++ b/frontend/app/api/payments/intents/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -35,6 +36,8 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch(`${API_BASE}/api/payments/intents`, { diff --git a/frontend/app/api/payments/reconcile-latest/route.ts b/frontend/app/api/payments/reconcile-latest/route.ts index 52a32837..9d62e36f 100644 --- a/frontend/app/api/payments/reconcile-latest/route.ts +++ b/frontend/app/api/payments/reconcile-latest/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse } from "@/lib/api-proxy"; @@ -17,6 +18,8 @@ export async function POST(req: NextRequest) { try { const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, { method: "POST", headers: auth.headers, diff --git a/frontend/app/api/payments/wallets/challenge/route.ts b/frontend/app/api/payments/wallets/challenge/route.ts index 53b29cd7..a993d792 100644 --- a/frontend/app/api/payments/wallets/challenge/route.ts +++ b/frontend/app/api/payments/wallets/challenge/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -20,6 +21,8 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, { diff --git a/frontend/app/api/payments/wallets/route.ts b/frontend/app/api/payments/wallets/route.ts index c3426123..f6696760 100644 --- a/frontend/app/api/payments/wallets/route.ts +++ b/frontend/app/api/payments/wallets/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) { } try { const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch(`${API_BASE}/api/payments/wallets`, { diff --git a/frontend/app/api/payments/wallets/verify/route.ts b/frontend/app/api/payments/wallets/verify/route.ts index ea182eec..49f93e75 100644 --- a/frontend/app/api/payments/wallets/verify/route.ts +++ b/frontend/app/api/payments/wallets/verify/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { applyAuthResponseCookies, buildBackendRequestHeaders, + requireBackendAuthUser, } from "@/lib/backend-auth"; import { buildProxyExceptionResponse, @@ -20,6 +21,8 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); const auth = await buildBackendRequestHeaders(req); + const authError = requireBackendAuthUser(auth); + if (authError) return authError; const proxiedHeaders = new Headers(auth.headers); proxiedHeaders.set("Content-Type", "application/json"); const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, { diff --git a/frontend/app/auth/callback/route.ts b/frontend/app/auth/callback/route.ts index 1c7cb046..d7df6e5e 100644 --- a/frontend/app/auth/callback/route.ts +++ b/frontend/app/auth/callback/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server"; +import { getConfiguredSiteUrl } from "@/lib/site-url"; function normalizeNextPath(input: string | null) { const fallback = "/"; @@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) { } export async function GET(request: NextRequest) { + const configuredSiteUrl = getConfiguredSiteUrl(); + if (configuredSiteUrl) { + const canonicalOrigin = new URL(configuredSiteUrl).origin; + if (request.nextUrl.origin !== canonicalOrigin) { + const canonicalCallbackUrl = new URL(request.nextUrl.pathname, canonicalOrigin); + canonicalCallbackUrl.search = request.nextUrl.search; + return NextResponse.redirect(canonicalCallbackUrl); + } + } + const nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next")); const redirectUrl = request.nextUrl.clone(); redirectUrl.pathname = nextPath; @@ -29,4 +40,3 @@ export async function GET(request: NextRequest) { return response; } - diff --git a/frontend/components/account/AccountCenter.tsx b/frontend/components/account/AccountCenter.tsx index c8f5c0c5..e0a13d22 100644 --- a/frontend/components/account/AccountCenter.tsx +++ b/frontend/components/account/AccountCenter.tsx @@ -44,6 +44,10 @@ import { getCurrentPaymentHost, isPaymentHostAllowed, } from "@/lib/payment-host"; +import { + assertExpectedPaymentReceiver, + EXPECTED_PAYMENT_RECEIVER_ADDRESS, +} from "@/lib/payment-receiver"; import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics"; import { useI18n } from "@/hooks/useI18n"; import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay"; @@ -1062,11 +1066,7 @@ export function AccountCenter() { (resolvedSelectedTokenAddress.startsWith("0x") ? shortAddress(resolvedSelectedTokenAddress) : "USDC"); - const paymentReceiverAddress = String( - selectedPaymentToken?.receiver_contract || - paymentConfig?.receiver_contract || - "", - ).toLowerCase(); + const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS; const paymentWalletLabel = String( selectedWallet || walletAddress || @@ -1348,6 +1348,15 @@ export function AccountCenter() { options: ConnectBindOptions = {}, ): Promise => { clearPaymentMessages(); + if (!paymentHostAllowed) { + setPaymentError( + copy.paymentHostBlocked.replace( + "{host}", + allowedPaymentHosts[0] || "polyweather-pro.vercel.app", + ), + ); + return false; + } if (!isAuthenticated) { setPaymentError(copy.loginBeforeBind); return false; @@ -1621,9 +1630,7 @@ export function AccountCenter() { const expectedReceiver = String( latestConfig.receiver_contract || "", ).toLowerCase(); - if (!expectedReceiver.startsWith("0x")) { - throw new Error("payment receiver contract is not configured"); - } + assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract"); if ( paymentConfig?.receiver_contract && String(paymentConfig.receiver_contract).toLowerCase() !== @@ -1920,6 +1927,10 @@ export function AccountCenter() { if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) { throw new Error("manual payment payload invalid"); } + assertExpectedPaymentReceiver( + direct.receiver_address, + "manual payment receiver", + ); setLastIntentId(intentId); setManualPayment(direct); setPaymentMethodTab("manual"); diff --git a/frontend/components/account/__tests__/paymentSecurity.test.ts b/frontend/components/account/__tests__/paymentSecurity.test.ts new file mode 100644 index 00000000..31e7e7fc --- /dev/null +++ b/frontend/components/account/__tests__/paymentSecurity.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string) { + if (!condition) throw new Error(message); +} + +const EXPECTED_RECEIVER = "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a"; + +export function runTests() { + const projectRoot = process.cwd(); + const receiverModulePath = path.join(projectRoot, "lib", "payment-receiver.ts"); + const backendAuthPath = path.join(projectRoot, "lib", "backend-auth.ts"); + const middlewarePath = path.join(projectRoot, "middleware.ts"); + const accountCenterPath = path.join( + projectRoot, + "components", + "account", + "AccountCenter.tsx", + ); + const paymentRoutes = [ + "app/api/payments/wallets/challenge/route.ts", + "app/api/payments/wallets/verify/route.ts", + "app/api/payments/wallets/route.ts", + "app/api/payments/intents/route.ts", + "app/api/payments/intents/[intentId]/submit/route.ts", + "app/api/payments/intents/[intentId]/confirm/route.ts", + "app/api/payments/intents/[intentId]/validate/route.ts", + "app/api/payments/reconcile-latest/route.ts", + ]; + + assert( + fs.existsSync(receiverModulePath), + "payment receiver guard module must exist", + ); + const receiverSource = fs.readFileSync(receiverModulePath, "utf8"); + assert( + receiverSource.includes(EXPECTED_RECEIVER), + "payment receiver guard must pin the production receiver address", + ); + assert( + receiverSource.includes("assertExpectedPaymentReceiver"), + "payment receiver guard must expose an assertion helper", + ); + + const accountCenterSource = fs.readFileSync(accountCenterPath, "utf8"); + assert( + accountCenterSource.includes("assertExpectedPaymentReceiver"), + "AccountCenter must validate backend-returned manual payment receiver before displaying it", + ); + assert( + accountCenterSource.includes("EXPECTED_PAYMENT_RECEIVER_ADDRESS"), + "AccountCenter must show the pinned payment receiver address in its payment guard", + ); + + const backendAuthSource = fs.readFileSync(backendAuthPath, "utf8"); + assert( + backendAuthSource.includes("requireBackendAuthUser"), + "backend auth helper must expose a real-user requirement for payment mutations", + ); + + const middlewareSource = fs.readFileSync(middlewarePath, "utf8"); + assert( + !middlewareSource.includes("/^bearer\\s+\\S+/i.test(authHeader)") && + !middlewareSource.includes("return NextResponse.next();\n }\n }\n\n const response = NextResponse.next"), + "middleware must not treat the mere presence of a bearer token as authenticated", + ); + + for (const route of paymentRoutes) { + const routeSource = fs.readFileSync(path.join(projectRoot, route), "utf8"); + assert( + routeSource.includes("requireBackendAuthUser"), + `${route} must reject payment mutations without a real Supabase user`, + ); + } +} diff --git a/frontend/components/auth/LoginClient.tsx b/frontend/components/auth/LoginClient.tsx index bcafcaf5..7a7750ab 100644 --- a/frontend/components/auth/LoginClient.tsx +++ b/frontend/components/auth/LoginClient.tsx @@ -17,6 +17,7 @@ import { getSupabaseBrowserClient, hasSupabasePublicEnv, } from "@/lib/supabase/client"; +import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url"; import { useI18n } from "@/hooks/useI18n"; type Mode = "login" | "signup"; @@ -38,8 +39,8 @@ export function LoginClient({ nextPath }: LoginClientProps) { const supabaseReady = hasSupabasePublicEnv(); const siteOrigin = - process.env.NEXT_PUBLIC_SITE_URL?.trim() || - (typeof window !== "undefined" ? window.location.origin : ""); + getConfiguredSiteUrl() || + (typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL); const isEn = locale === "en-US"; const copy = { backHome: isEn ? "Back to Home" : "返回首页", @@ -102,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) { setLoading(true); try { const supabase = getSupabaseBrowserClient(); - const { error } = await supabase.auth.resetPasswordForEmail(email.trim()); + const { error } = await supabase.auth.resetPasswordForEmail(email.trim(), { + redirectTo: `${siteOrigin}/auth/callback?next=${encodeURIComponent( + "/account", + )}`, + }); if (error) { setErrorText(error.message); return; diff --git a/frontend/lib/backend-auth.ts b/frontend/lib/backend-auth.ts index 028bd494..d2d0d066 100644 --- a/frontend/lib/backend-auth.ts +++ b/frontend/lib/backend-auth.ts @@ -94,4 +94,13 @@ export function applyAuthResponseCookies( return target; } - +export function requireBackendAuthUser(auth: HeaderBuildResult) { + if (auth.authUserId) return null; + return applyAuthResponseCookies( + NextResponse.json( + { error: "Authentication required", detail: "Supabase user required" }, + { status: 401 }, + ), + auth.response, + ); +} diff --git a/frontend/lib/payment-receiver.ts b/frontend/lib/payment-receiver.ts new file mode 100644 index 00000000..bd7dd4f1 --- /dev/null +++ b/frontend/lib/payment-receiver.ts @@ -0,0 +1,19 @@ +export const EXPECTED_PAYMENT_RECEIVER_ADDRESS = + "0x351a1bca5f49dd0046a7cf0bafa7e12fa6441c3a"; + +export function normalizePaymentReceiver(address: string | null | undefined) { + return String(address || "").trim().toLowerCase(); +} + +export function assertExpectedPaymentReceiver( + address: string | null | undefined, + label = "payment receiver", +) { + const normalized = normalizePaymentReceiver(address); + if (normalized !== EXPECTED_PAYMENT_RECEIVER_ADDRESS) { + throw new Error( + `${label} mismatch: expected ${EXPECTED_PAYMENT_RECEIVER_ADDRESS}, got ${normalized || "empty"}`, + ); + } + return normalized; +} diff --git a/frontend/lib/site-url.ts b/frontend/lib/site-url.ts new file mode 100644 index 00000000..c1071abb --- /dev/null +++ b/frontend/lib/site-url.ts @@ -0,0 +1,7 @@ +export const PRODUCTION_SITE_URL = "https://polyweather-pro.vercel.app"; + +export function getConfiguredSiteUrl() { + const configured = process.env.NEXT_PUBLIC_SITE_URL?.trim(); + if (configured) return configured; + return process.env.NODE_ENV === "production" ? PRODUCTION_SITE_URL : ""; +} diff --git a/frontend/middleware.ts b/frontend/middleware.ts index 4d421b41..fc3b9da8 100644 --- a/frontend/middleware.ts +++ b/frontend/middleware.ts @@ -106,12 +106,6 @@ async function handleSupabaseAuthGate(request: NextRequest) { if (isPublicPage(pathname) || isPublicApi(pathname)) { return NextResponse.next(); } - if (pathname.startsWith("/api/")) { - const authHeader = String(request.headers.get("authorization") || "").trim(); - if (/^bearer\s+\S+/i.test(authHeader)) { - return NextResponse.next(); - } - } const response = NextResponse.next({ request: { diff --git a/src/bot/io_layer.py b/src/bot/io_layer.py index 3e8ea606..bdf1370c 100644 --- a/src/bot/io_layer.py +++ b/src/bot/io_layer.py @@ -206,7 +206,6 @@ class BotIOLayer: "🔗 机器人: @polyyuanbot\n" "👥 社群: 加入 Telegram 群组\n\n" "📌 私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 /markets\n\n" - "🔐 /city 与 /deb 仅限官方群成员使用。\n\n" "示例: /city 伦敦/pwcity 伦敦\n" f"💡 提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 {MESSAGE_POINTS} 积分," f"每日上限 {MESSAGE_DAILY_CAP} 分。" diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 6bb31a11..d07ffa5b 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -81,8 +81,8 @@ def start_bot() -> None: startup_coordinator = StartupCoordinator( bot=bot, config=config, - command_access_mode="group_member_only", - protected_commands=["/city", "/deb"], + command_access_mode="public", + protected_commands=[], required_group_chat_id=",".join(get_telegram_chat_ids_from_env()), ) @@ -99,7 +99,7 @@ def start_bot() -> None: started_count = sum(1 for loop in runtime_status.loops if loop.started) logger.info( - "🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}", + "🤖 Bot 启动中... access=public protected_commands=none loops_started={}/{}", started_count, len(runtime_status.loops), ) diff --git a/web/routers/city.py b/web/routers/city.py index 3104d3e9..c70ef698 100644 --- a/web/routers/city.py +++ b/web/routers/city.py @@ -14,29 +14,23 @@ from web.services.city_api import ( router = APIRouter(tags=["city"]) -_MODEL_RANGE_CITIES: List[str] = [ - "beijing", - "shanghai", - "guangzhou", - "chengdu", - "chongqing", - "qingdao", - "wuhan", - "seoul", - "busan", -] +def _all_city_keys() -> List[str]: + from src.data_collection.city_registry import CITY_REGISTRY -_MODEL_RANGE_NAMES: Dict[str, str] = { - "beijing": "北京 (ZBAA)", - "shanghai": "上海 (ZSPD)", - "guangzhou": "广州 (ZGGG)", - "chengdu": "成都 (ZUUU)", - "chongqing": "重庆 (ZUCK)", - "qingdao": "青岛 (ZSQD)", - "wuhan": "武汉 (ZHHH)", - "seoul": "首尔 (RKSI)", - "busan": "釜山 (RKPK)", -} + return sorted(CITY_REGISTRY.keys()) + + +def _city_display_name(city: str) -> str: + from src.data_collection.city_registry import CITY_REGISTRY + + meta = CITY_REGISTRY.get(city) or {} + icao = str(meta.get("icao") or "").strip() + display = str(meta.get("display_name") or city).strip() + return f"{display} ({icao})" if icao else display + + +_MODEL_RANGE_CITIES: List[str] = _all_city_keys() +_MODEL_RANGE_NAMES: Dict[str, str] = {c: _city_display_name(c) for c in _MODEL_RANGE_CITIES} @router.get("/api/cities") @@ -44,55 +38,69 @@ async def list_cities(request: Request): return await list_cities_payload(request) +def _extract_city_model_range(city: str, _force_refresh: bool) -> Optional[Dict[str, Any]]: + """Extract cached model range data without triggering fresh analysis.""" + from web.analysis_service import _cache, _analysis_cache_key + + for detail_mode in ("full", "panel", "nearby", "market"): + cache_key = _analysis_cache_key(city, detail_mode) + cached = _cache.get(cache_key) + if cached and isinstance(cached.get("d"), dict): + result = cached["d"] + if isinstance(result.get("multi_model"), dict) and result["multi_model"]: + break + else: + return None + + if not isinstance(result, dict): + return None + + deb = result.get("deb") if isinstance(result, dict) else None + deb_pred = deb.get("prediction") if isinstance(deb, dict) else None + + models = result.get("multi_model") if isinstance(result, dict) else {} + model_min: Optional[float] = None + model_max: Optional[float] = None + spread: Optional[float] = None + spread_label: str = "" + + if isinstance(models, dict): + vals = sorted([v for v in models.values() if isinstance(v, (int, float))]) + if len(vals) >= 2: + model_min = vals[0] + model_max = vals[-1] + spread = model_max - model_min + if spread <= 2.0: + spread_label = "低分歧" + elif spread <= 4.0: + spread_label = "中等分歧" + else: + spread_label = "高分歧" + + return { + "id": city, + "name": _MODEL_RANGE_NAMES.get(city, city), + "deb": round(deb_pred, 1) if deb_pred is not None else None, + "model_min": round(model_min, 1) if model_min is not None else None, + "model_max": round(model_max, 1) if model_max is not None else None, + "spread": round(spread, 1) if spread is not None else None, + "spread_label": spread_label, + } + + @router.get("/api/cities/model-range") async def cities_model_range( request: Request, force_refresh: bool = Query(False), ): - """Return DEB prediction and model range for monitored cities (CN + KR).""" - from web.app import _analyze - + """Return DEB prediction and model range for all monitored cities.""" rows: List[Dict[str, Any]] = [] for city in _MODEL_RANGE_CITIES: - try: - result = _analyze(city, force_refresh=force_refresh) - except Exception: - continue - - deb = result.get("deb") if isinstance(result, dict) else None - deb_pred = deb.get("prediction") if isinstance(deb, dict) else None - - models = result.get("multi_model") if isinstance(result, dict) else {} - model_min: Optional[float] = None - model_max: Optional[float] = None - spread: Optional[float] = None - spread_label: str = "" - - if isinstance(models, dict): - vals = sorted([v for v in models.values() if isinstance(v, (int, float))]) - if len(vals) >= 2: - model_min = vals[0] - model_max = vals[-1] - spread = model_max - model_min - if spread <= 2.0: - spread_label = "低分歧" - elif spread <= 4.0: - spread_label = "中等分歧" - else: - spread_label = "高分歧" - - rows.append( - { - "id": city, - "name": _MODEL_RANGE_NAMES.get(city, city), - "deb": round(deb_pred, 1) if deb_pred is not None else None, - "model_min": round(model_min, 1) if model_min is not None else None, - "model_max": round(model_max, 1) if model_max is not None else None, - "spread": round(spread, 1) if spread is not None else None, - "spread_label": spread_label, - } - ) + row = _extract_city_model_range(city, force_refresh) + if row is not None: + rows.append(row) + rows.sort(key=lambda r: str(r.get("id") or "")) return {"cities": rows} diff --git a/web/services/payment_api.py b/web/services/payment_api.py index 071545cd..1047e6b1 100644 --- a/web/services/payment_api.py +++ b/web/services/payment_api.py @@ -28,7 +28,11 @@ def _raise_payment_error(exc: Exception) -> None: def _require_payment_identity(request: Request) -> Dict[str, Any]: legacy_routes._assert_entitlement(request) - return legacy_routes._require_supabase_identity(request) + identity = legacy_routes._require_supabase_identity(request) + user_id = str(identity.get("user_id") or "").strip() + if not user_id or user_id == "entitlement" or user_id.startswith("admin:"): + raise HTTPException(status_code=401, detail="Supabase user required") + return identity def get_payment_config(request: Request) -> Dict[str, Any]: