1b8d6fcce6
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.
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { z } from "zod";
|
|
import {
|
|
createAdminSupabaseClient,
|
|
isAdminUser,
|
|
} from "@/lib/supabase/admin";
|
|
import {
|
|
generateRedeemCode,
|
|
hashRedeemCode,
|
|
maskRedeemCode,
|
|
} from "@/lib/supabase/codes";
|
|
import {
|
|
isSupabaseConfigurationError,
|
|
} from "@/lib/supabase/config";
|
|
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
const createCodesSchema = z.object({
|
|
credits: z.number().int().positive().max(1_000_000),
|
|
count: z.number().int().min(1).max(100),
|
|
expiresAt: z.string().datetime({ offset: true }).optional(),
|
|
note: z.string().trim().max(500).optional(),
|
|
});
|
|
|
|
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 };
|
|
}
|
|
|
|
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("redemption_codes")
|
|
.select("id,code_mask,credits,expires_at,note,created_at,redeemed_by,redeemed_email,redeemed_at")
|
|
.order("created_at", { ascending: false })
|
|
.limit(100);
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: "暂时无法读取兑换码列表" }, { status: 500 });
|
|
}
|
|
|
|
return NextResponse.json({
|
|
codes: data.map((code) => ({
|
|
id: code.id,
|
|
mask: code.code_mask,
|
|
credits: code.credits,
|
|
expiresAt: code.expires_at,
|
|
note: code.note,
|
|
createdAt: code.created_at,
|
|
redeemedBy: code.redeemed_by,
|
|
redeemedEmail: code.redeemed_email,
|
|
redeemedAt: code.redeemed_at,
|
|
})),
|
|
});
|
|
} 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 = createCodesSchema.safeParse(await request.json().catch(() => null));
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: "兑换码参数不正确" }, { status: 400 });
|
|
}
|
|
|
|
const { credits, count, expiresAt, note } = parsed.data;
|
|
const codes = Array.from({ length: count }, generateRedeemCode);
|
|
const admin = createAdminSupabaseClient();
|
|
const { error } = await admin.from("redemption_codes").insert(codes.map((code) => ({
|
|
code_hash: hashRedeemCode(code),
|
|
code_mask: maskRedeemCode(code),
|
|
credits,
|
|
expires_at: expiresAt ?? null,
|
|
note: note || null,
|
|
created_by: auth.user.id,
|
|
})));
|
|
|
|
if (error) {
|
|
return NextResponse.json({ error: "生成兑换码失败,请重试" }, { status: 500 });
|
|
}
|
|
|
|
return NextResponse.json({
|
|
codes: codes.map((code) => ({ code, credits, expiresAt: expiresAt ?? null, note: note || null })),
|
|
}, { status: 201 });
|
|
} catch (error) {
|
|
if (isSupabaseConfigurationError(error)) {
|
|
return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
|
|
}
|
|
return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 });
|
|
}
|
|
}
|