feat(billing): add products subscriptions orders and usage
This commit is contained in:
@@ -13,11 +13,18 @@ import {
|
||||
isSupabaseConfigurationError,
|
||||
} from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { readSelfHostedIdentityConfig } from "@/modules/identity/config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const unfinishedRectificationStatuses = ["starting", "active", "paused", "confirming"] as const;
|
||||
|
||||
function adminEntryUrl(): string {
|
||||
return process.env.AUTH_PROVIDER?.trim() === "self-hosted"
|
||||
? `${readSelfHostedIdentityConfig(process.env).adminOrigin}/admin`
|
||||
: "/admin";
|
||||
}
|
||||
|
||||
function isMissingProfileColumn(error: { code?: string; message?: string } | null) {
|
||||
const message = error?.message?.toLowerCase() ?? "";
|
||||
return error?.code === "PGRST204"
|
||||
@@ -109,8 +116,21 @@ export async function GET() {
|
||||
profile,
|
||||
Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [],
|
||||
);
|
||||
const { data: activeSubscriptions, error: subscriptionError } = await admin
|
||||
.from("user_subscriptions")
|
||||
.select("id,status,starts_at,ends_at,product_code,product_version,product_snapshot,entitlement_snapshot")
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "active")
|
||||
.lte("starts_at", new Date().toISOString())
|
||||
.gt("ends_at", new Date().toISOString())
|
||||
.order("ends_at", { ascending: true })
|
||||
.limit(1);
|
||||
if (subscriptionError) {
|
||||
return NextResponse.json({ error: "暂时无法读取会员状态" }, { status: 500 });
|
||||
}
|
||||
const activeSubscription = activeSubscriptions?.[0] ?? null;
|
||||
const isAdmin = await isAdminUser(user);
|
||||
const adminUrl = isAdmin ? "/admin/codes" : null;
|
||||
const adminUrl = isAdmin ? adminEntryUrl() : null;
|
||||
|
||||
return NextResponse.json({
|
||||
user: { id: user.id, email: user.email ?? null },
|
||||
@@ -118,6 +138,16 @@ export async function GET() {
|
||||
isAdmin,
|
||||
adminUrl,
|
||||
rectificationPriceCredits,
|
||||
activeSubscription: activeSubscription ? {
|
||||
id: activeSubscription.id,
|
||||
status: activeSubscription.status,
|
||||
startsAt: activeSubscription.starts_at,
|
||||
endsAt: activeSubscription.ends_at,
|
||||
productCode: activeSubscription.product_code,
|
||||
productVersion: activeSubscription.product_version,
|
||||
product: activeSubscription.product_snapshot,
|
||||
entitlements: activeSubscription.entitlement_snapshot,
|
||||
} : null,
|
||||
hasConfirmedBirthTime: profile.birth_time_status === "confirmed"
|
||||
&& typeof profile.active_birth_time === "string",
|
||||
hasUsableBirthTime: (profile.birth_time_status === "accepted" || profile.birth_time_status === "confirmed")
|
||||
|
||||
@@ -1,32 +1,43 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAdminSession } from "@/lib/admin/auth";
|
||||
import { runCodeRpc } from "@/lib/admin/codes";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
requireHighRiskAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const paramsSchema = z.object({ id: z.string().uuid() });
|
||||
const updateCodeSchema = z.object({
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
}).refine((value) => "note" in value || "expiresAt" in value, {
|
||||
message: "至少提供一个可修改字段",
|
||||
const revokeCodeSchema = z.object({
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
const updateCodeSchema = z
|
||||
.object({
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
})
|
||||
.refine((value) => "note" in value || "expiresAt" in value, {
|
||||
message: "至少提供一个可修改字段",
|
||||
});
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsedParams = paramsSchema.safeParse(await context.params);
|
||||
const parsedBody = updateCodeSchema.safeParse(await request.json().catch(() => null));
|
||||
const parsedBody = updateCodeSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsedParams.success || !parsedBody.success) {
|
||||
return invalidQueryResponse();
|
||||
}
|
||||
@@ -41,6 +52,7 @@ export async function PATCH(
|
||||
p_note: body.note ?? null,
|
||||
p_set_expires_at: "expiresAt" in body,
|
||||
p_expires_at: body.expiresAt ?? null,
|
||||
p_reason: body.reason,
|
||||
},
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
@@ -54,14 +66,20 @@ export async function DELETE(
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = paramsSchema.safeParse(await context.params);
|
||||
if (!parsed.success) return invalidQueryResponse();
|
||||
const parsedBody = revokeCodeSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success || !parsedBody.success) return invalidQueryResponse();
|
||||
const rows = await runCodeRpc(
|
||||
"admin_revoke_redemption_code",
|
||||
session,
|
||||
requestId(request),
|
||||
{ p_code_id: parsed.data.id },
|
||||
{ p_code_id: parsed.data.id, p_reason: parsedBody.data.reason },
|
||||
);
|
||||
return NextResponse.json({ data: rows[0] });
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requireAdminSession } from "@/lib/admin/auth";
|
||||
import { mapCode, runCodeRpc, type RedemptionCodeRecord } from "@/lib/admin/codes";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import {
|
||||
mapCode,
|
||||
runCodeRpc,
|
||||
type RedemptionCodeRecord,
|
||||
} from "@/lib/admin/codes";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requireHighRiskAdminMutation,
|
||||
requestId,
|
||||
} from "@/lib/admin/http";
|
||||
import {
|
||||
@@ -23,6 +28,7 @@ const createCodesSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
expiresAt: z.string().datetime({ offset: true }).nullable().optional(),
|
||||
note: z.string().trim().max(500).nullable().optional(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type CodeRow = {
|
||||
@@ -59,7 +65,7 @@ function serializedCodeRow(row: CodeRow) {
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requireAdminSession();
|
||||
await requirePermission("billing.orders.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const { page, pageSize, sort, order, q, status } = parsed.data;
|
||||
@@ -67,12 +73,19 @@ export async function GET(request: Request) {
|
||||
const conditions: string[] = [];
|
||||
if (q) {
|
||||
values.push(`%${q}%`);
|
||||
conditions.push(`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`);
|
||||
conditions.push(
|
||||
`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`,
|
||||
);
|
||||
}
|
||||
if (status && ["available", "expired", "redeemed", "revoked"].includes(status)) {
|
||||
if (
|
||||
status &&
|
||||
["available", "expired", "redeemed", "revoked"].includes(status)
|
||||
) {
|
||||
const clauses = {
|
||||
available: "c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())",
|
||||
expired: "c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()",
|
||||
available:
|
||||
"c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())",
|
||||
expired:
|
||||
"c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()",
|
||||
redeemed: "c.redeemed_at is not null",
|
||||
revoked: "c.revoked_at is not null",
|
||||
};
|
||||
@@ -80,7 +93,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
values.push(pageSize, pageOffset(page, pageSize));
|
||||
const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at";
|
||||
const rows = await queryAdminRows<CodeRow>(`
|
||||
const rows = await queryAdminRows<CodeRow>(
|
||||
`
|
||||
select c.id, c.code_mask, c.credits, c.expires_at, c.note,
|
||||
c.created_at, c.redeemed_by, c.redeemed_email, c.redeemed_at,
|
||||
c.revoked_by, c.revoked_at,
|
||||
@@ -95,7 +109,9 @@ export async function GET(request: Request) {
|
||||
${conditions.length ? `where ${conditions.join(" and ")}` : ""}
|
||||
order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.id asc
|
||||
limit $${values.length - 1} offset $${values.length}
|
||||
`, values);
|
||||
`,
|
||||
values,
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: rows.map(serializedCodeRow),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
@@ -107,10 +123,18 @@ export async function GET(request: Request) {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const parsed = createCodesSchema.safeParse(await request.json().catch(() => null));
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = createCodesSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const plainCodes = Array.from({ length: parsed.data.count }, generateRedeemCode);
|
||||
const plainCodes = Array.from(
|
||||
{ length: parsed.data.count },
|
||||
generateRedeemCode,
|
||||
);
|
||||
const records = plainCodes.map((code) => ({
|
||||
codeHash: hashRedeemCode(code),
|
||||
codeMask: maskRedeemCode(code),
|
||||
@@ -123,20 +147,23 @@ export async function POST(request: Request) {
|
||||
"admin_create_redemption_codes",
|
||||
session,
|
||||
operationRequestId,
|
||||
{ p_codes: records },
|
||||
{ p_codes: records, p_reason: parsed.data.reason },
|
||||
);
|
||||
const byMask = new Map<string, RedemptionCodeRecord>(
|
||||
stored.map((record) => [record.mask, record]),
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: {
|
||||
id: operationRequestId,
|
||||
generated: plainCodes.map((code) => ({
|
||||
...(byMask.get(maskRedeemCode(code)) ?? {}),
|
||||
code,
|
||||
})),
|
||||
return NextResponse.json(
|
||||
{
|
||||
data: {
|
||||
id: operationRequestId,
|
||||
generated: plainCodes.map((code) => ({
|
||||
...(byMask.get(maskRedeemCode(code)) ?? {}),
|
||||
code,
|
||||
})),
|
||||
},
|
||||
},
|
||||
}, { status: 201 });
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import crypto from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireAdminSession } from "@/lib/admin/auth";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse } from "@/lib/admin/http";
|
||||
import { adminErrorResponse, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
import { suggestedEpayUrls } from "@/lib/epay/config";
|
||||
import { encryptEpayKey } from "@/lib/epay/encryption";
|
||||
import { assertConfiguredEpayUrl, assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const httpUrl = z.string().trim().min(1).max(2048).url().refine((value) => /^https?:\/\//i.test(value), "必须使用 HTTP(S)");
|
||||
const epayUrl = z.string().trim().min(1).max(2048).url().refine((value) => {
|
||||
try {
|
||||
assertConfiguredEpayUrl(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, "必须使用 HTTPS;开发测试仅可显式启用 loopback HTTP");
|
||||
const settingsSchema = z.object({
|
||||
gatewayUrl: httpUrl,
|
||||
gatewayUrl: epayUrl,
|
||||
pid: z.string().trim().min(1).max(200),
|
||||
notifyUrl: httpUrl,
|
||||
returnUrl: httpUrl,
|
||||
notifyUrl: epayUrl,
|
||||
returnUrl: epayUrl,
|
||||
siteName: z.string().trim().min(1).max(100),
|
||||
chatEnabled: z.boolean(),
|
||||
newKey: z.string().min(1).max(1000).optional(),
|
||||
@@ -77,7 +85,7 @@ async function databaseRow() {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAdminSession("read");
|
||||
await requirePermission("billing.orders.read");
|
||||
const row = await databaseRow();
|
||||
if (row) return NextResponse.json(publicSettings(row, "database"));
|
||||
const settings = environmentSettings();
|
||||
@@ -91,9 +99,14 @@ export async function GET() {
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const session = await requireAdminSession("write");
|
||||
const session = await requireHighRiskAdminMutation(request, "billing.adjustments.write");
|
||||
const parsed = settingsSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 });
|
||||
try {
|
||||
await assertPublicGatewayUrl(parsed.data.gatewayUrl);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "易支付网关地址必须是允许的 HTTPS 公网地址" }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await databaseRow();
|
||||
if (!existing && !parsed.data.newKey) {
|
||||
@@ -110,7 +123,7 @@ export async function PUT(request: Request) {
|
||||
`, [
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.role,
|
||||
session.roles[0] ?? "admin",
|
||||
crypto.randomUUID(),
|
||||
parsed.data.gatewayUrl.replace(/\/+$/, ""),
|
||||
parsed.data.pid,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth";
|
||||
import { adminErrorResponse } from "@/lib/admin/http";
|
||||
import { AdminAuthorizationError } from "@/lib/admin/auth";
|
||||
import { adminErrorResponse, requireAdminMutation } from "@/lib/admin/http";
|
||||
import { epaySubmitUrl, readEpayConfig } from "@/lib/epay/config";
|
||||
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
|
||||
import { probePublicEpayGateway } from "@/lib/epay/gateway-policy";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -10,31 +10,19 @@ function reachableStatus(status: number) {
|
||||
return status >= 200 && status < 500;
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
await requireAdminSession("write");
|
||||
await requireAdminMutation(request, "billing.adjustments.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);
|
||||
const status = await probePublicEpayGateway(submitUrl);
|
||||
const available = reachableStatus(status);
|
||||
return NextResponse.json({
|
||||
available,
|
||||
message: available ? "当前已保存的易支付配置可访问" : "当前已保存的易支付配置暂不可用",
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
status: response.status,
|
||||
status,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AdminAuthorizationError) return adminErrorResponse(error);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import {
|
||||
adminErrorResponse,
|
||||
invalidQueryResponse,
|
||||
parseListQuery,
|
||||
requestId,
|
||||
requireHighRiskAdminMutation,
|
||||
} from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const adjustmentSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
action: z.enum(["retry_grant", "compensate", "record_refund"]),
|
||||
expectedVersion: z.number().int().min(0),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
});
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
order_no: string;
|
||||
user_id: string;
|
||||
email: string | null;
|
||||
product_code: string | null;
|
||||
product_version: number | null;
|
||||
money_cents: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
grant_type: string | null;
|
||||
grant_status: string;
|
||||
grant_error: string | null;
|
||||
adjustment_version: number;
|
||||
refund_status: string;
|
||||
refund_amount_cents: number | null;
|
||||
refunded_at: Date | null;
|
||||
paid_at: Date | null;
|
||||
created_at: Date;
|
||||
total_count: string;
|
||||
};
|
||||
|
||||
type AdjustmentRow = {
|
||||
id: string;
|
||||
order_no: string;
|
||||
status: string;
|
||||
grant_status: string;
|
||||
grant_error: string | null;
|
||||
adjustment_version: number;
|
||||
refund_status: string;
|
||||
refund_amount_cents: number | null;
|
||||
refunded_at: Date | null;
|
||||
action_success: boolean;
|
||||
};
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("billing.orders.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const query = parsed.data.q ? `%${parsed.data.q}%` : null;
|
||||
const rows = await queryAdminRows<Row>(
|
||||
`select o.id,o.order_no,o.user_id,u.email,o.product_code,o.product_version,
|
||||
o.money_cents,o.currency,o.status,o.grant_type,o.grant_status,o.grant_error,
|
||||
o.adjustment_version,o.refund_status,o.refund_amount_cents,o.refunded_at,
|
||||
o.paid_at,o.created_at,count(*) over()::text total_count
|
||||
from public.payment_orders o
|
||||
left join identity.users u on u.id=o.user_id
|
||||
where ($1::text is null or o.order_no ilike $1 or u.email ilike $1 or o.user_id::text ilike $1)
|
||||
and ($2::text is null or o.status=$2 or o.grant_status=$2 or o.refund_status=$2)
|
||||
order by o.created_at desc limit $3 offset $4`,
|
||||
[
|
||||
query,
|
||||
parsed.data.status ?? null,
|
||||
parsed.data.pageSize,
|
||||
pageOffset(parsed.data.page, parsed.data.pageSize),
|
||||
],
|
||||
);
|
||||
return NextResponse.json({
|
||||
data: rows.map((row) => ({
|
||||
id: row.id,
|
||||
orderNo: row.order_no,
|
||||
userId: row.user_id,
|
||||
email: row.email,
|
||||
productCode: row.product_code,
|
||||
productVersion: row.product_version,
|
||||
moneyCents: row.money_cents,
|
||||
currency: row.currency,
|
||||
status: row.status,
|
||||
grantType: row.grant_type,
|
||||
grantStatus: row.grant_status,
|
||||
grantError: row.grant_error,
|
||||
adjustmentVersion: row.adjustment_version,
|
||||
refundStatus: row.refund_status,
|
||||
refundAmountCents: row.refund_amount_cents,
|
||||
refundedAt: row.refunded_at?.toISOString() ?? null,
|
||||
paidAt: row.paid_at?.toISOString() ?? null,
|
||||
createdAt: row.created_at.toISOString(),
|
||||
})),
|
||||
total: Number(rows[0]?.total_count ?? 0),
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await requireHighRiskAdminMutation(
|
||||
request,
|
||||
"billing.adjustments.write",
|
||||
);
|
||||
const parsed = adjustmentSchema.safeParse(
|
||||
await request.json().catch(() => null),
|
||||
);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const rows = await queryAdminRows<AdjustmentRow>(
|
||||
`select * from public.admin_adjust_order(
|
||||
$1::uuid,$2::uuid,$3::text,$4::integer,$5::text,$6::text
|
||||
)`,
|
||||
[
|
||||
session.user.id,
|
||||
parsed.data.id,
|
||||
parsed.data.action,
|
||||
parsed.data.expectedVersion,
|
||||
parsed.data.reason,
|
||||
requestId(request),
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
return NextResponse.json({
|
||||
data: row
|
||||
? {
|
||||
id: row.id,
|
||||
orderNo: row.order_no,
|
||||
status: row.status,
|
||||
grantStatus: row.grant_status,
|
||||
grantError: row.grant_error,
|
||||
adjustmentVersion: row.adjustment_version,
|
||||
refundStatus: row.refund_status,
|
||||
refundAmountCents: row.refund_amount_cents,
|
||||
refundedAt: row.refunded_at?.toISOString() ?? null,
|
||||
actionSuccess: row.action_success,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +1,31 @@
|
||||
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();
|
||||
const replacement = "/api/admin/products";
|
||||
|
||||
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 function GET(request: Request) {
|
||||
return NextResponse.redirect(new URL(replacement, request.url), 308);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
function removedMutation() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "旧套餐写接口已停用,请使用统一商品管理。",
|
||||
replacement,
|
||||
},
|
||||
{ status: 410 },
|
||||
);
|
||||
}
|
||||
|
||||
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 function POST() {
|
||||
return removedMutation();
|
||||
}
|
||||
|
||||
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 function PATCH() {
|
||||
return removedMutation();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
export function DELETE() {
|
||||
return removedMutation();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const entitlementSchema = z.object({
|
||||
featureKey: z.enum(["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra"]),
|
||||
allowanceType: z.enum(["access", "unlimited", "quota", "credits"]),
|
||||
allowanceCount: z.number().int().positive().nullable(),
|
||||
resetPeriod: z.enum(["none", "day", "month", "billing_period"]),
|
||||
modelTier: z.enum(["standard", "premium", "internal"]).nullable().optional(),
|
||||
fairUsePolicyId: z.string().trim().max(100).nullable().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).default({}),
|
||||
}).strict();
|
||||
|
||||
const saveSchema = z.object({
|
||||
action: z.literal("save"),
|
||||
id: z.string().uuid().nullable().optional(),
|
||||
code: z.string().regex(/^[a-z][a-z0-9_]{1,79}$/),
|
||||
name: z.string().trim().min(1).max(80),
|
||||
description: z.string().trim().max(1000).default(""),
|
||||
productType: z.enum(["credit_pack", "trial", "subscription"]),
|
||||
billingPeriod: z.enum(["none", "day", "month", "year"]),
|
||||
intervalCount: z.number().int().nonnegative(),
|
||||
priceCents: z.number().int().positive(),
|
||||
currency: z.string().length(3).default("CNY"),
|
||||
enabled: z.boolean(),
|
||||
sortOrder: z.number().int(),
|
||||
oneTimePerUser: z.boolean(),
|
||||
entitlements: z.array(entitlementSchema).min(1),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const publishSchema = z.object({
|
||||
action: z.literal("publish"),
|
||||
id: z.string().uuid(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]);
|
||||
|
||||
type ProductRow = {
|
||||
id: string; code: string; version: number; name: string; description: string;
|
||||
product_type: string; billing_period: string; interval_count: number; price_cents: number;
|
||||
currency: string; enabled: boolean; status: string; sort_order: number; one_time_per_user: boolean;
|
||||
effective_from: Date | null; effective_to: Date | null; updated_at: Date; entitlements: unknown;
|
||||
total_count: string;
|
||||
};
|
||||
|
||||
function output(row: ProductRow) {
|
||||
return {
|
||||
id: row.id, code: row.code, version: row.version, name: row.name, description: row.description,
|
||||
productType: row.product_type, billingPeriod: row.billing_period, intervalCount: row.interval_count,
|
||||
priceCents: row.price_cents, currency: row.currency, enabled: row.enabled, status: row.status,
|
||||
sortOrder: row.sort_order, oneTimePerUser: row.one_time_per_user,
|
||||
effectiveFrom: row.effective_from?.toISOString() ?? null,
|
||||
effectiveTo: row.effective_to?.toISOString() ?? null,
|
||||
updatedAt: row.updated_at.toISOString(), entitlements: row.entitlements,
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("billing.products.read");
|
||||
const parsed = parseListQuery(request);
|
||||
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const q = parsed.data.q ? `%${parsed.data.q}%` : null;
|
||||
const rows = await queryAdminRows<ProductRow>(`
|
||||
select p.*,coalesce(jsonb_agg(jsonb_build_object(
|
||||
'featureKey',e.feature_key,'allowanceType',e.allowance_type,'allowanceCount',e.allowance_count,
|
||||
'resetPeriod',e.reset_period,'modelTier',e.model_tier,'fairUsePolicyId',e.fair_use_policy_id,'metadata',e.metadata
|
||||
) order by e.feature_key) filter(where e.id is not null),'[]'::jsonb) entitlements,
|
||||
count(*) over()::text total_count
|
||||
from public.billing_products p left join public.product_entitlements e on e.product_id=p.id
|
||||
where ($1::text is null or p.code ilike $1 or p.name ilike $1)
|
||||
and ($2::text is null or p.status=$2)
|
||||
group by p.id
|
||||
order by p.updated_at desc limit $3 offset $4
|
||||
`, [q, parsed.data.status ?? null, parsed.data.pageSize, pageOffset(parsed.data.page, parsed.data.pageSize)]);
|
||||
return NextResponse.json({ data: rows.map(output), total: Number(rows[0]?.total_count ?? 0) });
|
||||
} catch (error) { return adminErrorResponse(error); }
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = mutationSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!body.success) return invalidQueryResponse(body.error.flatten());
|
||||
const permission = body.data.action === "publish" ? "billing.products.publish" : "billing.products.write";
|
||||
const session = await requireHighRiskAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
if (body.data.action === "publish") {
|
||||
const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, body.data.reason, rid]);
|
||||
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
|
||||
}
|
||||
const value = body.data;
|
||||
const rows = await queryAdminRows<{ id: string }>(`
|
||||
select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id
|
||||
`, [session.user.id, value.id ?? null, value.code, value.name, value.description, value.productType,
|
||||
value.billingPeriod, value.intervalCount, value.priceCents, value.currency, value.enabled, value.sortOrder,
|
||||
value.oneTimePerUser, JSON.stringify(value.entitlements), value.reason, rid]);
|
||||
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
|
||||
} catch (error) { return adminErrorResponse(error); }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireHighRiskAdminMutation } from "@/lib/admin/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const mutationSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
action: z.enum(["extend", "revoke"]),
|
||||
days: z.number().int().min(1).max(3660).optional(),
|
||||
expectedEndsAt: z.string().datetime(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict();
|
||||
type Row = { id:string; user_id:string; email:string|null; product_code:string; product_version:number; status:string; starts_at:Date; ends_at:Date; created_at:Date; total_count:string };
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
await requirePermission("billing.orders.read");
|
||||
const parsed=parseListQuery(request); if(!parsed.success) return invalidQueryResponse(parsed.error.flatten());
|
||||
const q=parsed.data.q?`%${parsed.data.q}%`:null;
|
||||
const rows=await queryAdminRows<Row>(`select s.id,s.user_id,u.email,s.product_code,s.product_version,s.status,s.starts_at,s.ends_at,s.created_at,count(*) over()::text total_count
|
||||
from public.user_subscriptions s left join identity.users u on u.id=s.user_id
|
||||
where ($1::text is null or u.email ilike $1 or s.user_id::text ilike $1 or s.product_code ilike $1) and ($2::text is null or s.status=$2)
|
||||
order by s.created_at desc limit $3 offset $4`,[q,parsed.data.status??null,parsed.data.pageSize,pageOffset(parsed.data.page,parsed.data.pageSize)]);
|
||||
return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,productCode:r.product_code,productVersion:r.product_version,status:r.status,startsAt:r.starts_at.toISOString(),endsAt:r.ends_at.toISOString(),createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});
|
||||
} catch(error){return adminErrorResponse(error);}
|
||||
}
|
||||
export async function POST(request: Request){
|
||||
try{
|
||||
const body=mutationSchema.safeParse(await request.json().catch(()=>null)); if(!body.success)return invalidQueryResponse(body.error.flatten());
|
||||
const session=await requireHighRiskAdminMutation(request,"billing.adjustments.write"); const rid=requestId(request);
|
||||
const rows=await queryAdminRows<{id:string}>("select public.admin_adjust_subscription($1,$2,$3,$4,$5,$6,$7) id",[session.user.id,body.data.id,body.data.action,body.data.days??null,body.data.expectedEndsAt,body.data.reason,rid]);
|
||||
return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}});
|
||||
}catch(error){return adminErrorResponse(error);}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { pageOffset, queryAdminRows } from "@/lib/admin/database";
|
||||
import { adminErrorResponse, invalidQueryResponse, parseListQuery } from "@/lib/admin/http";
|
||||
export const runtime="nodejs";
|
||||
type Row={id:string;user_id:string;email:string|null;request_id:string;feature_key:string;source:string;requested_model_id:string|null;actual_model_id:string|null;model_config_version:number|null;input_tokens:number;output_tokens:number;cost_microusd:string;duration_ms:number|null;created_at:Date;total_count:string};
|
||||
export async function GET(request:Request){try{await requirePermission("billing.orders.read");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows<Row>(`select l.id,l.user_id,u.email,l.request_id,l.feature_key,l.source,l.requested_model_id,l.actual_model_id,l.model_config_version,l.input_tokens,l.output_tokens,l.cost_microusd::text,l.duration_ms,l.created_at,count(*) over()::text total_count from public.usage_ledger l left join identity.users u on u.id=l.user_id where ($1::text is null or u.email ilike $1 or l.request_id ilike $1 or l.actual_model_id ilike $1) and ($2::text is null or l.source=$2 or l.feature_key=$2) order by l.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,userId:r.user_id,email:r.email,requestId:r.request_id,featureKey:r.feature_key,source:r.source,requestedModelId:r.requested_model_id,actualModelId:r.actual_model_id,modelConfigVersion:r.model_config_version,inputTokens:r.input_tokens,outputTokens:r.output_tokens,costMicrousd:Number(r.cost_microusd),durationMs:r.duration_ms,createdAt:r.created_at.toISOString()})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}}
|
||||
@@ -5,36 +5,189 @@ 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 {
|
||||
epaySubmitUrl,
|
||||
readEpayConfig,
|
||||
EpayConfigurationError,
|
||||
} from "@/lib/epay/config";
|
||||
import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
|
||||
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const schema = z.object({ packageId: z.string().uuid() });
|
||||
const schema = z
|
||||
.object({
|
||||
productId: z.string().uuid().optional(),
|
||||
packageId: z.string().uuid().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) => Boolean(value.productId || value.packageId),
|
||||
"product_required",
|
||||
);
|
||||
|
||||
type ProductRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
product_type: "credit_pack" | "trial" | "subscription";
|
||||
billing_period: "none" | "day" | "month" | "year";
|
||||
interval_count: number;
|
||||
price_cents: number;
|
||||
currency: string;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
one_time_per_user: boolean;
|
||||
effective_from: string | null;
|
||||
effective_to: string | null;
|
||||
};
|
||||
|
||||
type EntitlementRow = {
|
||||
feature_key: string;
|
||||
allowance_type: string;
|
||||
allowance_count: number | null;
|
||||
reset_period: string;
|
||||
model_tier: string | null;
|
||||
fair_use_policy_id: string | null;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const client = await createServerSupabaseClient();
|
||||
const { data: { user } } = await client.auth.getUser();
|
||||
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 });
|
||||
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 });
|
||||
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 productId = parsed.data.productId ?? parsed.data.packageId!;
|
||||
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 { data, error: productError } = await admin
|
||||
.from("billing_products")
|
||||
.select(
|
||||
"id,code,version,name,description,product_type,billing_period,interval_count,price_cents,currency,enabled,status,one_time_per_user,effective_from,effective_to",
|
||||
)
|
||||
.eq("id", productId)
|
||||
.maybeSingle();
|
||||
const product = data as ProductRow | null;
|
||||
const now = Date.now();
|
||||
if (
|
||||
productError ||
|
||||
!product ||
|
||||
!product.enabled ||
|
||||
product.status !== "published" ||
|
||||
(product.effective_from && Date.parse(product.effective_from) > now) ||
|
||||
(product.effective_to && Date.parse(product.effective_to) <= now)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: "套餐不存在或已下架" },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
if (product.product_type !== "credit_pack") {
|
||||
const flags = await loadRuntimeFeatureFlags(["billing.subscriptions"]);
|
||||
if (!flags.get("billing.subscriptions")?.enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: "订阅套餐暂未开放", code: "BILLING_SUBSCRIPTIONS_DISABLED" },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (product.one_time_per_user) {
|
||||
const { data: redemption, error: redemptionError } = await admin
|
||||
.from("user_product_redemptions")
|
||||
.select("id")
|
||||
.eq("user_id", user.id)
|
||||
.eq("product_code", product.code)
|
||||
.maybeSingle();
|
||||
if (redemptionError)
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法核对体验资格" },
|
||||
{ status: 500 },
|
||||
);
|
||||
if (redemption)
|
||||
return NextResponse.json(
|
||||
{ error: "该体验套餐每位用户仅限一次" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const { data: entitlementRows, error: entitlementError } = await admin
|
||||
.from("product_entitlements")
|
||||
.select(
|
||||
"feature_key,allowance_type,allowance_count,reset_period,model_tier,fair_use_policy_id,metadata",
|
||||
)
|
||||
.eq("product_id", product.id);
|
||||
if (entitlementError)
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法读取套餐权益" },
|
||||
{ status: 500 },
|
||||
);
|
||||
const entitlements = ((entitlementRows ?? []) as EntitlementRow[]).map(
|
||||
(item) => ({
|
||||
featureKey: item.feature_key,
|
||||
allowanceType: item.allowance_type,
|
||||
allowanceCount: item.allowance_count,
|
||||
resetPeriod: item.reset_period,
|
||||
modelTier: item.model_tier,
|
||||
fairUsePolicyId: item.fair_use_policy_id,
|
||||
metadata: item.metadata,
|
||||
}),
|
||||
);
|
||||
const credits =
|
||||
entitlements.find((item) => item.allowanceType === "credits")
|
||||
?.allowanceCount ?? 0;
|
||||
const productSnapshot = {
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
version: product.version,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
productType: product.product_type,
|
||||
billingPeriod: product.billing_period,
|
||||
intervalCount: product.interval_count,
|
||||
priceCents: product.price_cents,
|
||||
currency: product.currency,
|
||||
oneTimePerUser: product.one_time_per_user,
|
||||
};
|
||||
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 { error: orderError } = await admin.from("payment_orders").insert({
|
||||
order_no: orderNo,
|
||||
user_id: user.id,
|
||||
package_id: product.product_type === "credit_pack" ? product.id : null,
|
||||
product_id: product.id,
|
||||
product_code: product.code,
|
||||
product_version: product.version,
|
||||
product_snapshot: productSnapshot,
|
||||
entitlement_snapshot: entitlements,
|
||||
money_cents: product.price_cents,
|
||||
currency: product.currency,
|
||||
credits,
|
||||
grant_type:
|
||||
product.product_type === "credit_pack"
|
||||
? "credits"
|
||||
: product.product_type,
|
||||
grant_status: "pending",
|
||||
});
|
||||
if (orderError)
|
||||
return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
|
||||
|
||||
const params = {
|
||||
money: (pack.price_cents / 100).toFixed(2),
|
||||
name: pack.name,
|
||||
money: (product.price_cents / 100).toFixed(2),
|
||||
name: product.name,
|
||||
notify_url: config.notifyUrl,
|
||||
out_trade_no: orderNo,
|
||||
pid: config.pid,
|
||||
@@ -42,12 +195,26 @@ export async function POST(request: Request) {
|
||||
sitename: config.siteName,
|
||||
type: "alipay",
|
||||
};
|
||||
const signedParams = { ...params, sign: epaySign(params, config.key), sign_type: "MD5" };
|
||||
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 });
|
||||
for (const [name, value] of Object.entries(signedParams))
|
||||
payUrl.searchParams.set(name, value);
|
||||
return NextResponse.json({
|
||||
orderNo,
|
||||
payUrl: payUrl.toString(),
|
||||
qrCode: null,
|
||||
product: productSnapshot,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 });
|
||||
if (error instanceof EpayConfigurationError)
|
||||
return NextResponse.json(
|
||||
{ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" },
|
||||
{ status: 503 },
|
||||
);
|
||||
return NextResponse.json({ error: "创建支付失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export const runtime = "nodejs";
|
||||
|
||||
const notify = createEpayNotifyHandler({
|
||||
readConfig: readEpayConfig,
|
||||
settle: async (args) => await createAdminSupabaseClient().rpc("settle_epay_order", args),
|
||||
settle: async (args) => await createAdminSupabaseClient().rpc("settle_order", args),
|
||||
});
|
||||
|
||||
export async function POST(request: Request) { return notify(request); }
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
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 }); }
|
||||
|
||||
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 admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin
|
||||
.from("payment_orders")
|
||||
.select("order_no,status,credits,paid_at,grant_type,grant_status,grant_reference_id,grant_error,product_snapshot")
|
||||
.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 });
|
||||
|
||||
let subscription = null;
|
||||
if (data.grant_reference_id && (data.grant_type === "subscription" || data.grant_type === "trial")) {
|
||||
const result = await admin
|
||||
.from("user_subscriptions")
|
||||
.select("id,status,starts_at,ends_at,product_code,product_version")
|
||||
.eq("id", data.grant_reference_id)
|
||||
.maybeSingle();
|
||||
if (!result.error) subscription = result.data;
|
||||
}
|
||||
return NextResponse.json({
|
||||
orderNo: data.order_no,
|
||||
status: data.status,
|
||||
credits: data.credits,
|
||||
paidAt: data.paid_at,
|
||||
grantType: data.grant_type,
|
||||
grantStatus: data.grant_status,
|
||||
grantError: data.grant_error,
|
||||
product: data.product_snapshot,
|
||||
subscription,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,28 +1,116 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { readEpayAvailability } from "@/lib/epay/availability";
|
||||
import { loadRuntimeFeatureFlags } from "@/lib/feature-flags";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type EntitlementRow = {
|
||||
feature_key: string;
|
||||
allowance_type: string;
|
||||
allowance_count: number | null;
|
||||
reset_period: string;
|
||||
model_tier: string | null;
|
||||
fair_use_policy_id: string | null;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
type EntitlementRowWithProduct = EntitlementRow & { product_id: string };
|
||||
|
||||
type ProductRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
version: number;
|
||||
name: string;
|
||||
description: string;
|
||||
product_type: "credit_pack" | "trial" | "subscription";
|
||||
billing_period: "none" | "day" | "month" | "year";
|
||||
interval_count: number;
|
||||
price_cents: number;
|
||||
currency: string;
|
||||
sort_order: number;
|
||||
effective_from: string | null;
|
||||
effective_to: string | null;
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const availability = await readEpayAvailability();
|
||||
if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] });
|
||||
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")
|
||||
const flags = await loadRuntimeFeatureFlags(["billing.subscriptions"]);
|
||||
const subscriptionsEnabled =
|
||||
flags.get("billing.subscriptions")?.enabled ?? false;
|
||||
|
||||
const admin = createAdminSupabaseClient();
|
||||
const { data, error } = await admin
|
||||
.from("billing_products")
|
||||
.select(
|
||||
"id,code,version,name,description,product_type,billing_period,interval_count,price_cents,currency,sort_order,effective_from,effective_to",
|
||||
)
|
||||
.eq("enabled", true)
|
||||
.eq("status", "published")
|
||||
.order("sort_order")
|
||||
.order("created_at");
|
||||
if (error) return NextResponse.json({ enabled: false, packages: [] });
|
||||
|
||||
const now = Date.now();
|
||||
const products = ((data ?? []) as ProductRow[]).filter(
|
||||
(product) =>
|
||||
(!product.effective_from || Date.parse(product.effective_from) <= now) &&
|
||||
(!product.effective_to || Date.parse(product.effective_to) > now) &&
|
||||
(subscriptionsEnabled || product.product_type === "credit_pack"),
|
||||
);
|
||||
const productIds = products.map((product) => product.id);
|
||||
const { data: entitlementData, error: entitlementError } =
|
||||
productIds.length === 0
|
||||
? { data: [] as EntitlementRowWithProduct[], error: null }
|
||||
: await admin
|
||||
.from("product_entitlements")
|
||||
.select(
|
||||
"product_id,feature_key,allowance_type,allowance_count,reset_period,model_tier,fair_use_policy_id,metadata",
|
||||
)
|
||||
.in("product_id", productIds);
|
||||
if (entitlementError)
|
||||
return NextResponse.json({ enabled: false, packages: [] });
|
||||
const entitlementsByProduct = new Map<string, EntitlementRow[]>();
|
||||
for (const entitlement of (entitlementData ??
|
||||
[]) as EntitlementRowWithProduct[]) {
|
||||
const current = entitlementsByProduct.get(entitlement.product_id) ?? [];
|
||||
current.push(entitlement);
|
||||
entitlementsByProduct.set(entitlement.product_id, current);
|
||||
}
|
||||
|
||||
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,
|
||||
})),
|
||||
packages: products.map((product) => {
|
||||
const entitlements = entitlementsByProduct.get(product.id) ?? [];
|
||||
const creditEntitlement = entitlements.find(
|
||||
(item) => item.allowance_type === "credits",
|
||||
);
|
||||
return {
|
||||
id: product.id,
|
||||
productId: product.id,
|
||||
code: product.code,
|
||||
version: product.version,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
productType: product.product_type,
|
||||
billingPeriod: product.billing_period,
|
||||
intervalCount: product.interval_count,
|
||||
priceCents: product.price_cents,
|
||||
currency: product.currency,
|
||||
credits: creditEntitlement?.allowance_count ?? 0,
|
||||
entitlements: entitlements.map((item) => ({
|
||||
featureKey: item.feature_key,
|
||||
allowanceType: item.allowance_type,
|
||||
allowanceCount: item.allowance_count,
|
||||
resetPeriod: item.reset_period,
|
||||
modelTier: item.model_tier,
|
||||
fairUsePolicyId: item.fair_use_policy_id,
|
||||
metadata: item.metadata,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user