feat: add configurable payments and Gitea staging delivery
Add database-backed administrators, configurable Alipay packages, idempotent payment settlement and platform reporting. Standardize Gitea workflows on the xiaoxin runner so reviewed staging commits are tested, packaged, and deployed by immutable image digest.
This commit is contained in:
@@ -147,7 +147,7 @@ export default function AdminCodesPage() {
|
||||
<main className="standalone-page admin-page">
|
||||
<header className="admin-header">
|
||||
<h1>兑换码管理</h1>
|
||||
<Link className="button-secondary" href="/">返回对话</Link>
|
||||
<div><Link className="button-secondary" href="/admin/users">管理员管理</Link> <Link className="button-secondary" href="/admin/packages">充值套餐</Link> <Link className="button-secondary" href="/admin/payments">支付记录</Link> <Link className="button-secondary" href="/">返回对话</Link></div>
|
||||
</header>
|
||||
|
||||
<div className="admin-scroll">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactNode } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { isAdminEmail } from "@/lib/supabase/admin";
|
||||
import { isAdminUser } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,7 +12,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (!user) redirect("/login");
|
||||
if (!isAdminEmail(user.email)) redirect("/");
|
||||
if (!(await isAdminUser(user))) redirect("/");
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
type Package = { id: string; name: string; description: string; priceCents: number; credits: number; sortOrder: number; enabled: boolean };
|
||||
const empty = { name: "", description: "", priceCents: 100, credits: 10, sortOrder: 0, enabled: true };
|
||||
export default function AdminPackagesPage() { const [items, setItems] = useState<Package[]>([]); const [form, setForm] = useState(empty); const [editing, setEditing] = useState<string | null>(null); const [error, setError] = useState("");
|
||||
async function load() { const response = await fetch("/api/admin/packages", { cache: "no-store" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "读取失败"); setItems(data.packages); }
|
||||
useEffect(() => { void load().catch((e) => setError(e.message)); }, []);
|
||||
async function save(event: FormEvent) { event.preventDefault(); setError(""); const response = await fetch("/api/admin/packages", { method: editing ? "PATCH" : "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(editing ? { ...form, id: editing } : form) }); const data = await response.json(); if (!response.ok) { setError(data.error || "保存失败"); return; } setForm(empty); setEditing(null); await load(); }
|
||||
async function disable(id: string) { const response = await fetch("/api/admin/packages", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ id }) }); if (!response.ok) setError("停用失败"); else await load(); }
|
||||
return <main className="standalone-page admin-page"><header className="admin-header"><h1>充值套餐</h1><div><Link className="button-secondary" href="/admin/codes">兑换码管理</Link> <Link className="button-secondary" href="/admin/payments">支付记录</Link> <Link className="button-secondary" href="/">返回对话</Link></div></header><div className="admin-scroll"><section className="admin-section"><div className="section-title"><div><h2>{editing ? "编辑套餐" : "添加套餐"}</h2><p>只配置公开套餐,不显示易支付密钥。</p></div></div><form className="code-form" onSubmit={save}><label><span>名称</span><input required maxLength={80} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></label><label><span>价格(分)</span><input type="number" min={1} required value={form.priceCents} onChange={(e) => setForm({ ...form, priceCents: Number(e.target.value) })} /></label><label><span>点数</span><input type="number" min={1} required value={form.credits} onChange={(e) => setForm({ ...form, credits: Number(e.target.value) })} /></label><label><span>排序</span><input type="number" value={form.sortOrder} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} /></label><label className="note-field"><span>描述</span><input maxLength={500} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></label><label><span>启用</span><input type="checkbox" checked={form.enabled} onChange={(e) => setForm({ ...form, enabled: e.target.checked })} /></label><button className="button-primary" type="submit">{editing ? "保存修改" : "添加套餐"}</button></form>{error && <p className="form-error" role="alert">{error}</p>}</section><section className="admin-section"><div className="section-title"><div><h2>套餐列表</h2><p>{items.length} 个套餐</p></div></div><div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>名称</th><th>价格</th><th>点数</th><th>状态</th><th>操作</th></tr></thead><tbody>{items.map((item) => <tr key={item.id}><td>{item.name}<br /><small>{item.description}</small></td><td>¥{(item.priceCents / 100).toFixed(2)}</td><td>{item.credits}</td><td>{item.enabled ? "启用" : "停用"}</td><td><button className="button-secondary" type="button" onClick={() => { setEditing(item.id); setForm({ name: item.name, description: item.description, priceCents: item.priceCents, credits: item.credits, sortOrder: item.sortOrder, enabled: item.enabled }); }}>编辑</button> {item.enabled && <button className="button-secondary" type="button" onClick={() => void disable(item.id)}>停用</button>}</td></tr>)}</tbody></table></div></section></div></main>; }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Order = { orderNo: string; userEmail: string | null; packageName: string | null; moneyCents: number; credits: number; status: string; epayTradeNo: string | null; createdAt: string; paidAt: string | null };
|
||||
type Stats = { totalOrders: number; paidOrders: number; pendingOrders: number; failedExpiredOrders: number; paidAmountCents: number; grantedCredits: number };
|
||||
const initialStats: Stats = { totalOrders: 0, paidOrders: 0, pendingOrders: 0, failedExpiredOrders: 0, paidAmountCents: 0, grantedCredits: 0 };
|
||||
const statusLabels: Record<string, string> = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" };
|
||||
const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" });
|
||||
|
||||
function formatDate(value: string | null) { return value ? dateFormatter.format(new Date(value)) : "—"; }
|
||||
function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; }
|
||||
|
||||
export default function AdminPaymentsPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [stats, setStats] = useState(initialStats);
|
||||
const [status, setStatus] = useState("");
|
||||
const [from, setFrom] = useState("");
|
||||
const [to, setTo] = useState("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const limit = 20;
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
|
||||
if (status) params.set("status", status);
|
||||
if (from) params.set("from", new Date(`${from}T00:00:00+08:00`).toISOString());
|
||||
if (to) params.set("to", new Date(`${to}T23:59:59.999+08:00`).toISOString());
|
||||
void fetch(`/api/admin/payments?${params}`, { cache: "no-store" }).then(async (response) => {
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.error || "读取支付记录失败");
|
||||
setOrders(payload.orders); setStats(payload.stats); setTotal(payload.pagination.total); setHasMore(payload.pagination.hasMore); setError("");
|
||||
}).catch((caught) => setError(caught instanceof Error ? caught.message : "读取支付记录失败"));
|
||||
}, [status, from, to, offset]);
|
||||
|
||||
function filterChange(setter: (value: string) => void, value: string) { setter(value); setOffset(0); }
|
||||
|
||||
return <main className="standalone-page admin-page"><header className="admin-header"><h1>支付记录</h1><div><Link className="button-secondary" href="/admin/packages">充值套餐</Link> <Link className="button-secondary" href="/admin/codes">兑换码管理</Link> <Link className="button-secondary" href="/">返回对话</Link></div></header><div className="admin-scroll">
|
||||
<section className="admin-section"><div className="section-title"><div><h2>平台支付统计</h2><p>统计范围按订单创建时间筛选,金额为人民币。</p></div></div><div className="payment-stats"><div><span>总订单</span><strong>{stats.totalOrders}</strong></div><div><span>已支付</span><strong>{stats.paidOrders}</strong></div><div><span>待支付</span><strong>{stats.pendingOrders}</strong></div><div><span>失败/过期</span><strong>{stats.failedExpiredOrders}</strong></div><div><span>已支付金额</span><strong>{formatMoney(stats.paidAmountCents)}</strong></div><div><span>已赠送点数</span><strong>{stats.grantedCredits}</strong></div></div></section>
|
||||
<section className="admin-section"><div className="section-title"><div><h2>支付记录</h2><p>{total} 条平台订单</p></div></div><div className="payment-filters"><label><span>开始日期</span><input type="date" value={from} onChange={(e) => filterChange(setFrom, e.target.value)} /></label><label><span>结束日期</span><input type="date" value={to} onChange={(e) => filterChange(setTo, e.target.value)} /></label><label><span>状态</span><select value={status} onChange={(e) => filterChange(setStatus, e.target.value)}><option value="">全部</option><option value="paid">已支付</option><option value="pending">待支付</option><option value="failed">失败</option><option value="expired">已过期</option></select></label></div>{error && <p className="form-error" role="alert">{error}</p>}<div className="admin-table-wrap"><table className="admin-table"><thead><tr><th>订单号</th><th>用户邮箱</th><th>套餐</th><th>金额</th><th>点数</th><th>状态</th><th>易支付交易号</th><th>创建时间</th><th>支付时间</th></tr></thead><tbody>{orders.map((order) => <tr key={order.orderNo}><td><code>{order.orderNo}</code></td><td>{order.userEmail || "—"}</td><td>{order.packageName || "—"}</td><td>{formatMoney(order.moneyCents)}</td><td>{order.credits}</td><td>{statusLabels[order.status] || order.status}</td><td>{order.epayTradeNo || "—"}</td><td>{formatDate(order.createdAt)}</td><td>{formatDate(order.paidAt)}</td></tr>)}{orders.length === 0 && <tr><td colSpan={9} className="empty-cell">暂无支付记录</td></tr>}</tbody></table></div><div className="payment-pagination"><span>第 {total ? offset + 1 : 0}–{Math.min(offset + orders.length, total)} 条</span><button className="button-secondary" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - limit))}>上一页</button><button className="button-secondary" disabled={!hasMore} onClick={() => setOffset(offset + limit)}>下一页</button></div></section>
|
||||
</div></main>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"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" };
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
async function load() {
|
||||
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 || "暂时无法读取管理员列表");
|
||||
setUsers(payload.users);
|
||||
}
|
||||
useEffect(() => { void load().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(""); await load();
|
||||
}
|
||||
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; }
|
||||
await load();
|
||||
}
|
||||
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";
|
||||
@@ -90,7 +90,7 @@ export async function GET() {
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email ?? null },
|
||||
credits: profile.credits,
|
||||
isAdmin: isAdminEmail(user.email),
|
||||
isAdmin: await isAdminUser(user),
|
||||
rectificationPriceCredits,
|
||||
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
||||
&& typeof profile.active_birth_time === "string",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createAdminSupabaseClient,
|
||||
isAdminEmail,
|
||||
isAdminUser,
|
||||
} from "@/lib/supabase/admin";
|
||||
import {
|
||||
generateRedeemCode,
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
} from "@/lib/supabase/codes";
|
||||
import {
|
||||
isSupabaseConfigurationError,
|
||||
SupabaseConfigurationError,
|
||||
} from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
@@ -25,14 +24,10 @@ const createCodesSchema = z.object({
|
||||
});
|
||||
|
||||
async function requireAdmin() {
|
||||
if (!process.env.ADMIN_EMAILS?.trim()) {
|
||||
throw new SupabaseConfigurationError(["ADMIN_EMAILS"]);
|
||||
}
|
||||
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) };
|
||||
if (!isAdminEmail(user.email)) {
|
||||
if (!(await isAdminUser(user))) {
|
||||
return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) };
|
||||
}
|
||||
return { user };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
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() });
|
||||
async function requireAdmin() {
|
||||
const client = await createServerSupabaseClient();
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
|
||||
return user;
|
||||
}
|
||||
function output(row: Record<string, unknown>) { 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, updatedAt: row.updated_at }; }
|
||||
export async function GET() { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); }
|
||||
export async function POST(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); }
|
||||
export async function PATCH(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); }
|
||||
export async function DELETE(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); }
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
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),
|
||||
});
|
||||
|
||||
async function requireAdmin() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
if (error || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 });
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const auth = await requireAdmin();
|
||||
if (auth instanceof NextResponse) return auth;
|
||||
|
||||
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 admin = createAdminSupabaseClient();
|
||||
let ordersQuery = admin
|
||||
.from("payment_orders")
|
||||
.select("order_no,user_id,money_cents,credits,status,epay_trade_no,paid_at,created_at,payment_packages(name)", { count: "exact" })
|
||||
.order("created_at", { ascending: false })
|
||||
.range(offset, offset + limit - 1);
|
||||
if (status) ordersQuery = ordersQuery.eq("status", status);
|
||||
if (from) ordersQuery = ordersQuery.gte("created_at", from);
|
||||
if (to) ordersQuery = ordersQuery.lte("created_at", to);
|
||||
|
||||
const statsPromise = admin.rpc("get_payment_order_stats", {
|
||||
p_from: from ?? null,
|
||||
p_to: to ?? null,
|
||||
});
|
||||
const [{ data, error, count }, statsResult] = await Promise.all([ordersQuery, statsPromise]);
|
||||
if (error || statsResult.error) return NextResponse.json({ error: "暂时无法读取支付记录" }, { status: 500 });
|
||||
|
||||
const emailEntries = await Promise.all([...new Set((data ?? []).map((row) => row.user_id))].map(async (userId) => {
|
||||
const result = await admin.auth.admin.getUserById(userId);
|
||||
return [userId, result.data.user?.email ?? null] as const;
|
||||
}));
|
||||
const emails = new Map(emailEntries);
|
||||
const orders = (data ?? []).map((row) => {
|
||||
const relation = row.payment_packages as { name?: string } | { name?: string }[] | null;
|
||||
const packageName = Array.isArray(relation) ? relation[0]?.name : relation?.name;
|
||||
return {
|
||||
orderNo: row.order_no,
|
||||
userEmail: emails.get(row.user_id) ?? null,
|
||||
packageName: packageName ?? null,
|
||||
moneyCents: row.money_cents,
|
||||
credits: row.credits,
|
||||
status: row.status,
|
||||
epayTradeNo: row.epay_trade_no,
|
||||
createdAt: row.created_at,
|
||||
paidAt: row.paid_at,
|
||||
};
|
||||
});
|
||||
const rawStats = Array.isArray(statsResult.data) ? statsResult.data[0] : statsResult.data;
|
||||
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 = count ?? 0;
|
||||
return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function requireAdmin() {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) };
|
||||
if (!(await isAdminUser(user))) return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) };
|
||||
return { user };
|
||||
}
|
||||
|
||||
function envAdmins() {
|
||||
return (process.env.ADMIN_EMAILS ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const auth = await requireAdmin();
|
||||
if ("response" in auth) return auth.response;
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin.from("admin_users").select("user_id,created_at,created_by").is("revoked_at", null).order("created_at", { ascending: true });
|
||||
if (error) return NextResponse.json({ error: "暂时无法读取管理员列表" }, { status: 500 });
|
||||
const users = await Promise.all((data ?? []).map(async (row) => {
|
||||
const result = await admin.auth.admin.getUserById(row.user_id);
|
||||
return { userId: row.user_id, email: result.data.user?.email ?? null, createdAt: row.created_at, createdBy: row.created_by, source: "database" as const };
|
||||
}));
|
||||
return NextResponse.json({ users: [...envAdmins().map((email) => ({ userId: null, email, createdAt: null, createdBy: null, source: "env" as const })), ...users] });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const auth = await requireAdmin();
|
||||
if ("response" in auth) return auth.response;
|
||||
const parsed = z.object({ email: z.string().trim().email() }).safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 });
|
||||
const email = parsed.data.email.toLowerCase();
|
||||
if (envAdmins().includes(email)) return NextResponse.json({ error: "该用户已由环境配置管理" }, { status: 409 });
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data: users, error: listError } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 });
|
||||
if (listError) return NextResponse.json({ error: "暂时无法查找用户" }, { status: 500 });
|
||||
const target = users.users.find((candidate) => candidate.email?.trim().toLowerCase() === email);
|
||||
if (!target) return NextResponse.json({ error: "该邮箱尚未注册" }, { status: 404 });
|
||||
const { error } = await admin.from("admin_users").upsert({ user_id: target.id, created_by: auth.user.id, revoked_at: null, revoked_by: null });
|
||||
if (error) return NextResponse.json({ error: "添加管理员失败" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
try {
|
||||
const auth = await requireAdmin();
|
||||
if ("response" in auth) return auth.response;
|
||||
const parsed = z.object({ userId: z.string().uuid() }).safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "用户参数不正确" }, { status: 400 });
|
||||
const admin = createAdminSupabaseClient();
|
||||
const target = await admin.auth.admin.getUserById(parsed.data.userId);
|
||||
if (target.data.user?.email && envAdmins().includes(target.data.user.email.toLowerCase())) return NextResponse.json({ error: "环境配置管理员不可撤销" }, { status: 409 });
|
||||
const { error } = await admin.from("admin_users").update({ revoked_at: new Date().toISOString(), revoked_by: auth.user.id }).eq("user_id", parsed.data.userId).is("revoked_at", null);
|
||||
if (error) return NextResponse.json({ error: "撤销管理员失败" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { epaySign } from "@/lib/epay/sign";
|
||||
import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const schema = z.object({ packageId: z.string().uuid() });
|
||||
function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } }
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
|
||||
const admin = createAdminSupabaseClient(); const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle();
|
||||
if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 });
|
||||
const config = readEpayConfig(); const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
|
||||
const { error: orderError } = await admin.from("payment_orders").insert({ order_no: orderNo, user_id: user.id, package_id: pack.id, money_cents: pack.price_cents, credits: pack.credits });
|
||||
if (orderError) return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
|
||||
const params = { money: (pack.price_cents / 100).toFixed(2), name: pack.name, notify_url: config.notifyUrl, out_trade_no: orderNo, pid: config.pid, return_url: config.returnUrl, sitename: config.siteName, type: "alipay" };
|
||||
const body = new URLSearchParams({ ...params, sign: epaySign(params, config.key), sign_type: "MD5" });
|
||||
const upstream = await fetch(epaySubmitUrl(config.gatewayUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) });
|
||||
if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 });
|
||||
const text = await upstream.text(); let payload: Record<string, unknown> = {}; try { const json = JSON.parse(text); if (json && typeof json === "object") payload = json; } catch { /* gateway may return HTML */ }
|
||||
const payUrl = safeUpstreamUrl(payload.payurl ?? payload.pay_url ?? payload.url ?? (text.trim().startsWith("http") ? text.trim() : null), config.gatewayUrl);
|
||||
const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl);
|
||||
return NextResponse.json({ orderNo, payUrl, qrCode });
|
||||
} catch (error) {
|
||||
if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 });
|
||||
return NextResponse.json({ error: "创建支付失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { epaySign, timingSafeSignEqual } from "@/lib/epay/sign";
|
||||
import { readEpayConfig } from "@/lib/epay/config";
|
||||
export const runtime = "nodejs";
|
||||
async function notify(request: Request) {
|
||||
try {
|
||||
const config = readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record<string, string> = {}; params.forEach((value, key) => { values[key] = value; });
|
||||
if (!timingSafeSignEqual(values.sign, epaySign(values, config.key)) || values.pid !== config.pid || values.trade_status !== "TRADE_SUCCESS" || !values.out_trade_no || !values.money) return new NextResponse("success", { status: 200 });
|
||||
const moneyCents = Math.round(Number(values.money) * 100); if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new NextResponse("success", { status: 200 });
|
||||
const hash = crypto.createHash("sha256").update(raw).digest("hex");
|
||||
const { error } = await createAdminSupabaseClient().rpc("settle_epay_order", { p_order_no: values.out_trade_no, p_trade_no: values.trade_no || values.transaction_id || values.out_trade_no, p_money_cents: moneyCents, p_payload_hash: hash });
|
||||
if (error) return new NextResponse("success", { status: 200 });
|
||||
return new NextResponse("success", { status: 200 });
|
||||
} catch { return new NextResponse("success", { status: 200 }); }
|
||||
}
|
||||
export async function POST(request: Request) { return notify(request); }
|
||||
export async function GET(request: Request) { return notify(request); }
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
export const runtime = "nodejs";
|
||||
export async function GET(request: Request) { const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const orderNo = new URL(request.url).searchParams.get("orderNo"); if (!orderNo) return NextResponse.json({ error: "缺少订单号" }, { status: 400 }); const { data, error } = await createAdminSupabaseClient().from("payment_orders").select("order_no,status,credits,paid_at").eq("order_no", orderNo).eq("user_id", user.id).maybeSingle(); if (error) return NextResponse.json({ error: "暂时无法查询订单" }, { status: 500 }); if (!data) return NextResponse.json({ error: "订单不存在" }, { status: 404 }); return NextResponse.json({ orderNo: data.order_no, status: data.status, credits: data.credits, paidAt: data.paid_at }); }
|
||||
@@ -0,0 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
export const runtime = "nodejs";
|
||||
export async function GET() { 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({ error: "暂时无法读取充值套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map((p) => ({ id: p.id, name: p.name, description: p.description, priceCents: p.price_cents, credits: p.credits })) }); }
|
||||
@@ -749,6 +749,14 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
.code-status { display: inline-flex; min-height: 28px; align-items: center; padding: 0 9px; border-radius: var(--radius-md); background: var(--color-canvas-muted); color: var(--color-ink-secondary); }
|
||||
.status-可用 { background: var(--color-success-muted); color: var(--color-success); }
|
||||
.status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); }
|
||||
.payment-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; margin-top: 16px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-border); }
|
||||
.payment-stats > div { display: grid; gap: 6px; padding: var(--space-4); background: var(--color-canvas-muted); }
|
||||
.payment-stats span, .payment-filters label span { color: var(--color-ink-secondary); font-size: 12px; }
|
||||
.payment-stats strong { font-size: 20px; font-variant-numeric: tabular-nums; }
|
||||
.payment-filters { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: 16px; }
|
||||
.payment-filters label { display: grid; gap: 6px; }
|
||||
.payment-pagination { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); margin-top: 16px; color: var(--color-ink-secondary); font-size: 13px; }
|
||||
@media (max-width: 767px) { .payment-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .payment-pagination { justify-content: space-between; } }
|
||||
|
||||
@media (hover: hover) {
|
||||
.new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); }
|
||||
@@ -1660,3 +1668,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
|
||||
|
||||
.birth-time-clock-menu.select-content { width: 108px; min-width: 108px; }
|
||||
.birth-time-clock-menu .select-item { justify-content: flex-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%; }
|
||||
|
||||
|
||||
@@ -903,6 +903,10 @@ export default function Home() {
|
||||
const [redeemError, setRedeemError] = useState("");
|
||||
const [redeemMessage, setRedeemMessage] = useState("");
|
||||
const [redeeming, setRedeeming] = 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[]>([]);
|
||||
@@ -1955,6 +1959,39 @@ 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) setPaymentPackages(payload.packages || []);
|
||||
});
|
||||
}, [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 || "创建支付失败");
|
||||
setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode, status: "pending" });
|
||||
if (payload.payUrl) 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();
|
||||
@@ -3516,6 +3553,12 @@ export default function Home() {
|
||||
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
|
||||
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
|
||||
</form>
|
||||
<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>)}
|
||||
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
|
||||
{paymentOrder && <div className="payment-status" role="status"><p>订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}</p>{paymentOrder.qrCode && <div className="payment-qr-wrap"><img src={paymentOrder.qrCode} alt="支付宝支付二维码" /><span className="payment-qr-badge" aria-label="支付宝"><svg viewBox="0 0 48 48" role="img" aria-hidden="true"><rect width="48" height="48" rx="10" fill="#1677ff" /><path d="M13 16.5h15.8c2.3 0 4.2 1.9 4.2 4.2v6.1c0 2.3-1.9 4.2-4.2 4.2H22l-5.9 4.4v-4.4h-1.1c-2.3 0-4.2-1.9-4.2-4.2v-6.1c0-2.3 1.9-4.2 4.2-4.2Z" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinejoin="round" /><path d="M18.5 22.2h8.8M18.5 26h5.4" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" /></svg></span></div>{paymentOrder.payUrl && <a href={paymentOrder.payUrl} target="_blank" rel="noreferrer">打开支付页面</a>}</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import "server-only";
|
||||
|
||||
const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify";
|
||||
|
||||
export class EpayConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "EpayConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
function required(name: string) {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) throw new EpayConfigurationError(`${name} 未配置`);
|
||||
return value;
|
||||
}
|
||||
|
||||
export function readEpayConfig() {
|
||||
const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, "");
|
||||
let gatewayUrl: URL;
|
||||
try {
|
||||
gatewayUrl = new URL(gateway);
|
||||
} catch {
|
||||
throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效");
|
||||
}
|
||||
if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)");
|
||||
return {
|
||||
gatewayUrl,
|
||||
pid: required("EPAY_PID"),
|
||||
key: required("EPAY_KEY"),
|
||||
notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL,
|
||||
returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/",
|
||||
siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha",
|
||||
};
|
||||
}
|
||||
|
||||
export function epaySubmitUrl(gatewayUrl: URL) {
|
||||
const url = new URL(gatewayUrl.toString());
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`;
|
||||
url.search = "";
|
||||
return url;
|
||||
}
|
||||
|
||||
export { DEFAULT_NOTIFY_URL };
|
||||
@@ -0,0 +1,18 @@
|
||||
import crypto from "node:crypto";
|
||||
|
||||
export function epayCanonical(params: Record<string, string | number | null | undefined>) {
|
||||
return Object.entries(params)
|
||||
.filter(([key, value]) => key !== "sign" && key !== "sign_type" && value !== null && value !== undefined && String(value) !== "")
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("&");
|
||||
}
|
||||
|
||||
export function epaySign(params: Record<string, string | number | null | undefined>, key: string) {
|
||||
return crypto.createHash("md5").update(`${epayCanonical(params)}${key}`).digest("hex");
|
||||
}
|
||||
|
||||
export function timingSafeSignEqual(actual: string | null | undefined, expected: string) {
|
||||
if (!actual || actual.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
||||
}
|
||||
@@ -36,3 +36,22 @@ export function isAdminEmail(email: string | null | undefined) {
|
||||
.split(",")
|
||||
.some((candidate) => candidate.trim().toLowerCase() === normalized);
|
||||
}
|
||||
|
||||
export async function isAdminUser(user: { id?: string | null; email?: string | null } | null | undefined) {
|
||||
if (!user?.email) return false;
|
||||
if (isAdminEmail(user.email)) return true;
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted" || !user.id) return false;
|
||||
|
||||
try {
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin
|
||||
.from("admin_users")
|
||||
.select("user_id")
|
||||
.eq("user_id", user.id)
|
||||
.is("revoked_at", null)
|
||||
.maybeSingle();
|
||||
return !error && Boolean(data);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user