Merge GitHub upstream into Gitea primary
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PackageManagement } from "@/components/admin/package-management";
|
||||
|
||||
export default function AdminPackagesPage() {
|
||||
return <PackageManagement />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PaymentManagement from "@/components/admin/payment-management";
|
||||
|
||||
export default function AdminPaymentsPage() {
|
||||
return <PaymentManagement />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user