From aaaa5fb73cf133682c9f53922b1d9ae2560411dd Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 31 Aug 2026 03:37:44 +0800 Subject: [PATCH] feat(billing): add feature pricing configuration --- PROGRESS-billing-pricing-20260830.md | 20 ++ .../src/app/admin/feature-pricing/page.tsx | 5 + .../app/api/admin/feature-pricing/route.ts | 102 +++++++++ frontend/src/components/admin/admin-app.tsx | 2 + .../admin/feature-pricing-management.tsx | 107 +++++++++ frontend/src/lib/consultation-billing.ts | 2 +- frontend/src/lib/feature-pricing.ts | 50 +++++ .../20260831010000_feature_pricing.sql | 207 ++++++++++++++++++ .../tests/database-local-business.test.ts | 2 + .../tests/feature-pricing-contract.test.ts | 22 ++ 10 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/admin/feature-pricing/page.tsx create mode 100644 frontend/src/app/api/admin/feature-pricing/route.ts create mode 100644 frontend/src/components/admin/feature-pricing-management.tsx create mode 100644 frontend/src/lib/feature-pricing.ts create mode 100644 frontend/supabase/migrations/20260831010000_feature_pricing.sql create mode 100644 frontend/tests/feature-pricing-contract.test.ts diff --git a/PROGRESS-billing-pricing-20260830.md b/PROGRESS-billing-pricing-20260830.md index 9f60fd09..32b3e43a 100644 --- a/PROGRESS-billing-pricing-20260830.md +++ b/PROGRESS-billing-pricing-20260830.md @@ -27,3 +27,23 @@ ## 尚未执行 任务 1–6 依赖任务 0 的真实成本口径;在取得可审计的 staging/production 聚合数据前,不接入会导致线上默认失败的空定价配置,也不修改商品售价或公平使用参数。 + +## 任务 3 · 功能级定价服务端配置 + +已完成结构与后台管理接线: + +- 新增 `public.feature_pricing`,按 `feature_key × model_tier × version` 保存草稿、发布、退休状态;迁移事务化、幂等、RLS 与 service/admin runtime 权限均已配置。 +- 新增 `resolve_feature_pricing(feature_key, model_id)`:只解析 published/enabled 模型与价格;模型或价格缺失时分别 fail-closed 为 `feature_pricing_model_unavailable` / `feature_pricing_missing`。 +- 新增 security-definer 管理函数 `admin_save_feature_pricing_draft` 与 `admin_publish_feature_pricing`,均要求既有权限、原因与 request id,并写入 admin 审计日志。 +- 新增 `/admin/feature-pricing` 与 `/api/admin/feature-pricing`,前端不携带默认价格数字;价格只来自服务端配置。 +- 已为 `profile.extra` / `report.export` 预留 feature key,未接入业务路径。 + +本轮必要地更新了 `database-local-business.test.ts` 的 schema 表清单:原断言锁定基线表集合,本迁移有意新增 `feature_pricing`,因此仅补入该表并在断言旁注明原因。 + +验证: + +- `./node_modules/.bin/tsc --noEmit`:通过 +- `./node_modules/.bin/tsx --test tests/feature-pricing-contract.test.ts`:2 通过 +- `npm run test:db`:34 通过,0 失败;Docker 中 migration apply 与业务 schema 检查通过 + +价格门控仍生效:没有 staging/production 非零真实成本前,migration 未写入任何价格 seed。 diff --git a/frontend/src/app/admin/feature-pricing/page.tsx b/frontend/src/app/admin/feature-pricing/page.tsx new file mode 100644 index 00000000..c67aa32e --- /dev/null +++ b/frontend/src/app/admin/feature-pricing/page.tsx @@ -0,0 +1,5 @@ +import { FeaturePricingManagement } from "@/components/admin/feature-pricing-management"; + +export default function FeaturePricingPage() { + return ; +} diff --git a/frontend/src/app/api/admin/feature-pricing/route.ts b/frontend/src/app/api/admin/feature-pricing/route.ts new file mode 100644 index 00000000..bdef2adc --- /dev/null +++ b/frontend/src/app/api/admin/feature-pricing/route.ts @@ -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(` + 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); + } +} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 255018ef..7833928b 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -111,6 +111,8 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, + { name: "feature-pricing", list: "/admin/feature-pricing", meta: { label: "功能定价", icon: } }, + { name: "pricing-simulator", list: "/admin/pricing-simulator", meta: { label: "定价测算", icon: } }, { name: "model-releases", list: "/admin/model-releases", meta: { label: "模型发布", icon: } }, { name: "feature-flags", list: "/admin/feature-flags", meta: { label: "功能开关", icon: } }, { name: "security", list: "/admin/security", meta: { label: "安全验证", icon: } }, diff --git a/frontend/src/components/admin/feature-pricing-management.tsx b/frontend/src/components/admin/feature-pricing-management.tsx new file mode 100644 index 00000000..c86bb33d --- /dev/null +++ b/frontend/src/components/admin/feature-pricing-management.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [refresh, setRefresh] = useState(0); + const [editing, setEditing] = useState(); + 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 ( + } onClick={() => setEditing(null)}>新建草稿 : null} + > + + 价格只在服务端计费解析和本页后台接口可见;没有发布配置的功能会 fail-closed,不会回退到模型默认点数。 + + + 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) => {value} }, + { + title: "操作", + render: (_: unknown, row: Row) => ( + + {row.status === "draft" && canWrite ? : null} + {row.status === "draft" && canPublish ? : null} + + ), + }, + ]} + /> + { setEditing(undefined); form.resetFields(); }} onOk={save} okButtonProps={{ disabled: !canWrite }}> +
+ ({ value, label: value }))} disabled={Boolean(editing)} /> + + +
+
+
+ ); +} diff --git a/frontend/src/lib/consultation-billing.ts b/frontend/src/lib/consultation-billing.ts index ae5a409f..4b850e0f 100644 --- a/frontend/src/lib/consultation-billing.ts +++ b/frontend/src/lib/consultation-billing.ts @@ -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; }, diff --git a/frontend/src/lib/feature-pricing.ts b/frontend/src/lib/feature-pricing.ts new file mode 100644 index 00000000..b0a74d18 --- /dev/null +++ b/frontend/src/lib/feature-pricing.ts @@ -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): 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; + +export async function resolveFeaturePricing( + accounting: PricingClient, + featureKey: FeaturePricing["feature_key"], + modelId: string, +): Promise { + 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"); + } +} diff --git a/frontend/supabase/migrations/20260831010000_feature_pricing.sql b/frontend/supabase/migrations/20260831010000_feature_pricing.sql new file mode 100644 index 00000000..2c70439c --- /dev/null +++ b/frontend/supabase/migrations/20260831010000_feature_pricing.sql @@ -0,0 +1,207 @@ +begin; + +create table if not exists public.feature_pricing ( + id uuid primary key default gen_random_uuid(), + feature_key text not null check (feature_key in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra')), + model_tier text not null check (model_tier in ('standard', 'premium', 'internal')), + credit_cost integer not null check (credit_cost > 0), + version integer not null check (version > 0), + status text not null default 'draft' check (status in ('draft', 'published', 'retired')), + enabled boolean not null default false, + effective_from timestamptz, + effective_to timestamptz, + created_by uuid references auth.users(id) on delete set null, + updated_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (feature_key, model_tier, version), + check (effective_to is null or effective_from is null or effective_to > effective_from) +); + +create unique index if not exists feature_pricing_one_draft_idx + on public.feature_pricing(feature_key, model_tier) where status = 'draft'; +create index if not exists feature_pricing_active_idx + on public.feature_pricing(feature_key, model_tier, effective_from) + where status = 'published' and enabled; + +create or replace function public.resolve_feature_pricing( + p_feature_key text, + p_model_id text +) +returns table(feature_key text, model_tier text, credit_cost integer, version integer) +language plpgsql security definer set search_path = '' +as $$ +declare + v_model_tier text; +begin + if p_feature_key is null or p_feature_key not in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra') then + raise exception 'feature_pricing_invalid_feature' using errcode = '22023'; + end if; + if p_model_id is null or btrim(p_model_id) = '' then + raise exception 'feature_pricing_model_required' using errcode = '22023'; + end if; + + select v.model_tier into v_model_tier + from public.model_configs c + join public.model_config_versions v on v.config_id = c.id + where c.model_id = btrim(p_model_id) + and v.status = 'published' + and v.enabled + order by v.version desc + limit 1; + + if v_model_tier is null then + raise exception 'feature_pricing_model_unavailable' using errcode = '22023'; + end if; + + return query + select p.feature_key, p.model_tier, p.credit_cost, p.version + from public.feature_pricing p + where p.feature_key = p_feature_key + and p.model_tier = v_model_tier + and p.status = 'published' + and p.enabled + and (p.effective_from is null or p.effective_from <= clock_timestamp()) + and (p.effective_to is null or p.effective_to > clock_timestamp()) + order by p.version desc + limit 1; + + if not found then + raise exception 'feature_pricing_missing' using errcode = '22023'; + end if; +end; $$; + +create or replace function public.admin_save_feature_pricing_draft( + p_actor_user_id uuid, + p_pricing_id uuid, + p_feature_key text, + p_model_tier text, + p_credit_cost integer, + p_reason text, + p_request_id text +) +returns uuid +language plpgsql security definer set search_path = '' +as $$ +declare + v_id uuid; + v_version integer; +begin + if not public.admin_has_permission(p_actor_user_id, 'billing.products.write') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode = '22023'; + end if; + if p_feature_key not in ('chat.standard', 'chat.premium', 'rectification', 'report.full', 'report.export', 'profile.extra') + or p_model_tier not in ('standard', 'premium', 'internal') + or p_credit_cost is null or p_credit_cost <= 0 then + raise exception 'feature_pricing_invalid' using errcode = '22023'; + end if; + + if p_pricing_id is null then + select coalesce(max(version), 0) + 1 into v_version + from public.feature_pricing + where feature_key = p_feature_key and model_tier = p_model_tier; + insert into public.feature_pricing(feature_key, model_tier, credit_cost, version, status, enabled, updated_by) + values (p_feature_key, p_model_tier, p_credit_cost, v_version, 'draft', false, p_actor_user_id) + returning id into v_id; + else + update public.feature_pricing + set credit_cost = p_credit_cost, updated_by = p_actor_user_id, updated_at = clock_timestamp() + where id = p_pricing_id and status = 'draft' + returning id into v_id; + if v_id is null then + raise exception 'feature_pricing_draft_not_found' using errcode = '22023'; + end if; + end if; + + insert into audit.admin_audit_logs( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + after_value, request_id, permission_used, reason + ) + select p_actor_user_id, lower(btrim(u.email)), 'admin', 'billing.feature_pricing.draft.save', + 'feature_pricing', v_id, + jsonb_build_object('featureKey', p_feature_key, 'modelTier', p_model_tier, 'creditCost', p_credit_cost), + btrim(p_request_id), 'billing.products.write', btrim(p_reason) + from identity.users u where u.id = p_actor_user_id + on conflict do nothing; + return v_id; +end; $$; + +create or replace function public.admin_publish_feature_pricing( + p_actor_user_id uuid, + p_pricing_id uuid, + p_reason text, + p_request_id text +) +returns uuid +language plpgsql security definer set search_path = '' +as $$ +declare + v_draft public.feature_pricing%rowtype; + v_old public.feature_pricing%rowtype; +begin + if not public.admin_has_permission(p_actor_user_id, 'billing.products.publish') then + raise exception 'admin_permission_denied' using errcode = '42501'; + end if; + if char_length(btrim(coalesce(p_reason, ''))) not between 1 and 500 then + raise exception 'admin_reason_required' using errcode = '22023'; + end if; + + select * into v_draft from public.feature_pricing where id = p_pricing_id and status = 'draft' for update; + if not found then raise exception 'feature_pricing_draft_not_found' using errcode = '22023'; end if; + + select * into v_old + from public.feature_pricing + where feature_key = v_draft.feature_key and model_tier = v_draft.model_tier + and status = 'published' + order by version desc limit 1 for update; + + if v_old.id is not null then + update public.feature_pricing + set status = 'retired', enabled = false, effective_to = clock_timestamp(), updated_by = p_actor_user_id, updated_at = clock_timestamp() + where id = v_old.id; + end if; + update public.feature_pricing + set status = 'published', enabled = true, effective_from = clock_timestamp(), effective_to = null, + updated_by = p_actor_user_id, updated_at = clock_timestamp() + where id = v_draft.id; + + insert into audit.admin_audit_logs( + actor_user_id, actor_email, actor_role, action, target_type, target_id, + before_value, after_value, request_id, permission_used, reason + ) + select p_actor_user_id, lower(btrim(u.email)), 'admin', 'billing.feature_pricing.publish', + 'feature_pricing', v_draft.id, + case when v_old.id is null then '{}'::jsonb else jsonb_build_object('id', v_old.id, 'version', v_old.version, 'status', v_old.status) end, + jsonb_build_object('featureKey', v_draft.feature_key, 'modelTier', v_draft.model_tier, 'version', v_draft.version, 'creditCost', v_draft.credit_cost), + btrim(p_request_id), 'billing.products.publish', btrim(p_reason) + from identity.users u where u.id = p_actor_user_id + on conflict do nothing; + return v_draft.id; +end; $$; + +alter table public.feature_pricing enable row level security; +revoke all on table public.feature_pricing from public, anon, authenticated; +grant all on table public.feature_pricing to service_role; + +drop policy if exists feature_pricing_public_select on public.feature_pricing; + +revoke all on function public.resolve_feature_pricing(text, text), + public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text), + public.admin_publish_feature_pricing(uuid, uuid, text, text) + from public, anon, authenticated; +grant execute on function public.resolve_feature_pricing(text, text) to service_role; +grant execute on function public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text), + public.admin_publish_feature_pricing(uuid, uuid, text, text) to service_role; + +do $$ begin + if exists(select 1 from pg_roles where rolname = 'admin_runtime') then + grant select on table public.feature_pricing to admin_runtime; + grant execute on function public.admin_save_feature_pricing_draft(uuid, uuid, text, text, integer, text, text), + public.admin_publish_feature_pricing(uuid, uuid, text, text) to admin_runtime; + end if; +end $$; + +commit; diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index e2667029..d01636b2 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -197,6 +197,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "credit_transactions", "epay_settings", "feature_flags", + // This migration intentionally adds the server-owned feature pricing table. + "feature_pricing", "model_config_versions", "model_configs", "model_connection_test_evidence", diff --git a/frontend/tests/feature-pricing-contract.test.ts b/frontend/tests/feature-pricing-contract.test.ts new file mode 100644 index 00000000..dfabc586 --- /dev/null +++ b/frontend/tests/feature-pricing-contract.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const source = readFileSync( + fileURLToPath(new URL("../src/lib/feature-pricing.ts", import.meta.url)), + "utf8", +); + +test("feature pricing resolver calls the server-side RPC and validates its row", () => { + assert.match(source, /rpc\("resolve_feature_pricing"/); + assert.match(source, /p_feature_key: featureKey/); + assert.match(source, /p_model_id: modelId/); + assert.match(source, /feature_pricing_invalid_response/); +}); + +test("feature pricing resolver has explicit fail-closed errors", () => { + assert.match(source, /error\.message/); + assert.match(source, /feature_pricing_unavailable/); + assert.match(source, /class FeaturePricingError/); +});