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
+11 -1
View File
@@ -1,11 +1,21 @@
import "@refinedev/antd/dist/reset.css";
import "antd/dist/reset.css";
import type { ReactNode } from "react";
import { redirect } from "next/navigation";
import { AdminApp } from "@/components/admin/admin-app";
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
export const dynamic = "force-dynamic";
export default function AdminLayout({ children }: { children: ReactNode }) {
export default async function AdminLayout({ children }: { children: ReactNode }) {
try {
await requireAdminSession("read");
} catch (error) {
if (error instanceof AdminAuthorizationError) {
redirect(error.status === 401 ? "/login" : "/");
}
throw error;
}
return <AdminApp>{children}</AdminApp>;
}
+5
View File
@@ -0,0 +1,5 @@
import { PackageManagement } from "@/components/admin/package-management";
export default function AdminPackagesPage() {
return <PackageManagement />;
}
+5
View File
@@ -0,0 +1,5 @@
import PaymentManagement from "@/components/admin/payment-management";
export default function AdminPaymentsPage() {
return <PaymentManagement />;
}
+18 -5
View File
@@ -1,6 +1,19 @@
export function GET() {
return new Response(null, {
status: 307,
headers: { location: "/admin/codes" },
});
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
export async function GET() {
try {
await requireAdminSession("read");
return new Response(null, {
status: 307,
headers: { location: "/admin/codes" },
});
} catch (error) {
if (error instanceof AdminAuthorizationError) {
return new Response(null, {
status: 307,
headers: { location: error.status === 401 ? "/login" : "/" },
});
}
throw error;
}
}
+33
View File
@@ -0,0 +1,33 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
type AdminUser = { userId: string | null; email: string | null; createdAt: string | null; source: "env" | "database" };
async function loadUsers(): Promise<AdminUser[]> {
const response = await fetch("/api/admin/users", { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "暂时无法读取管理员列表");
return payload.users;
}
export default function AdminUsersPage() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [email, setEmail] = useState("");
const [error, setError] = useState("");
useEffect(() => { void loadUsers().then(setUsers).catch((caught) => setError(caught.message)); }, []);
async function add(event: FormEvent) {
event.preventDefault(); setError("");
const response = await fetch("/api/admin/users", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) });
const payload = await response.json().catch(() => null);
if (!response.ok) { setError(payload?.error || "添加失败"); return; }
setEmail(""); setUsers(await loadUsers());
}
async function revoke(userId: string) {
const response = await fetch("/api/admin/users", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ userId }) });
if (!response.ok) { const payload = await response.json().catch(() => null); setError(payload?.error || "撤销失败"); return; }
setUsers(await loadUsers());
}
return <main className="standalone-page admin-page"><header className="admin-header"><h1></h1><Link className="button-secondary" href="/admin/codes"></Link></header><div className="admin-scroll"><section className="admin-section"><div className="section-title"><div><h2></h2><p> Supabase </p></div></div><form className="code-form" onSubmit={add}><label className="note-field"><span></span><input type="email" required value={email} onChange={(event) => setEmail(event.target.value)} /></label><button className="button-primary" type="submit"></button></form>{error && <p className="form-error" role="alert">{error}</p>}</section><section className="admin-section"><div className="section-title"><div><h2></h2><p>{users.length} </p></div></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th></th><th></th><th></th><th></th></tr></thead><tbody>{users.map((user) => <tr key={`${user.source}-${user.userId ?? user.email}`}><td>{user.email || "—"}</td><td>{user.source === "env" ? "环境配置" : "后台配置"}</td><td>{user.createdAt ? new Date(user.createdAt).toLocaleString("zh-CN") : "—"}</td><td>{user.userId && <button className="button-secondary" type="button" onClick={() => void revoke(user.userId!)}></button>}</td></tr>)}</tbody></table></div></section></div></main>;
}
+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,
})),
});
}
+7
View File
@@ -803,6 +803,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); }
.admin-page { background: var(--color-canvas-soft); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; }
.admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
.admin-header h1 { font-size: var(--type-display-md); }
.admin-scroll { display: grid; width: min(1200px, 100%); gap: var(--space-5); margin: 0 auto; padding: var(--space-8) var(--space-8) var(--space-16); }
@@ -1780,3 +1782,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
}
.rectification-candidate { min-height: 122px; padding: 12px; scroll-snap-align: start; }
}
.payment-qr-wrap { position: relative; width: min(220px, 72vw); aspect-ratio: 1; margin: 14px auto; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow-elevated); }
.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; }
.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); }
.payment-qr-badge svg { display: block; width: 100%; height: 100%; }
+3 -24
View File
@@ -1,35 +1,14 @@
import { headers } from "next/headers";
import { EmailOtpLogin } from "@/components/email-otp-login";
import {
isSelfHostedIdentityEnabled,
readIdentityConfig,
readSelfHostedIdentityConfig,
} from "@/modules/identity/config";
import { resolveIdentitySurface } from "@/modules/identity/host";
import { readIdentityConfig } from "@/modules/identity/config";
export const dynamic = "force-dynamic";
export default async function LoginPage() {
const config = readIdentityConfig(process.env);
let provider = config.provider;
let passwordEnabled = false;
let passwordOnly = false;
if (isSelfHostedIdentityEnabled(process.env)) {
const selfHosted = readSelfHostedIdentityConfig(process.env);
const surface = resolveIdentitySurface(
(await headers()).get("host"),
selfHosted,
);
if (surface === "admin") provider = "self-hosted";
passwordEnabled = provider === "self-hosted";
passwordOnly = surface === "admin";
}
return (
<EmailOtpLogin
provider={provider}
passwordEnabled={passwordEnabled}
passwordOnly={passwordOnly}
provider={config.provider}
passwordEnabled={config.provider === "self-hosted"}
/>
);
}
+71 -5
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { ArrowUp, ArrowUpRight, ShieldCheck, Sparkles, Square, X } from "lucide-react";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
@@ -179,6 +179,7 @@ type Account = {
user: { id: string; email: string | null };
credits: number;
isAdmin: boolean;
adminUrl: string | null;
rectificationPriceCredits: number;
hasConfirmedBirthTime: boolean;
hasUsableBirthTime: boolean;
@@ -926,6 +927,11 @@ export default function Home() {
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [paymentPackages, setPaymentPackages] = useState<Array<{ id: string; name: string; description: string; priceCents: number; credits: number }>>([]);
const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null);
const [paymentError, setPaymentError] = useState("");
const [payingPackageId, setPayingPackageId] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
@@ -1221,6 +1227,7 @@ export default function Home() {
user: { id: "preview-user", email: "preview@local.test" },
credits: 8,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 1,
hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed",
hasUsableBirthTime: previewProfile.birthTimeStatus === "accepted" || previewProfile.birthTimeStatus === "confirmed",
@@ -1656,6 +1663,10 @@ export default function Home() {
case "redeem":
setRedeemError("");
setRedeemMessage("");
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
break;
case "logout":
break;
@@ -1929,6 +1940,47 @@ export default function Home() {
}
}
useEffect(() => {
if (activeAccountDialog !== "redeem") return;
void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
return;
}
if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试");
}).catch(() => {
setPaymentError("套餐支付暂时不可用,请稍后重试");
});
}, [activeAccountDialog]);
useEffect(() => {
if (!paymentOrder || paymentOrder.status === "paid") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
setPaymentOrder((current) => current ? { ...current, status: payload.status } : current);
if (payload.status === "paid") void refreshAccount();
});
}, 3000);
return () => window.clearInterval(timer);
}, [paymentOrder]);
async function createPayment(packageId: string) {
if (payingPackageId) return;
setPayingPackageId(packageId); setPaymentError("");
try {
const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败");
setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" });
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); }
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
@@ -2682,6 +2734,7 @@ export default function Home() {
email: account.user.email || "尚未读取邮箱",
credits: account.credits,
isAdmin: account.isAdmin,
adminUrl: account.adminUrl,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
|| "你",
@@ -2765,10 +2818,17 @@ export default function Home() {
? "正在校正出生时间"
: personalChartAvailable ? "基于星盘证据回答" : "回答一般占星知识"}</span>
</div>
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
<div className="chat-header-actions">
{account.isAdmin && account.adminUrl ? (
<Link className="admin-button" href={account.adminUrl} aria-label="后台管理" title="后台管理">
<ShieldCheck aria-hidden="true" />
</Link>
) : null}
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
</div>
</header>
{!rectificationSurfaceOpen && (
@@ -3157,6 +3217,12 @@ export default function Home() {
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
</form>
{paymentEnabled && <div className="payment-packages">
<h3></h3>
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || `${item.credits}`}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "立即支付"}</button></div>)}
{paymentOrder && <div className="payment-status" role="status"><p> {paymentOrder.orderNo}{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}</p></div>}
</div>}
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
</>
)}