全局配置更新: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_URL=
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||||
|
|
||||||
|
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||||
|
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||||
|
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||||
|
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||||
|
|
||||||
# 常用:前端鉴权开关
|
# 常用:前端鉴权开关
|
||||||
# true: 启用 Supabase 登录
|
# true: 启用 Supabase 登录
|
||||||
# false: 关闭登录能力,访客模式
|
# false: 关闭登录能力,访客模式
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
|||||||
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
# 必填:生产环境站点 URL(OAuth 回调强制使用此域名)
|
||||||
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
# 设置后,所有登录回调将始终跳转到此域名,而非当前浏览器地址。
|
||||||
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
# 生产环境必须设为 https://polyweather-pro.vercel.app
|
||||||
NEXT_PUBLIC_SITE_URL=
|
NEXT_PUBLIC_SITE_URL=https://polyweather-pro.vercel.app
|
||||||
|
|
||||||
# 常用:前端鉴权开关
|
# 常用:前端鉴权开关
|
||||||
# true: 启用 Supabase 登录
|
# true: 启用 Supabase 登录
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -24,6 +25,8 @@ export async function POST(
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -35,6 +36,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
const res = await fetch(`${API_BASE}/api/payments/intents`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||||
|
|
||||||
@@ -17,6 +18,8 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
const res = await fetch(`${API_BASE}/api/payments/reconcile-latest`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: auth.headers,
|
headers: auth.headers,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -44,6 +45,8 @@ export async function DELETE(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import {
|
import {
|
||||||
applyAuthResponseCookies,
|
applyAuthResponseCookies,
|
||||||
buildBackendRequestHeaders,
|
buildBackendRequestHeaders,
|
||||||
|
requireBackendAuthUser,
|
||||||
} from "@/lib/backend-auth";
|
} from "@/lib/backend-auth";
|
||||||
import {
|
import {
|
||||||
buildProxyExceptionResponse,
|
buildProxyExceptionResponse,
|
||||||
@@ -20,6 +21,8 @@ export async function POST(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = await req.json();
|
||||||
const auth = await buildBackendRequestHeaders(req);
|
const auth = await buildBackendRequestHeaders(req);
|
||||||
|
const authError = requireBackendAuthUser(auth);
|
||||||
|
if (authError) return authError;
|
||||||
const proxiedHeaders = new Headers(auth.headers);
|
const proxiedHeaders = new Headers(auth.headers);
|
||||||
proxiedHeaders.set("Content-Type", "application/json");
|
proxiedHeaders.set("Content-Type", "application/json");
|
||||||
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
import { createSupabaseRouteClient, hasSupabaseServerEnv } from "@/lib/supabase/server";
|
||||||
|
import { getConfiguredSiteUrl } from "@/lib/site-url";
|
||||||
|
|
||||||
function normalizeNextPath(input: string | null) {
|
function normalizeNextPath(input: string | null) {
|
||||||
const fallback = "/";
|
const fallback = "/";
|
||||||
@@ -11,6 +12,16 @@ function normalizeNextPath(input: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
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 nextPath = normalizeNextPath(request.nextUrl.searchParams.get("next"));
|
||||||
const redirectUrl = request.nextUrl.clone();
|
const redirectUrl = request.nextUrl.clone();
|
||||||
redirectUrl.pathname = nextPath;
|
redirectUrl.pathname = nextPath;
|
||||||
@@ -29,4 +40,3 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,10 @@ import {
|
|||||||
getCurrentPaymentHost,
|
getCurrentPaymentHost,
|
||||||
isPaymentHostAllowed,
|
isPaymentHostAllowed,
|
||||||
} from "@/lib/payment-host";
|
} from "@/lib/payment-host";
|
||||||
|
import {
|
||||||
|
assertExpectedPaymentReceiver,
|
||||||
|
EXPECTED_PAYMENT_RECEIVER_ADDRESS,
|
||||||
|
} from "@/lib/payment-receiver";
|
||||||
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
import { markAnalyticsOnce, trackAppEvent } from "@/lib/app-analytics";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
import { UnlockProOverlay } from "@/components/subscription/UnlockProOverlay";
|
||||||
@@ -1062,11 +1066,7 @@ export function AccountCenter() {
|
|||||||
(resolvedSelectedTokenAddress.startsWith("0x")
|
(resolvedSelectedTokenAddress.startsWith("0x")
|
||||||
? shortAddress(resolvedSelectedTokenAddress)
|
? shortAddress(resolvedSelectedTokenAddress)
|
||||||
: "USDC");
|
: "USDC");
|
||||||
const paymentReceiverAddress = String(
|
const paymentReceiverAddress = EXPECTED_PAYMENT_RECEIVER_ADDRESS;
|
||||||
selectedPaymentToken?.receiver_contract ||
|
|
||||||
paymentConfig?.receiver_contract ||
|
|
||||||
"",
|
|
||||||
).toLowerCase();
|
|
||||||
const paymentWalletLabel = String(
|
const paymentWalletLabel = String(
|
||||||
selectedWallet ||
|
selectedWallet ||
|
||||||
walletAddress ||
|
walletAddress ||
|
||||||
@@ -1348,6 +1348,15 @@ export function AccountCenter() {
|
|||||||
options: ConnectBindOptions = {},
|
options: ConnectBindOptions = {},
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
clearPaymentMessages();
|
clearPaymentMessages();
|
||||||
|
if (!paymentHostAllowed) {
|
||||||
|
setPaymentError(
|
||||||
|
copy.paymentHostBlocked.replace(
|
||||||
|
"{host}",
|
||||||
|
allowedPaymentHosts[0] || "polyweather-pro.vercel.app",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
setPaymentError(copy.loginBeforeBind);
|
setPaymentError(copy.loginBeforeBind);
|
||||||
return false;
|
return false;
|
||||||
@@ -1621,9 +1630,7 @@ export function AccountCenter() {
|
|||||||
const expectedReceiver = String(
|
const expectedReceiver = String(
|
||||||
latestConfig.receiver_contract || "",
|
latestConfig.receiver_contract || "",
|
||||||
).toLowerCase();
|
).toLowerCase();
|
||||||
if (!expectedReceiver.startsWith("0x")) {
|
assertExpectedPaymentReceiver(expectedReceiver, "payment receiver contract");
|
||||||
throw new Error("payment receiver contract is not configured");
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
paymentConfig?.receiver_contract &&
|
paymentConfig?.receiver_contract &&
|
||||||
String(paymentConfig.receiver_contract).toLowerCase() !==
|
String(paymentConfig.receiver_contract).toLowerCase() !==
|
||||||
@@ -1920,6 +1927,10 @@ export function AccountCenter() {
|
|||||||
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
if (!intentId || !direct?.receiver_address || !direct?.amount_usdc) {
|
||||||
throw new Error("manual payment payload invalid");
|
throw new Error("manual payment payload invalid");
|
||||||
}
|
}
|
||||||
|
assertExpectedPaymentReceiver(
|
||||||
|
direct.receiver_address,
|
||||||
|
"manual payment receiver",
|
||||||
|
);
|
||||||
setLastIntentId(intentId);
|
setLastIntentId(intentId);
|
||||||
setManualPayment(direct);
|
setManualPayment(direct);
|
||||||
setPaymentMethodTab("manual");
|
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,
|
getSupabaseBrowserClient,
|
||||||
hasSupabasePublicEnv,
|
hasSupabasePublicEnv,
|
||||||
} from "@/lib/supabase/client";
|
} from "@/lib/supabase/client";
|
||||||
|
import { getConfiguredSiteUrl, PRODUCTION_SITE_URL } from "@/lib/site-url";
|
||||||
import { useI18n } from "@/hooks/useI18n";
|
import { useI18n } from "@/hooks/useI18n";
|
||||||
|
|
||||||
type Mode = "login" | "signup";
|
type Mode = "login" | "signup";
|
||||||
@@ -38,8 +39,8 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
|
|
||||||
const supabaseReady = hasSupabasePublicEnv();
|
const supabaseReady = hasSupabasePublicEnv();
|
||||||
const siteOrigin =
|
const siteOrigin =
|
||||||
process.env.NEXT_PUBLIC_SITE_URL?.trim() ||
|
getConfiguredSiteUrl() ||
|
||||||
(typeof window !== "undefined" ? window.location.origin : "");
|
(typeof window !== "undefined" ? window.location.origin : PRODUCTION_SITE_URL);
|
||||||
const isEn = locale === "en-US";
|
const isEn = locale === "en-US";
|
||||||
const copy = {
|
const copy = {
|
||||||
backHome: isEn ? "Back to Home" : "返回首页",
|
backHome: isEn ? "Back to Home" : "返回首页",
|
||||||
@@ -102,7 +103,11 @@ export function LoginClient({ nextPath }: LoginClientProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const supabase = getSupabaseBrowserClient();
|
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) {
|
if (error) {
|
||||||
setErrorText(error.message);
|
setErrorText(error.message);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -94,4 +94,13 @@ export function applyAuthResponseCookies(
|
|||||||
return target;
|
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)) {
|
if (isPublicPage(pathname) || isPublicApi(pathname)) {
|
||||||
return NextResponse.next();
|
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({
|
const response = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
|
|||||||
@@ -206,7 +206,6 @@ class BotIOLayer:
|
|||||||
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
|
"🔗 机器人: <a href=\"https://t.me/polyyuanbot\">@polyyuanbot</a>\n"
|
||||||
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
"👥 社群: <a href=\"https://t.me/+Io5H9oVHFmVjOTQ5\">加入 Telegram 群组</a>\n\n"
|
||||||
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
"📌 <i>私有频道用于接收自动推送;手动查看市场概览请私聊机器人发送 <code>/markets</code>。</i>\n\n"
|
||||||
"🔐 <i>/city 与 /deb 仅限官方群成员使用。</i>\n\n"
|
|
||||||
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
"示例: <code>/city 伦敦</code> 或 <code>/pwcity 伦敦</code>\n"
|
||||||
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
f"💡 <i>提示: 群内有效发言(满 {MESSAGE_MIN_LENGTH} 字)获得 <b>{MESSAGE_POINTS}</b> 积分,"
|
||||||
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
f"每日上限 {MESSAGE_DAILY_CAP} 分。"
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ def start_bot() -> None:
|
|||||||
startup_coordinator = StartupCoordinator(
|
startup_coordinator = StartupCoordinator(
|
||||||
bot=bot,
|
bot=bot,
|
||||||
config=config,
|
config=config,
|
||||||
command_access_mode="group_member_only",
|
command_access_mode="public",
|
||||||
protected_commands=["/city", "/deb"],
|
protected_commands=[],
|
||||||
required_group_chat_id=",".join(get_telegram_chat_ids_from_env()),
|
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)
|
started_count = sum(1 for loop in runtime_status.loops if loop.started)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"🤖 Bot 启动中... access=group-member-only protected_commands=/city,/deb loops_started={}/{}",
|
"🤖 Bot 启动中... access=public protected_commands=none loops_started={}/{}",
|
||||||
started_count,
|
started_count,
|
||||||
len(runtime_status.loops),
|
len(runtime_status.loops),
|
||||||
)
|
)
|
||||||
|
|||||||
+71
-63
@@ -14,29 +14,23 @@ from web.services.city_api import (
|
|||||||
|
|
||||||
router = APIRouter(tags=["city"])
|
router = APIRouter(tags=["city"])
|
||||||
|
|
||||||
_MODEL_RANGE_CITIES: List[str] = [
|
def _all_city_keys() -> List[str]:
|
||||||
"beijing",
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
"shanghai",
|
|
||||||
"guangzhou",
|
|
||||||
"chengdu",
|
|
||||||
"chongqing",
|
|
||||||
"qingdao",
|
|
||||||
"wuhan",
|
|
||||||
"seoul",
|
|
||||||
"busan",
|
|
||||||
]
|
|
||||||
|
|
||||||
_MODEL_RANGE_NAMES: Dict[str, str] = {
|
return sorted(CITY_REGISTRY.keys())
|
||||||
"beijing": "北京 (ZBAA)",
|
|
||||||
"shanghai": "上海 (ZSPD)",
|
|
||||||
"guangzhou": "广州 (ZGGG)",
|
def _city_display_name(city: str) -> str:
|
||||||
"chengdu": "成都 (ZUUU)",
|
from src.data_collection.city_registry import CITY_REGISTRY
|
||||||
"chongqing": "重庆 (ZUCK)",
|
|
||||||
"qingdao": "青岛 (ZSQD)",
|
meta = CITY_REGISTRY.get(city) or {}
|
||||||
"wuhan": "武汉 (ZHHH)",
|
icao = str(meta.get("icao") or "").strip()
|
||||||
"seoul": "首尔 (RKSI)",
|
display = str(meta.get("display_name") or city).strip()
|
||||||
"busan": "釜山 (RKPK)",
|
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")
|
@router.get("/api/cities")
|
||||||
@@ -44,55 +38,69 @@ async def list_cities(request: Request):
|
|||||||
return await list_cities_payload(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")
|
@router.get("/api/cities/model-range")
|
||||||
async def cities_model_range(
|
async def cities_model_range(
|
||||||
request: Request,
|
request: Request,
|
||||||
force_refresh: bool = Query(False),
|
force_refresh: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""Return DEB prediction and model range for monitored cities (CN + KR)."""
|
"""Return DEB prediction and model range for all monitored cities."""
|
||||||
from web.app import _analyze
|
|
||||||
|
|
||||||
rows: List[Dict[str, Any]] = []
|
rows: List[Dict[str, Any]] = []
|
||||||
for city in _MODEL_RANGE_CITIES:
|
for city in _MODEL_RANGE_CITIES:
|
||||||
try:
|
row = _extract_city_model_range(city, force_refresh)
|
||||||
result = _analyze(city, force_refresh=force_refresh)
|
if row is not None:
|
||||||
except Exception:
|
rows.append(row)
|
||||||
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,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
rows.sort(key=lambda r: str(r.get("id") or ""))
|
||||||
return {"cities": rows}
|
return {"cities": rows}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,11 @@ def _raise_payment_error(exc: Exception) -> None:
|
|||||||
|
|
||||||
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
def _require_payment_identity(request: Request) -> Dict[str, Any]:
|
||||||
legacy_routes._assert_entitlement(request)
|
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]:
|
def get_payment_config(request: Request) -> Dict[str, Any]:
|
||||||
|
|||||||
Reference in New Issue
Block a user