feat: add configurable payments and Gitea staging delivery
Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest.
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { epaySign } from "@/lib/epay/sign";
|
||||
import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const schema = z.object({ packageId: z.string().uuid() });
|
||||
function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } }
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
|
||||
const admin = createAdminSupabaseClient(); const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle();
|
||||
if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 });
|
||||
const config = readEpayConfig(); const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
|
||||
const { error: orderError } = await admin.from("payment_orders").insert({ order_no: orderNo, user_id: user.id, package_id: pack.id, money_cents: pack.price_cents, credits: pack.credits });
|
||||
if (orderError) return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
|
||||
const params = { money: (pack.price_cents / 100).toFixed(2), name: pack.name, notify_url: config.notifyUrl, out_trade_no: orderNo, pid: config.pid, return_url: config.returnUrl, sitename: config.siteName, type: "alipay" };
|
||||
const body = new URLSearchParams({ ...params, sign: epaySign(params, config.key), sign_type: "MD5" });
|
||||
const upstream = await fetch(epaySubmitUrl(config.gatewayUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) });
|
||||
if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 });
|
||||
const text = await upstream.text(); let payload: Record<string, unknown> = {}; try { const json = JSON.parse(text); if (json && typeof json === "object") payload = json; } catch { /* gateway may return HTML */ }
|
||||
const payUrl = safeUpstreamUrl(payload.payurl ?? payload.pay_url ?? payload.url ?? (text.trim().startsWith("http") ? text.trim() : null), config.gatewayUrl);
|
||||
const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl);
|
||||
return NextResponse.json({ orderNo, payUrl, qrCode });
|
||||
} catch (error) {
|
||||
if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "创建支付失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { epaySign, timingSafeSignEqual } from "@/lib/epay/sign";
|
||||
import { readEpayConfig } from "@/lib/epay/config";
|
||||
export const runtime = "nodejs";
|
||||
async function notify(request: Request) {
|
||||
try {
|
||||
const config = readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record<string, string> = {}; params.forEach((value, key) => { values[key] = value; });
|
||||
if (!timingSafeSignEqual(values.sign, epaySign(values, config.key)) || values.pid !== config.pid || values.trade_status !== "TRADE_SUCCESS" || !values.out_trade_no || !values.money) return new NextResponse("success", { status: 200 });
|
||||
const moneyCents = Math.round(Number(values.money) * 100); if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new NextResponse("success", { status: 200 });
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
const { error } = await createAdminSupabaseClient().rpc("settle_epay_order", { p_order_no: values.out_trade_no, p_trade_no: values.trade_no || values.transaction_id || values.out_trade_no, p_money_cents: moneyCents, p_payload_hash: hash });
|
||||
if (error) return new NextResponse("success", { status: 200 });
|
||||
return new NextResponse("success", { status: 200 });
|
||||
} catch { return new NextResponse("success", { status: 200 }); }
|
||||
}
|
||||
export async function POST(request: Request) { return notify(request); }
|
||||
export async function GET(request: Request) { return notify(request); }
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
export const runtime = "nodejs";
|
||||
export async function GET(request: Request) { const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const orderNo = new URL(request.url).searchParams.get("orderNo"); if (!orderNo) return NextResponse.json({ error: "缺少订单号" }, { status: 400 }); const { data, error } = await createAdminSupabaseClient().from("payment_orders").select("order_no,status,credits,paid_at").eq("order_no", orderNo).eq("user_id", user.id).maybeSingle(); if (error) return NextResponse.json({ error: "暂时无法查询订单" }, { status: 500 }); if (!data) return NextResponse.json({ error: "订单不存在" }, { status: 404 }); return NextResponse.json({ orderNo: data.order_no, status: data.status, credits: data.credits, paidAt: data.paid_at }); }
|
||||
Reference in New Issue
Block a user