全局配置更新:OAuth 回调修复、支付安全加固、站点 URL 工具
- 新增 NEXT_PUBLIC_SITE_URL 支持及 site-url.ts 工具模块 - 修复 OAuth 回调域名:import.meta.env 统一读取站点 URL - 支付 API 路由新增收款地址校验 - 后端支付服务更新 - middleware 清理 - 新增 paymentSecurity 测试
This commit is contained in:
@@ -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: 关闭登录能力,访客模式
|
||||
|
||||
+1
-1
@@ -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 登录
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<boolean> => {
|
||||
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");
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 : "";
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -206,7 +206,6 @@ class BotIOLayer:
|
||||
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
|
||||
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
+71
-63
@@ -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}
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user