feat(billing): add feature pricing configuration
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { FeaturePricingManagement } from "@/components/admin/feature-pricing-management";
|
||||
|
||||
export default function FeaturePricingPage() {
|
||||
return <FeaturePricingManagement />;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { requirePermission } from "@/lib/admin/auth";
|
||||
import { adminErrorResponse, requestId, requireAdminMutation } from "@/lib/admin/http";
|
||||
import { queryAdminRows } from "@/lib/admin/database";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const featureKey = z.enum([
|
||||
"chat.standard",
|
||||
"chat.premium",
|
||||
"rectification",
|
||||
"report.full",
|
||||
"report.export",
|
||||
"profile.extra",
|
||||
]);
|
||||
const modelTier = z.enum(["standard", "premium", "internal"]);
|
||||
const mutationSchema = z.discriminatedUnion("action", [
|
||||
z.object({
|
||||
action: z.literal("saveDraft"),
|
||||
id: z.string().uuid().nullable().optional(),
|
||||
featureKey,
|
||||
modelTier,
|
||||
creditCost: z.number().int().positive(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict(),
|
||||
z.object({
|
||||
action: z.literal("publish"),
|
||||
id: z.string().uuid(),
|
||||
reason: z.string().trim().min(1).max(500),
|
||||
}).strict(),
|
||||
]);
|
||||
|
||||
type FeaturePricingRow = {
|
||||
id: string;
|
||||
feature_key: string;
|
||||
model_tier: string;
|
||||
credit_cost: number;
|
||||
version: number;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
effective_from: Date | null;
|
||||
effective_to: Date | null;
|
||||
updated_at: Date;
|
||||
};
|
||||
|
||||
function output(row: FeaturePricingRow) {
|
||||
return {
|
||||
id: row.id,
|
||||
featureKey: row.feature_key,
|
||||
modelTier: row.model_tier,
|
||||
creditCost: row.credit_cost,
|
||||
version: row.version,
|
||||
status: row.status,
|
||||
enabled: row.enabled,
|
||||
effectiveFrom: row.effective_from?.toISOString() ?? null,
|
||||
effectiveTo: row.effective_to?.toISOString() ?? null,
|
||||
updatedAt: row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requirePermission("billing.products.read");
|
||||
const rows = await queryAdminRows<FeaturePricingRow>(`
|
||||
select id, feature_key, model_tier, credit_cost, version, status, enabled,
|
||||
effective_from, effective_to, updated_at
|
||||
from public.feature_pricing
|
||||
order by feature_key, model_tier, version desc
|
||||
`);
|
||||
return NextResponse.json({ data: rows.map(output), total: rows.length });
|
||||
} 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 NextResponse.json({ error: "请求格式不正确", details: body.error.flatten() }, { status: 400 });
|
||||
}
|
||||
const permission = body.data.action === "publish" ? "billing.products.publish" : "billing.products.write";
|
||||
const session = await requireAdminMutation(request, permission);
|
||||
const rid = requestId(request);
|
||||
if (body.data.action === "publish") {
|
||||
const rows = await queryAdminRows<{ id: string }>(
|
||||
"select public.admin_publish_feature_pricing($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 rows = await queryAdminRows<{ id: string }>(
|
||||
"select public.admin_save_feature_pricing_draft($1,$2,$3,$4,$5,$6,$7) id",
|
||||
[session.user.id, body.data.id ?? null, body.data.featureKey, body.data.modelTier, body.data.creditCost, body.data.reason, rid],
|
||||
);
|
||||
return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } });
|
||||
} catch (error) {
|
||||
return adminErrorResponse(error);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,8 @@ export function AdminApp({ children }: { children: ReactNode }) {
|
||||
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
|
||||
{ name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: <ExperimentOutlined /> } },
|
||||
{ name: "models", list: "/admin/models", meta: { label: "模型配置", icon: <ApiOutlined /> } },
|
||||
{ name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: <CreditCardOutlined /> } },
|
||||
{ name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: <ExperimentOutlined /> } },
|
||||
{ name: "model-releases", list: "/admin/model-releases", meta: { label: "模型发布", icon: <ControlOutlined /> } },
|
||||
{ name: "feature-flags", list: "/admin/feature-flags", meta: { label: "功能开关", icon: <ControlOutlined /> } },
|
||||
{ name: "security", list: "/admin/security", meta: { label: "安全验证", icon: <SafetyCertificateOutlined /> } },
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { EditOutlined, PlusOutlined, UploadOutlined } from "@ant-design/icons";
|
||||
import { useGetIdentity } from "@refinedev/core";
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { adminRequestJson } from "@/lib/admin/providers";
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
featureKey: string;
|
||||
modelTier: string;
|
||||
creditCost: number;
|
||||
version: number;
|
||||
status: string;
|
||||
enabled: boolean;
|
||||
effectiveFrom: string | null;
|
||||
effectiveTo: string | null;
|
||||
};
|
||||
|
||||
const featureOptions = ["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra"];
|
||||
const tierOptions = ["standard", "premium", "internal"];
|
||||
|
||||
export function FeaturePricingManagement() {
|
||||
const { data: identity } = useGetIdentity<{ permissions?: string[] }>();
|
||||
const [rows, setRows] = useState<Row[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const [editing, setEditing] = useState<Row | null | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
const canWrite = Boolean(identity?.permissions?.includes("billing.products.write"));
|
||||
const canPublish = Boolean(identity?.permissions?.includes("billing.products.publish"));
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setIsLoading(true);
|
||||
adminRequestJson<{ data: Row[] }>("/api/admin/feature-pricing")
|
||||
.then((payload) => { if (active) setRows(payload.data); })
|
||||
.finally(() => { if (active) setIsLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [refresh]);
|
||||
|
||||
const refetch = async () => setRefresh((value) => value + 1);
|
||||
|
||||
async function save() {
|
||||
const values = await form.validateFields();
|
||||
await adminRequestJson("/api/admin/feature-pricing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "saveDraft", ...values, id: editing?.id ?? null, reason: values.reason }),
|
||||
});
|
||||
setEditing(undefined);
|
||||
form.resetFields();
|
||||
await refetch();
|
||||
}
|
||||
|
||||
async function publish(row: Row) {
|
||||
await adminRequestJson("/api/admin/feature-pricing", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "publish", id: row.id, reason: "后台发布功能定价草稿" }),
|
||||
});
|
||||
await refetch();
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="功能级定价"
|
||||
extra={canWrite ? <Button icon={<PlusOutlined />} onClick={() => setEditing(null)}>新建草稿</Button> : null}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
价格只在服务端计费解析和本页后台接口可见;没有发布配置的功能会 fail-closed,不会回退到模型默认点数。
|
||||
</Typography.Paragraph>
|
||||
<Table<Row>
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={rows}
|
||||
columns={[
|
||||
{ title: "功能", dataIndex: "featureKey" },
|
||||
{ title: "模型档位", dataIndex: "modelTier" },
|
||||
{ title: "积分", dataIndex: "creditCost" },
|
||||
{ title: "版本", dataIndex: "version" },
|
||||
{ title: "状态", dataIndex: "status", render: (value: string) => <Tag color={value === "published" ? "green" : "default"}>{value}</Tag> },
|
||||
{
|
||||
title: "操作",
|
||||
render: (_: unknown, row: Row) => (
|
||||
<Space>
|
||||
{row.status === "draft" && canWrite ? <Button icon={<EditOutlined />} onClick={() => {
|
||||
form.setFieldsValue({ featureKey: row.featureKey, modelTier: row.modelTier, creditCost: row.creditCost });
|
||||
setEditing(row);
|
||||
}}>编辑</Button> : null}
|
||||
{row.status === "draft" && canPublish ? <Button icon={<UploadOutlined />} onClick={() => publish(row)}>发布</Button> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal open={editing !== undefined} title={editing ? "编辑定价草稿" : "新建定价草稿"} onCancel={() => { setEditing(undefined); form.resetFields(); }} onOk={save} okButtonProps={{ disabled: !canWrite }}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="featureKey" label="功能" rules={[{ required: true }]}><Select options={featureOptions.map((value) => ({ value, label: value }))} disabled={Boolean(editing)} /></Form.Item>
|
||||
<Form.Item name="modelTier" label="模型档位" rules={[{ required: true }]}><Select options={tierOptions.map((value) => ({ value, label: value }))} disabled={Boolean(editing)} /></Form.Item>
|
||||
<Form.Item name="creditCost" label="积分" rules={[{ required: true, type: "number", min: 1 }]}><InputNumber precision={0} min={1} style={{ width: "100%" }} /></Form.Item>
|
||||
<Form.Item name="reason" label="变更原因" rules={[{ required: true, min: 1, max: 500 }]}><Input /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -97,7 +97,7 @@ export async function authorizeUsage(
|
||||
input: {
|
||||
userId: string;
|
||||
requestId: string;
|
||||
featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export";
|
||||
featureKey: "chat.standard" | "chat.premium" | "rectification" | "report.full" | "report.export" | "profile.extra";
|
||||
requestedModelId: string;
|
||||
creditCost: number;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import "server-only";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
const featurePricingSchema = z.object({
|
||||
feature_key: z.string(),
|
||||
model_tier: z.enum(["standard", "premium", "internal"]),
|
||||
credit_cost: z.number().int().positive(),
|
||||
version: z.number().int().positive(),
|
||||
});
|
||||
|
||||
type PricingClient = {
|
||||
rpc(rpcName: string, args: Record<string, unknown>): PromiseLike<{
|
||||
data: unknown;
|
||||
error: { message: string } | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export class FeaturePricingError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string) {
|
||||
super(`Feature pricing failed: ${code}`);
|
||||
this.name = "FeaturePricingError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type FeaturePricing = z.infer<typeof featurePricingSchema>;
|
||||
|
||||
export async function resolveFeaturePricing(
|
||||
accounting: PricingClient,
|
||||
featureKey: FeaturePricing["feature_key"],
|
||||
modelId: string,
|
||||
): Promise<FeaturePricing> {
|
||||
try {
|
||||
const { data, error } = await accounting.rpc("resolve_feature_pricing", {
|
||||
p_feature_key: featureKey,
|
||||
p_model_id: modelId,
|
||||
});
|
||||
const row = Array.isArray(data) ? data[0] : data;
|
||||
const parsed = featurePricingSchema.safeParse(row);
|
||||
if (error) throw new FeaturePricingError(error.message);
|
||||
if (!parsed.success) throw new FeaturePricingError("feature_pricing_invalid_response");
|
||||
return parsed.data;
|
||||
} catch (error) {
|
||||
if (error instanceof FeaturePricingError) throw error;
|
||||
throw new FeaturePricingError(error instanceof Error ? error.message : "feature_pricing_unavailable");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user