Merge GitHub upstream into Gitea primary

This commit is contained in:
Jesse_Chen
2026-08-05 11:13:34 +08:00
105 changed files with 3612 additions and 886 deletions
+5 -2
View File
@@ -8,7 +8,7 @@ import {
applyAccountProfileConcurrencyGuards,
resolveAccountBirthTimeApplicationPatch,
} from "@/lib/account-profile-patch";
import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin";
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
import {
isSupabaseConfigurationError,
} from "@/lib/supabase/config";
@@ -109,11 +109,14 @@ export async function GET() {
profile,
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
);
const isAdmin = await isAdminUser(user);
const adminUrl = isAdmin ? "/admin/codes" : null;
return NextResponse.json({
user: { id: user.id, email: user.email ?? null },
credits: profile.credits,
isAdmin: isAdminEmail(user.email),
isAdmin,
adminUrl,
rectificationPriceCredits,
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
&& typeof profile.active_birth_time === "string",
@@ -0,0 +1,132 @@
import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
import { suggestedEpayUrls } from "@/lib/epay/config";
import { encryptEpayKey } from "@/lib/epay/encryption";
export const runtime = "nodejs";
const httpUrl = z.string().trim().min(1).max(2048).url().refine((value) => /^https?:\/\//i.test(value), "必须使用 HTTP(S)");
const settingsSchema = z.object({
gatewayUrl: httpUrl,
pid: z.string().trim().min(1).max(200),
notifyUrl: httpUrl,
returnUrl: httpUrl,
siteName: z.string().trim().min(1).max(100),
chatEnabled: z.boolean(),
newKey: z.string().min(1).max(1000).optional(),
}).strict();
type SettingsRow = {
gateway_url: string;
pid: string;
encrypted_key: string;
notify_url: string;
return_url: string;
site_name: string;
chat_enabled: boolean;
updated_at?: Date;
};
function publicSettings(row: SettingsRow, source: "database" | "environment") {
return {
gatewayUrl: row.gateway_url,
pid: row.pid,
notifyUrl: row.notify_url,
returnUrl: row.return_url,
siteName: row.site_name,
chatEnabled: row.chat_enabled,
keyConfigured: Boolean(row.encrypted_key),
complete: Boolean(row.gateway_url && row.pid && row.encrypted_key && row.notify_url && row.return_url && row.site_name),
source,
updatedAt: source === "database" ? row.updated_at?.toISOString() ?? null : null,
};
}
function environmentSettings() {
const defaults = suggestedEpayUrls();
const row: SettingsRow = {
gateway_url: process.env.EPAY_GATEWAY_URL?.trim() || "",
pid: process.env.EPAY_PID?.trim() || "",
encrypted_key: process.env.EPAY_KEY?.trim() ? "configured" : "",
notify_url: process.env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl,
return_url: process.env.EPAY_RETURN_URL?.trim() || defaults.returnUrl,
site_name: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
chat_enabled: ["true", "1"].includes(process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase() || ""),
};
return publicSettings(row, "environment");
}
async function databaseRow() {
try {
const rows = await queryAdminRows<SettingsRow>(`
select gateway_url, pid, encrypted_key, notify_url, return_url, site_name, chat_enabled, updated_at
from public.epay_settings
where id = true
limit 1
`);
return rows[0] ?? null;
} catch (error) {
if (isPostgresError(error) && error.code === "42P01") return null;
throw error;
}
}
export async function GET() {
try {
await requireAdminSession("read");
const row = await databaseRow();
if (row) return NextResponse.json(publicSettings(row, "database"));
const settings = environmentSettings();
return NextResponse.json(settings.complete || settings.keyConfigured
? settings
: { ...settings, source: "unconfigured" });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function PUT(request: Request) {
try {
const session = await requireAdminSession("write");
const parsed = settingsSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 });
const existing = await databaseRow();
if (!existing && !parsed.data.newKey) {
return NextResponse.json({ error: "首次保存数据库配置时必须输入新的商户密钥" }, { status: 400 });
}
const encryptedKey = parsed.data.newKey
? encryptEpayKey(parsed.data.newKey)
: existing!.encrypted_key;
try {
const rows = await queryAdminRows<SettingsRow>(`
select * from public.admin_save_epay_settings(
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
)
`, [
session.user.id,
session.user.email,
session.role,
crypto.randomUUID(),
parsed.data.gatewayUrl.replace(/\/+$/, ""),
parsed.data.pid,
encryptedKey,
parsed.data.notifyUrl,
parsed.data.returnUrl,
parsed.data.siteName,
parsed.data.chatEnabled,
Boolean(parsed.data.newKey),
]);
if (!rows[0]) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
return NextResponse.json(publicSettings(rows[0], "database"));
} catch {
return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
}
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,46 @@
import { NextResponse } from "next/server";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
import { adminErrorResponse } from "@/lib/admin/http";
import { epaySubmitUrl, readEpayConfig } from "@/lib/epay/config";
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
export const runtime = "nodejs";
function reachableStatus(status: number) {
return status >= 200 && status < 500;
}
export async function POST() {
try {
await requireAdminSession("write");
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
const startedAt = performance.now();
let response = await fetch(submitUrl, {
method: "HEAD",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
if (response.status === 405 || response.status === 501) {
response = await fetch(submitUrl, {
method: "GET",
redirect: "manual",
signal: AbortSignal.timeout(8_000),
});
}
const available = reachableStatus(response.status);
return NextResponse.json({
available,
message: available ? "当前已保存的易支付配置可访问" : "当前已保存的易支付配置暂不可用",
latencyMs: Math.round(performance.now() - startedAt),
status: response.status,
});
} catch (error) {
if (error instanceof AdminAuthorizationError) return adminErrorResponse(error);
return NextResponse.json({
available: false,
message: "当前已保存的易支付配置暂不可用",
});
}
}
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
export const runtime = "nodejs";
const schema = z.object({
name: z.string().trim().min(1).max(80),
description: z.string().trim().max(500),
priceCents: z.number().int().positive().max(100_000_000),
credits: z.number().int().positive().max(10_000_000),
sortOrder: z.number().int().min(-100_000).max(100_000),
enabled: z.boolean(),
}).strict();
const updateSchema = schema.extend({ id: z.string().uuid() });
const idSchema = z.object({ id: z.string().uuid() }).strict();
type PackageRow = {
id: string;
name: string;
description: string;
price_cents: number;
credits: number;
sort_order: number;
enabled: boolean;
created_at: Date;
updated_at: Date;
};
function output(row: PackageRow) {
return {
id: row.id,
name: row.name,
description: row.description,
priceCents: row.price_cents,
credits: row.credits,
sortOrder: row.sort_order,
enabled: row.enabled,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
export async function GET() {
try {
await requireAdminSession("read");
const rows = await queryAdminRows<PackageRow>(`
select id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
from public.payment_packages
order by sort_order, created_at
`);
return NextResponse.json({ packages: rows.map(output) });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const auth = await requireAdminSession("write");
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const p = parsed.data;
const rows = await queryAdminRows<PackageRow>(`
insert into public.payment_packages
(name, description, price_cents, credits, sort_order, enabled, created_by)
values ($1, $2, $3, $4, $5, $6, $7)
returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
`, [p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled, auth.user.id]);
return NextResponse.json({ package: output(rows[0]) }, { status: 201 });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
await requireAdminSession("write");
const parsed = updateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const p = parsed.data;
const rows = await queryAdminRows<PackageRow>(`
update public.payment_packages
set name = $2, description = $3, price_cents = $4, credits = $5,
sort_order = $6, enabled = $7, updated_at = clock_timestamp()
where id = $1
returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
`, [p.id, p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled]);
if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 });
return NextResponse.json({ package: output(rows[0]) });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
await requireAdminSession("write");
const parsed = idSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const rows = await queryAdminRows<{ id: string }>(`
update public.payment_packages
set enabled = false, updated_at = clock_timestamp()
where id = $1
returning id
`, [parsed.data.id]);
if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (error) {
return adminErrorResponse(error);
}
}
@@ -0,0 +1,128 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
export const runtime = "nodejs";
const querySchema = z.object({
status: z.enum(["pending", "paid", "failed", "expired"]).optional(),
from: z.string().datetime({ offset: true }).optional(),
to: z.string().datetime({ offset: true }).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
offset: z.coerce.number().int().min(0).default(0),
});
type PaymentOrderRow = {
order_no: string;
user_email: string | null;
package_name: string | null;
money_cents: number;
credits: number;
status: string;
epay_trade_no: string | null;
created_at: Date;
paid_at: Date | null;
total_count: string;
};
type PaymentStatsRow = {
total_orders: string;
paid_orders: string;
pending_orders: string;
failed_expired_orders: string;
paid_amount_cents: string;
granted_credits: string;
};
export async function GET(request: Request) {
try {
await requireAdminSession("read");
const url = new URL(request.url);
const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams));
if (!parsed.success) return NextResponse.json({ error: "查询参数不正确" }, { status: 400 });
const { status, from, to, limit, offset } = parsed.data;
if (from && to && new Date(from) > new Date(to)) return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
const values: unknown[] = [];
const conditions: string[] = [];
if (status) {
values.push(status);
conditions.push(`o.status = $${values.length}`);
}
if (from) {
values.push(from);
conditions.push(`o.created_at >= $${values.length}::timestamptz`);
}
if (to) {
values.push(to);
conditions.push(`o.created_at <= $${values.length}::timestamptz`);
}
const statsValues: unknown[] = [];
const dateConditions: string[] = [];
if (from) {
statsValues.push(from);
dateConditions.push(`o.created_at >= $${statsValues.length}::timestamptz`);
}
if (to) {
statsValues.push(to);
dateConditions.push(`o.created_at <= $${statsValues.length}::timestamptz`);
}
values.push(limit, offset);
const [rows, statsRows] = await Promise.all([
queryAdminRows<PaymentOrderRow>(`
select
o.order_no, u.email as user_email, p.name as package_name,
o.money_cents, o.credits, o.status, o.epay_trade_no,
o.created_at, o.paid_at, count(*) over()::text as total_count
from public.payment_orders o
left join public.payment_packages p on p.id = o.package_id
left join identity.users u on u.id = o.user_id
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
order by o.created_at desc, o.order_no asc
limit $${values.length - 1} offset $${values.length}
`, values),
queryAdminRows<PaymentStatsRow>(`
select
count(*)::text as total_orders,
count(*) filter (where o.status = 'paid')::text as paid_orders,
count(*) filter (where o.status = 'pending')::text as pending_orders,
count(*) filter (where o.status in ('failed', 'expired'))::text as failed_expired_orders,
coalesce(sum(o.money_cents) filter (where o.status = 'paid'), 0)::text as paid_amount_cents,
coalesce(sum(o.credits) filter (where o.status = 'paid'), 0)::text as granted_credits
from public.payment_orders o
${dateConditions.length ? `where ${dateConditions.join(" and ")}` : ""}
`, statsValues),
]);
const orders = rows.map((row) => ({
orderNo: row.order_no,
userEmail: row.user_email,
packageName: row.package_name,
moneyCents: row.money_cents,
credits: row.credits,
status: row.status,
epayTradeNo: row.epay_trade_no,
createdAt: row.created_at.toISOString(),
paidAt: row.paid_at?.toISOString() ?? null,
}));
const rawStats = statsRows[0];
const stats = {
totalOrders: Number(rawStats?.total_orders ?? 0),
paidOrders: Number(rawStats?.paid_orders ?? 0),
pendingOrders: Number(rawStats?.pending_orders ?? 0),
failedExpiredOrders: Number(rawStats?.failed_expired_orders ?? 0),
paidAmountCents: Number(rawStats?.paid_amount_cents ?? 0),
grantedCredits: Number(rawStats?.granted_credits ?? 0),
};
const total = Number(rows[0]?.total_count ?? 0);
return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } });
} catch (error) {
const response = adminErrorResponse(error);
if (response.status === 401 || response.status === 403) return response;
return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 });
}
}
@@ -24,7 +24,6 @@ async function dispatch(
const services = getIdentityAuthServices();
const handlers = createHostIsolatedAuthHandlers(config, {
user: toNextJsHandler(services.user),
admin: toNextJsHandler(services.admin),
});
return handlers[method](request);
}
@@ -0,0 +1,53 @@
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 { readEpayAvailability } from "@/lib/epay/availability";
import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config";
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
export const runtime = "nodejs";
const schema = z.object({ packageId: z.string().uuid() });
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 availability = await readEpayAvailability();
if (!availability.enabled) return NextResponse.json({ error: "在线支付暂未开放", code: "EPAY_DISABLED" }, { status: 403 });
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
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 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 signedParams = { ...params, sign: epaySign(params, config.key), sign_type: "MD5" };
const payUrl = new URL(submitUrl);
for (const [name, value] of Object.entries(signedParams)) payUrl.searchParams.set(name, value);
return NextResponse.json({ orderNo, payUrl: payUrl.toString(), qrCode: null });
} 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,13 @@
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { readEpayConfig } from "@/lib/epay/config";
import { createEpayNotifyHandler } from "@/lib/epay/notify-core";
export const runtime = "nodejs";
const notify = createEpayNotifyHandler({
readConfig: readEpayConfig,
settle: async (args) => await createAdminSupabaseClient().rpc("settle_epay_order", args),
});
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 }); }
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { readEpayAvailability } from "@/lib/epay/availability";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
export async function GET() {
const availability = await readEpayAvailability();
if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] });
const { data, error } = await createAdminSupabaseClient()
.from("payment_packages")
.select("id,name,description,price_cents,credits,sort_order")
.eq("enabled", true)
.order("sort_order")
.order("created_at");
if (error) return NextResponse.json({ enabled: false, packages: [] });
return NextResponse.json({
enabled: true,
packages: (data || []).map((item) => ({
id: item.id,
name: item.name,
description: item.description,
priceCents: item.price_cents,
credits: item.credits,
})),
});
}