feat: Introduce web application with payment processing and Supabase authentication.

This commit is contained in:
2569718930@qq.com
2026-03-13 05:13:48 +08:00
parent bd839d0cbe
commit e62a36e0bc
16 changed files with 2150 additions and 2 deletions
+18
View File
@@ -45,6 +45,24 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=false
POLYWEATHER_REQUIRE_ENTITLEMENT=false POLYWEATHER_REQUIRE_ENTITLEMENT=false
POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN= POLYWEATHER_BACKEND_ENTITLEMENT_TOKEN=
# P1 Contract Checkout (MetaMask + Polygon USDC)
POLYWEATHER_PAYMENT_ENABLED=false
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
# JSON object
# Example: {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}}
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
# Polymarket P0 Read-Only Market Layer # Polymarket P0 Read-Only Market Layer
POLYMARKET_MARKET_SCAN_ENABLED=true POLYMARKET_MARKET_SCAN_ENABLED=true
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
+6
View File
@@ -94,6 +94,12 @@ flowchart TD
5. Observability: 5. Observability:
- Vercel Speed Insights integrated. - Vercel Speed Insights integrated.
- Telegram alert/watcher startup diagnostics exposed through `/diag`. - Telegram alert/watcher startup diagnostics exposed through `/diag`.
6. P1 contract checkout (new):
- New payment APIs: `/api/payments/config|wallets|intents/*`.
- MetaMask wallet binding via nonce challenge + `personal_sign`.
- Frontend receives contract `tx_payload` and calls `eth_sendTransaction`.
- Backend validates `OrderPaid(orderId,payer,planId,token,amount)` onchain event and auto-grants entitlement.
- Confirmation writes `payments/subscriptions/entitlement_events` and can notify Telegram.
## Repository Layout ## Repository Layout
+6
View File
@@ -94,6 +94,12 @@ flowchart TD
5. 可观测性: 5. 可观测性:
- 前端集成 Vercel Speed Insights。 - 前端集成 Vercel Speed Insights。
- Bot 启动和后台循环状态可通过 `/diag` 查看。 - Bot 启动和后台循环状态可通过 `/diag` 查看。
6. P1 合约支付链路(新增):
- 新增支付接口:`/api/payments/config|wallets|intents/*`
- 支持 MetaMask 钱包绑定(nonce + `personal_sign` 验签)。
- 支持合约订单支付:前端拿 `tx_payload``eth_sendTransaction`
- 后端按 `OrderPaid(orderId,payer,planId,token,amount)` 事件验单并自动开通订阅。
- 交易确认后自动写入 `payments/subscriptions/entitlement_events` 并可推送 Telegram。
## 目录说明 ## 目录说明
+43 -1
View File
@@ -1,4 +1,4 @@
# Supabase + Google 登录接入说明(P0 # Supabase + Google 登录 + 合约支付接入说明(P1
## 1. 目标 ## 1. 目标
@@ -26,6 +26,10 @@
- `subscriptions` - `subscriptions`
- `payments` - `payments`
- `entitlement_events` - `entitlement_events`
- `user_wallets`
- `wallet_link_challenges`
- `payment_intents`
- `payment_transactions`
并建立 `auth.users -> profiles` 同步触发器。 并建立 `auth.users -> profiles` 同步触发器。
@@ -56,6 +60,24 @@ SUPABASE_SERVICE_ROLE_KEY=
SUPABASE_HTTP_TIMEOUT_SEC=8 SUPABASE_HTTP_TIMEOUT_SEC=8
SUPABASE_AUTH_CACHE_TTL_SEC=30 SUPABASE_AUTH_CACHE_TTL_SEC=30
SUPABASE_SUB_CACHE_TTL_SEC=60 SUPABASE_SUB_CACHE_TTL_SEC=60
# P1 合约支付(MetaMask + Polygon USDC
POLYWEATHER_PAYMENT_ENABLED=true
POLYWEATHER_PAYMENT_CHAIN_ID=137
POLYWEATHER_PAYMENT_RPC_URL=https://polygon-rpc.com
POLYWEATHER_PAYMENT_RECEIVER_CONTRACT=0x<your_payment_contract>
POLYWEATHER_PAYMENT_TOKEN_ADDRESS=0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174
POLYWEATHER_PAYMENT_TOKEN_DECIMALS=6
POLYWEATHER_PAYMENT_CONFIRMATIONS=2
POLYWEATHER_PAYMENT_INTENT_TTL_SEC=1800
POLYWEATHER_PAYMENT_WALLET_CHALLENGE_TTL_SEC=600
POLYWEATHER_PAYMENT_HTTP_TIMEOUT_SEC=10
POLYWEATHER_PAYMENT_POLL_INTERVAL_SEC=4
POLYWEATHER_PAYMENT_MAX_WAIT_SEC=50
POLYWEATHER_PAYMENT_TELEGRAM_NOTIFY_ENABLED=true
# JSON 示例:
# {"pro_monthly":{"plan_id":101,"amount_usdc":"29","duration_days":30}}
POLYWEATHER_PAYMENT_PLAN_CATALOG_JSON=
``` ```
可选(Bot 也走 Supabase 订阅): 可选(Bot 也走 Supabase 订阅):
@@ -78,3 +100,23 @@ POLYWEATHER_BOT_USE_SUPABASE_ENTITLEMENT=true
2. `POLYWEATHER_AUTH_REQUIRED=false` 时,未登录访问首页与 `/api/cities` 应返回 200。 2. `POLYWEATHER_AUTH_REQUIRED=false` 时,未登录访问首页与 `/api/cities` 应返回 200。
3. 登录后访问 `/api/auth/me`,应返回 `authenticated=true``user_id` 3. 登录后访问 `/api/auth/me`,应返回 `authenticated=true``user_id`
4. `POLYWEATHER_AUTH_REQUIRED=true` 时,未登录访问受保护接口应返回 401 或跳转登录页。 4. `POLYWEATHER_AUTH_REQUIRED=true` 时,未登录访问受保护接口应返回 401 或跳转登录页。
## 7. P1 支付链路验证
1. 登录后访问 `GET /api/payments/config`,应看到 `enabled=true``configured=true`
2. 账户页点击“连接并绑定 MetaMask”,完成签名后 `GET /api/payments/wallets` 可看到地址。
3. 点击“创建订单并支付”:
- `POST /api/payments/intents`
- MetaMask 发交易到 `POLYWEATHER_PAYMENT_RECEIVER_CONTRACT`
- `POST /api/payments/intents/{intent_id}/submit`
- `POST /api/payments/intents/{intent_id}/confirm`
4. 确认后应自动写入:
- `payments``status=confirmed`
- `subscriptions`(新增 `active` 记录)
- `entitlement_events``subscription_granted`
合约事件要求:
- 合约需在 `pay(orderId, planId, amount, token)` 成功后发出
`OrderPaid(bytes32 orderId, address payer, uint256 planId, address token, uint256 amount)`
(字段顺序和类型需一致)。
+42
View File
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/config`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch payment config", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/confirm`,
{
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to confirm payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(
req: NextRequest,
context: { params: Promise<{ intentId: string }> },
) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
const { intentId } = await context.params;
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(
`${API_BASE}/api/payments/intents/${encodeURIComponent(intentId)}/submit`,
{
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
cache: "no-store",
},
);
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to submit payment tx", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/intents`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create payment intent", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets/challenge`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to create wallet challenge", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets`, {
headers: auth.headers,
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data, {
headers: { "Cache-Control": "no-store" },
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to fetch wallets", detail: String(error) },
{ status: 500 },
);
}
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function POST(req: NextRequest) {
if (!API_BASE) {
return NextResponse.json(
{ error: "POLYWEATHER_API_BASE_URL is not configured" },
{ status: 500 },
);
}
try {
const body = await req.json();
const auth = await buildBackendRequestHeaders(req);
const res = await fetch(`${API_BASE}/api/payments/wallets/verify`, {
method: "POST",
headers: {
...auth.headers,
"Content-Type": "application/json",
},
body: JSON.stringify(body ?? {}),
cache: "no-store",
});
if (!res.ok) {
const raw = await res.text();
const response = NextResponse.json(
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
{ status: 502 },
);
return applyAuthResponseCookies(response, auth.response);
}
const data = await res.json();
const response = NextResponse.json(data);
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return NextResponse.json(
{ error: "Failed to verify wallet binding", detail: String(error) },
{ status: 500 },
);
}
}
@@ -6,20 +6,24 @@ import { useRouter } from "next/navigation";
import type { User } from "@supabase/supabase-js"; import type { User } from "@supabase/supabase-js";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { import {
BadgeCheck,
Bot, Bot,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
Clock, Clock,
Copy, Copy,
Crown, Crown,
CreditCard,
Fingerprint, Fingerprint,
Hash, Hash,
Loader2, Loader2,
LogIn, LogIn,
LogOut, LogOut,
Mail, Mail,
Wallet,
RefreshCw, RefreshCw,
Shield, Shield,
Sparkles,
User as UserIcon, User as UserIcon,
UserCheck, UserCheck,
} from "lucide-react"; } from "lucide-react";
@@ -38,6 +42,56 @@ type AuthMeResponse = {
subscription_active?: boolean | null; subscription_active?: boolean | null;
}; };
type PaymentPlan = {
plan_code: string;
plan_id: number;
amount_usdc: string;
duration_days: number;
};
type PaymentConfig = {
enabled?: boolean;
configured?: boolean;
chain_id?: number;
token_address?: string;
token_decimals?: number;
receiver_contract?: string;
confirmations?: number;
plans?: PaymentPlan[];
};
type BoundWallet = {
chain_id: number;
address: string;
status: string;
is_primary: boolean;
verified_at?: string | null;
};
type CreatedIntent = {
intent?: {
intent_id: string;
order_id_hex: string;
plan_code: string;
amount_usdc: string;
allowed_wallet?: string | null;
};
tx_payload?: {
chain_id: number;
to: string;
data: string;
value: string;
};
};
declare global {
interface Window {
ethereum?: {
request: (args: { method: string; params?: any[] | object }) => Promise<any>;
};
}
}
type InfoItemProps = { type InfoItemProps = {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
@@ -72,6 +126,12 @@ function normalizeProvider(user: User | null) {
return ""; return "";
} }
function shortAddress(address: string) {
const text = String(address || "");
if (!text.startsWith("0x") || text.length < 12) return text || "--";
return `${text.slice(0, 8)}...${text.slice(-6)}`;
}
function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) { function InfoItem({ icon: Icon, label, value, status = "default" }: InfoItemProps) {
return ( return (
<div className="group flex items-center justify-between rounded-2xl border border-white/5 bg-white/5 p-4 transition-all hover:bg-white/10"> <div className="group flex items-center justify-between rounded-2xl border border-white/5 bg-white/5 p-4 transition-all hover:bg-white/10">
@@ -102,9 +162,52 @@ export function AccountCenter() {
const [updatedAt, setUpdatedAt] = useState<string>(""); const [updatedAt, setUpdatedAt] = useState<string>("");
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [backend, setBackend] = useState<AuthMeResponse | null>(null); const [backend, setBackend] = useState<AuthMeResponse | null>(null);
const [paymentConfig, setPaymentConfig] = useState<PaymentConfig | null>(null);
const [boundWallets, setBoundWallets] = useState<BoundWallet[]>([]);
const [walletAddress, setWalletAddress] = useState("");
const [selectedPlanCode, setSelectedPlanCode] = useState("pro_monthly");
const [selectedWallet, setSelectedWallet] = useState("");
const [paymentBusy, setPaymentBusy] = useState(false);
const [paymentInfo, setPaymentInfo] = useState("");
const [paymentError, setPaymentError] = useState("");
const [lastIntentId, setLastIntentId] = useState("");
const [lastTxHash, setLastTxHash] = useState("");
const supabaseReady = hasSupabasePublicEnv(); const supabaseReady = hasSupabasePublicEnv();
const loadPaymentSnapshot = useCallback(async () => {
if (!backend?.authenticated) {
setPaymentConfig(null);
setBoundWallets([]);
return;
}
try {
const [configRes, walletsRes] = await Promise.all([
fetch("/api/payments/config", { cache: "no-store" }),
fetch("/api/payments/wallets", { cache: "no-store" }),
]);
if (configRes.ok) {
const configJson = (await configRes.json()) as PaymentConfig;
setPaymentConfig(configJson);
if (!selectedPlanCode && Array.isArray(configJson.plans) && configJson.plans.length) {
setSelectedPlanCode(configJson.plans[0].plan_code);
}
}
if (walletsRes.ok) {
const walletsJson = (await walletsRes.json()) as {
wallets?: BoundWallet[];
};
const wallets = Array.isArray(walletsJson.wallets) ? walletsJson.wallets : [];
setBoundWallets(wallets);
if (wallets.length && !selectedWallet) {
setSelectedWallet(wallets[0].address);
}
}
} catch {
return;
}
}, [backend?.authenticated, selectedPlanCode, selectedWallet]);
const loadSnapshot = useCallback(async () => { const loadSnapshot = useCallback(async () => {
setErrorText(""); setErrorText("");
try { try {
@@ -146,9 +249,14 @@ export function AccountCenter() {
}; };
}, [loadSnapshot]); }, [loadSnapshot]);
useEffect(() => {
void loadPaymentSnapshot();
}, [loadPaymentSnapshot]);
const onRefresh = async () => { const onRefresh = async () => {
setRefreshing(true); setRefreshing(true);
await loadSnapshot(); await loadSnapshot();
await loadPaymentSnapshot();
setRefreshing(false); setRefreshing(false);
}; };
@@ -216,6 +324,177 @@ export function AccountCenter() {
} catch {} } catch {}
}; };
const planList = paymentConfig?.plans || [];
const selectedPlan = planList.find((p) => p.plan_code === selectedPlanCode) || planList[0];
const paymentFeatureReady = Boolean(paymentConfig?.enabled && paymentConfig?.configured);
const connectAndBindWallet = async () => {
setPaymentError("");
setPaymentInfo("");
if (!isAuthenticated) {
setPaymentError("请先登录后再绑定钱包。");
return;
}
const eth = window.ethereum;
if (!eth) {
setPaymentError("未检测到 MetaMask,请先安装扩展。");
return;
}
setPaymentBusy(true);
try {
const accounts = (await eth.request({
method: "eth_requestAccounts",
})) as string[];
const address = String(accounts?.[0] || "").toLowerCase();
if (!address) {
throw new Error("钱包账户为空");
}
setWalletAddress(address);
const challengeRes = await fetch("/api/payments/wallets/challenge", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address }),
});
if (!challengeRes.ok) {
const raw = (await challengeRes.text()).slice(0, 300);
throw new Error(`challenge failed: ${raw}`);
}
const challengeJson = (await challengeRes.json()) as {
nonce?: string;
message?: string;
};
const message = String(challengeJson.message || "");
const nonce = String(challengeJson.nonce || "");
if (!message || !nonce) {
throw new Error("challenge payload invalid");
}
const signature = (await eth.request({
method: "personal_sign",
params: [message, address],
})) as string;
const verifyRes = await fetch("/api/payments/wallets/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ address, nonce, signature }),
});
if (!verifyRes.ok) {
const raw = (await verifyRes.text()).slice(0, 300);
throw new Error(`verify failed: ${raw}`);
}
setPaymentInfo(`钱包绑定成功: ${shortAddress(address)}`);
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(String(error));
} finally {
setPaymentBusy(false);
}
};
const createIntentAndPay = async () => {
setPaymentError("");
setPaymentInfo("");
if (!isAuthenticated) {
setPaymentError("请先登录后再支付。");
return;
}
if (!paymentConfig?.configured) {
setPaymentError("支付服务未配置完成。");
return;
}
const eth = window.ethereum;
if (!eth) {
setPaymentError("未检测到 MetaMask。");
return;
}
const payingWallet = (selectedWallet || walletAddress || "").toLowerCase();
if (!payingWallet) {
setPaymentError("请先绑定钱包。");
return;
}
setPaymentBusy(true);
try {
const currentChainIdHex = String(
(await eth.request({ method: "eth_chainId" })) || "",
);
const targetChainId = Number(paymentConfig.chain_id || 137);
const targetChainHex = `0x${targetChainId.toString(16)}`;
if (currentChainIdHex.toLowerCase() !== targetChainHex.toLowerCase()) {
await eth.request({
method: "wallet_switchEthereumChain",
params: [{ chainId: targetChainHex }],
});
}
const createRes = await fetch("/api/payments/intents", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
plan_code: selectedPlanCode || "pro_monthly",
payment_mode: "strict",
allowed_wallet: payingWallet,
metadata: { source: "account_center" },
}),
});
if (!createRes.ok) {
const raw = (await createRes.text()).slice(0, 350);
throw new Error(`create intent failed: ${raw}`);
}
const created = (await createRes.json()) as CreatedIntent;
const intentId = String(created.intent?.intent_id || "");
const txPayload = created.tx_payload;
if (!intentId || !txPayload?.to || !txPayload?.data) {
throw new Error("intent payload invalid");
}
setLastIntentId(intentId);
const txHash = (await eth.request({
method: "eth_sendTransaction",
params: [
{
from: payingWallet,
to: txPayload.to,
data: txPayload.data,
value: txPayload.value || "0x0",
},
],
})) as string;
const txHashNorm = String(txHash || "").toLowerCase();
setLastTxHash(txHashNorm);
const submitRes = await fetch(`/api/payments/intents/${intentId}/submit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tx_hash: txHashNorm,
from_address: payingWallet,
}),
});
if (!submitRes.ok) {
const raw = (await submitRes.text()).slice(0, 350);
throw new Error(`submit tx failed: ${raw}`);
}
const confirmRes = await fetch(`/api/payments/intents/${intentId}/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tx_hash: txHashNorm }),
});
if (!confirmRes.ok) {
const raw = (await confirmRes.text()).slice(0, 350);
setPaymentInfo(`交易已提交: ${shortAddress(txHashNorm)},等待确认中。`);
throw new Error(`confirm pending: ${raw}`);
}
setPaymentInfo(`支付确认成功,交易: ${shortAddress(txHashNorm)}`);
await loadSnapshot();
await loadPaymentSnapshot();
} catch (error) {
setPaymentError(String(error));
} finally {
setPaymentBusy(false);
}
};
return ( return (
<div className="relative min-h-screen w-full overflow-hidden bg-[#0b0f1a] p-4 font-sans text-slate-200 md:p-8"> <div className="relative min-h-screen w-full overflow-hidden bg-[#0b0f1a] p-4 font-sans text-slate-200 md:p-8">
<div className="pointer-events-none absolute right-0 top-0 h-[500px] w-[500px] rounded-full bg-blue-600/10 blur-[120px]" /> <div className="pointer-events-none absolute right-0 top-0 h-[500px] w-[500px] rounded-full bg-blue-600/10 blur-[120px]" />
@@ -350,6 +629,138 @@ export function AccountCenter() {
</section> </section>
</div> </div>
<div className="lg:col-span-12">
<section className="rounded-3xl border border-emerald-500/20 bg-gradient-to-r from-emerald-600/10 to-cyan-600/10 p-8 backdrop-blur-sm">
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h3 className="flex items-center gap-2 text-lg font-bold">
<Wallet size={20} className="text-emerald-300" /> P1
</h3>
<span className="rounded-full border border-white/10 bg-white/10 px-3 py-1 text-xs text-slate-300">
Chain #{paymentConfig?.chain_id || 137}
</span>
</div>
{!isAuthenticated ? (
<p className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
</p>
) : null}
{isAuthenticated && !paymentFeatureReady ? (
<p className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-4 py-3 text-sm text-rose-300">
`POLYWEATHER_PAYMENT_*`
</p>
) : null}
{isAuthenticated && paymentFeatureReady ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
<div className="mb-3 flex items-center justify-between">
<p className="flex items-center gap-2 text-sm font-semibold text-slate-200">
<BadgeCheck size={16} className="text-emerald-300" />
</p>
<button
type="button"
onClick={() => void connectAndBindWallet()}
disabled={paymentBusy}
className="rounded-lg border border-emerald-400/30 bg-emerald-500/20 px-3 py-1 text-xs text-emerald-200 transition hover:bg-emerald-500/30 disabled:opacity-60"
>
MetaMask
</button>
</div>
<div className="space-y-2">
{boundWallets.length ? (
boundWallets.map((wallet) => (
<label
key={wallet.address}
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-slate-200"
>
<span className="flex items-center gap-2">
<input
type="radio"
name="payWallet"
className="h-4 w-4"
checked={selectedWallet === wallet.address}
onChange={() => setSelectedWallet(wallet.address)}
/>
{shortAddress(wallet.address)}
{wallet.is_primary ? (
<span className="rounded bg-blue-500/30 px-1.5 py-0.5 text-[10px] text-blue-100">
</span>
) : null}
</span>
<span className="text-xs text-slate-400">{wallet.status}</span>
</label>
))
) : (
<p className="text-sm text-slate-400"></p>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/30 p-4">
<p className="mb-3 flex items-center gap-2 text-sm font-semibold text-slate-200">
<CreditCard size={16} className="text-cyan-300" />
</p>
<div className="mb-3 space-y-2">
{planList.map((plan) => (
<label
key={plan.plan_code}
className="flex cursor-pointer items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm"
>
<span className="flex items-center gap-2 text-slate-200">
<input
type="radio"
name="payPlan"
className="h-4 w-4"
checked={selectedPlanCode === plan.plan_code}
onChange={() => setSelectedPlanCode(plan.plan_code)}
/>
{plan.plan_code}
</span>
<span className="text-cyan-200">
{plan.amount_usdc} USDC / {plan.duration_days}
</span>
</label>
))}
</div>
<button
type="button"
onClick={() => void createIntentAndPay()}
disabled={paymentBusy || !selectedPlan || !selectedWallet}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-cyan-600 px-4 py-3 text-sm font-semibold text-white transition hover:from-emerald-500 hover:to-cyan-500 disabled:opacity-60"
>
{paymentBusy ? <Loader2 size={16} className="animate-spin" /> : <Sparkles size={16} />}
</button>
{lastIntentId ? (
<p className="mt-2 text-xs text-slate-400">
Intent: {lastIntentId}
</p>
) : null}
{lastTxHash ? (
<p className="mt-1 text-xs text-slate-400">Tx: {shortAddress(lastTxHash)}</p>
) : null}
</div>
</div>
) : null}
{paymentInfo ? (
<p className="mt-4 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-200">
{paymentInfo}
</p>
) : null}
{paymentError ? (
<p className="mt-3 rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-300">
{paymentError}
</p>
) : null}
</section>
</div>
<div className="lg:col-span-12"> <div className="lg:col-span-12">
<section className="group relative overflow-hidden rounded-3xl border border-blue-500/20 bg-gradient-to-r from-blue-600/10 to-indigo-600/10 p-8 backdrop-blur-sm"> <section className="group relative overflow-hidden rounded-3xl border border-blue-500/20 bg-gradient-to-r from-blue-600/10 to-indigo-600/10 p-8 backdrop-blur-sm">
<div className="absolute right-0 top-0 translate-x-1/4 -translate-y-1/4"> <div className="absolute right-0 top-0 translate-x-1/4 -translate-y-1/4">
+79 -1
View File
@@ -1,6 +1,8 @@
-- PolyWeather minimal commerce/auth schema (P0) -- PolyWeather minimal commerce/auth schema (P0)
-- Run in Supabase SQL editor. -- Run in Supabase SQL editor.
create extension if not exists pgcrypto;
create table if not exists public.profiles ( create table if not exists public.profiles (
id uuid primary key references auth.users(id) on delete cascade, id uuid primary key references auth.users(id) on delete cascade,
email text not null default '', email text not null default '',
@@ -48,6 +50,83 @@ create table if not exists public.entitlement_events (
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );
create table if not exists public.user_wallets (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
chain_id integer not null default 137,
address text not null,
status text not null default 'active' check (status in ('active', 'revoked')),
is_primary boolean not null default false,
verified_at timestamptz,
last_used_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique(chain_id, address)
);
create index if not exists idx_user_wallets_user_chain
on public.user_wallets(user_id, chain_id, status, is_primary desc, verified_at desc);
create table if not exists public.wallet_link_challenges (
id bigserial primary key,
user_id uuid not null references auth.users(id) on delete cascade,
chain_id integer not null default 137,
address text not null,
nonce text not null unique,
message text not null,
expires_at timestamptz not null,
consumed_at timestamptz,
created_at timestamptz not null default now()
);
create index if not exists idx_wallet_link_challenges_lookup
on public.wallet_link_challenges(user_id, chain_id, address, nonce, created_at desc);
create table if not exists public.payment_intents (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
plan_code text not null,
plan_id bigint not null,
chain_id integer not null default 137,
token_address text not null,
receiver_address text not null,
amount_units numeric(78,0) not null,
payment_mode text not null default 'strict' check (payment_mode in ('strict', 'flex')),
allowed_wallet text,
order_id_hex text not null unique,
status text not null default 'created' check (status in ('created', 'submitted', 'confirmed', 'expired', 'failed', 'cancelled')),
tx_hash text,
expires_at timestamptz not null,
confirmed_at timestamptz,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_payment_intents_user_status
on public.payment_intents(user_id, status, created_at desc);
create index if not exists idx_payment_intents_tx_hash
on public.payment_intents(tx_hash);
create table if not exists public.payment_transactions (
id bigserial primary key,
intent_id uuid not null references public.payment_intents(id) on delete cascade,
tx_hash text not null unique,
chain_id integer not null default 137,
from_address text not null,
to_address text not null,
block_number bigint,
status text not null default 'submitted' check (status in ('submitted', 'confirmed', 'failed')),
raw_receipt jsonb not null default '{}'::jsonb,
raw_tx jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create index if not exists idx_payment_transactions_intent
on public.payment_transactions(intent_id, created_at desc);
create or replace function public.sync_profile_from_auth() create or replace function public.sync_profile_from_auth()
returns trigger returns trigger
language plpgsql language plpgsql
@@ -68,4 +147,3 @@ drop trigger if exists on_auth_user_created_polyweather on auth.users;
create trigger on_auth_user_created_polyweather create trigger on_auth_user_created_polyweather
after insert on auth.users after insert on auth.users
for each row execute function public.sync_profile_from_auth(); for each row execute function public.sync_profile_from_auth();
+14
View File
@@ -0,0 +1,14 @@
from .contract_checkout import (
PAYMENT_CHECKOUT,
PaymentCheckoutError,
PaymentIntentRecord,
WalletBindingRecord,
)
__all__ = [
"PAYMENT_CHECKOUT",
"PaymentCheckoutError",
"PaymentIntentRecord",
"WalletBindingRecord",
]
File diff suppressed because it is too large Load Diff
+154
View File
@@ -23,6 +23,7 @@ if _file_dir not in sys.path:
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from loguru import logger from loguru import logger
from src.utils.config_loader import load_config from src.utils.config_loader import load_config
@@ -35,6 +36,7 @@ from src.auth.supabase_entitlement import (
SUPABASE_ENTITLEMENT, SUPABASE_ENTITLEMENT,
extract_bearer_token, extract_bearer_token,
) )
from src.payments import PAYMENT_CHECKOUT, PaymentCheckoutError
# ────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────
# Setup # Setup
@@ -157,6 +159,52 @@ def _assert_entitlement(request: Request) -> None:
raise HTTPException(status_code=401, detail="Unauthorized") raise HTTPException(status_code=401, detail="Unauthorized")
def _require_supabase_identity(request: Request) -> Dict[str, str]:
if not SUPABASE_ENTITLEMENT.enabled:
raise HTTPException(
status_code=503,
detail="payment requires POLYWEATHER_AUTH_ENABLED=true",
)
if not SUPABASE_ENTITLEMENT.configured:
raise HTTPException(
status_code=503,
detail="payment requires SUPABASE_URL and SUPABASE_ANON_KEY",
)
token = extract_bearer_token(request.headers.get("authorization"))
if not token:
raise HTTPException(status_code=401, detail="Unauthorized")
identity = SUPABASE_ENTITLEMENT.get_identity(token)
if not identity:
raise HTTPException(status_code=401, detail="Unauthorized")
return {"user_id": identity.user_id, "email": identity.email}
class WalletChallengeRequest(BaseModel):
address: str = Field(..., min_length=8)
class WalletVerifyRequest(BaseModel):
address: str = Field(..., min_length=8)
nonce: str = Field(..., min_length=6)
signature: str = Field(..., min_length=20)
class CreatePaymentIntentRequest(BaseModel):
plan_code: str = Field(default="pro_monthly", min_length=2)
payment_mode: str = Field(default="strict")
allowed_wallet: Optional[str] = None
metadata: Dict[str, Any] = Field(default_factory=dict)
class SubmitPaymentTxRequest(BaseModel):
tx_hash: str = Field(..., min_length=10)
from_address: str = Field(..., min_length=8)
class ConfirmPaymentTxRequest(BaseModel):
tx_hash: Optional[str] = None
def _sf(v) -> Optional[float]: def _sf(v) -> Optional[float]:
"""Safe float conversion.""" """Safe float conversion."""
if v is None: if v is None:
@@ -1058,6 +1106,112 @@ async def auth_me(request: Request):
} }
@app.get("/api/payments/config")
async def payment_config(request: Request):
_assert_entitlement(request)
try:
return PAYMENT_CHECKOUT.get_config_payload()
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.get("/api/payments/wallets")
async def payment_wallets(request: Request):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
wallets = PAYMENT_CHECKOUT.list_wallets(identity["user_id"])
return {
"wallets": [w.__dict__ for w in wallets],
"chain_id": PAYMENT_CHECKOUT.chain_id,
}
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/wallets/challenge")
async def payment_wallet_challenge(request: Request, body: WalletChallengeRequest):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
challenge = PAYMENT_CHECKOUT.create_wallet_challenge(
user_id=identity["user_id"],
address=body.address,
)
return challenge
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/wallets/verify")
async def payment_wallet_verify(request: Request, body: WalletVerifyRequest):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
bound = PAYMENT_CHECKOUT.verify_wallet_binding(
user_id=identity["user_id"],
address=body.address,
nonce=body.nonce,
signature=body.signature,
)
return {"wallet": bound.__dict__}
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/intents")
async def payment_create_intent(request: Request, body: CreatePaymentIntentRequest):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
return PAYMENT_CHECKOUT.create_intent(
user_id=identity["user_id"],
plan_code=body.plan_code,
payment_mode=body.payment_mode,
allowed_wallet=body.allowed_wallet,
metadata=body.metadata,
)
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/intents/{intent_id}/submit")
async def payment_submit_tx(
request: Request,
intent_id: str,
body: SubmitPaymentTxRequest,
):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
return PAYMENT_CHECKOUT.submit_intent_tx(
user_id=identity["user_id"],
intent_id=intent_id,
tx_hash=body.tx_hash,
from_address=body.from_address,
)
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.post("/api/payments/intents/{intent_id}/confirm")
async def payment_confirm_tx(
request: Request,
intent_id: str,
body: ConfirmPaymentTxRequest,
):
_assert_entitlement(request)
identity = _require_supabase_identity(request)
try:
return PAYMENT_CHECKOUT.confirm_intent_tx(
user_id=identity["user_id"],
intent_id=intent_id,
tx_hash=body.tx_hash,
)
except PaymentCheckoutError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
@app.get("/api/city/{name}/summary") @app.get("/api/city/{name}/summary")
async def city_summary(request: Request, name: str, force_refresh: bool = False): async def city_summary(request: Request, name: str, force_refresh: bool = False):
_assert_entitlement(request) _assert_entitlement(request)