fix: preserve billing response shape
This commit is contained in:
@@ -0,0 +1,87 @@
|
|||||||
|
# Task: Fix billing empty collection contract
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260817-fix-billing-empty-arrays-4f9d2a7c
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: main
|
||||||
|
- Worktree: D:\Datas\OthersProjects\NianAIGC
|
||||||
|
- Base commit: 600cba023ca39d6dd81e526ba1e6014d4bfad65b
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Make Go billing overview responses encode empty collections as JSON arrays,
|
||||||
|
never `null`.
|
||||||
|
- Add frontend response-shape validation so malformed billing payloads produce
|
||||||
|
an inline error instead of crashing the `/billing` route subtree.
|
||||||
|
- Replace the obsolete `npm run db:migrate` route-error guidance with wording
|
||||||
|
appropriate to the static Web + Go API architecture.
|
||||||
|
- Add regression coverage at both the Go HTTP contract seam and the pure
|
||||||
|
frontend payload parsing seam.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Preserve the accepted static Web + Go-only API architecture and existing
|
||||||
|
authorization behavior.
|
||||||
|
- Do not change database schema, seed fake organizations, or require production
|
||||||
|
data to make empty-state rendering safe.
|
||||||
|
- Keep the fix surgical: Go owns valid JSON output; frontend validation is a
|
||||||
|
defense against incompatible or malformed deployments.
|
||||||
|
- Work test-first from the deterministic diagnosis recorded in
|
||||||
|
`20260817-diagnose-billing-page-7c3a91e2`.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Go billing overview services now normalize every response collection used by
|
||||||
|
the member and super-administrator billing pages to a non-nil slice, so an
|
||||||
|
empty database result is encoded as `[]` rather than `null`.
|
||||||
|
- The browser validates every billing object, collection element, and nested
|
||||||
|
price dimension used by the renderer before committing API data to React
|
||||||
|
state. Incompatible successful responses now produce the existing inline
|
||||||
|
error state instead of throwing during render and activating the route error
|
||||||
|
boundary. Validation preserves Go `omitempty` behavior for optional fields,
|
||||||
|
including a fresh super-administrator without `organizationId`.
|
||||||
|
- The billing route error boundary and invalid-JSON fallback now direct
|
||||||
|
operators to the Go API status and server logs rather than the removed Node
|
||||||
|
migration workflow.
|
||||||
|
- Regression tests cover the real Go HTTP response contract with an empty store
|
||||||
|
and the frontend parser contract for empty, missing, null, and malformed
|
||||||
|
payloads.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- RED: `go test ./internal/httpapi -run TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays -count=1`
|
||||||
|
failed because empty member/admin collections encoded as `null`.
|
||||||
|
- RED: `npx vitest run tests/billing-api-contract.test.ts` failed before the
|
||||||
|
frontend parser module existed.
|
||||||
|
- PASS: `go test ./...`.
|
||||||
|
- PASS: `go vet ./...`.
|
||||||
|
- Initial final review: FAIL because top-level array checks still allowed
|
||||||
|
malformed collection elements such as `priceRules: [null]` and a null member
|
||||||
|
`organization` to reach render-time property access.
|
||||||
|
- Remediation: added complete render-shape validation and regressions for admin
|
||||||
|
organizations/wallets, members, ledger entries, price rules/dimensions/tiers,
|
||||||
|
and member organization/wallet/summary/personal data.
|
||||||
|
- PASS after remediation: `npm test` (`54` files, `174` tests).
|
||||||
|
- PASS: `npx tsc --noEmit --incremental false`.
|
||||||
|
- PASS: `npm run deploy:check` (`7` ACK manifest files).
|
||||||
|
- PASS: `npm run build` (all routes statically exported, including `/billing`).
|
||||||
|
- PASS: `check_project_docs.py --target .`.
|
||||||
|
- PASS: task-aware `check_doc_drift.py` for this task ID.
|
||||||
|
- PASS: `git diff --check` (line-ending notices only).
|
||||||
|
- Final read-only review: PASS on both Standards and Spec after the malformed
|
||||||
|
nested-payload remediation; no blockers remain.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- After publishing new Web and Go images under a fresh immutable tag, verify an
|
||||||
|
authenticated super-administrator with zero organizations sees the billing
|
||||||
|
empty state in production. This requires deployment access and is not a code
|
||||||
|
acceptance blocker.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- None. This task applies the existing static Web + Go API architecture and does
|
||||||
|
not introduce a new architectural decision.
|
||||||
@@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
|
|||||||
<div className="billing-route-error-card">
|
<div className="billing-route-error-card">
|
||||||
<span className="billing-route-error-kicker">计费中心</span>
|
<span className="billing-route-error-kicker">计费中心</span>
|
||||||
<h1>计费服务暂时不可用</h1>
|
<h1>计费服务暂时不可用</h1>
|
||||||
<p>请先刷新页面。如果问题持续,请检查服务端日志,并确认已通过 <code>npm run db:migrate</code> 完成数据库迁移。</p>
|
<p>请先刷新页面。如果问题持续,请检查 Go API 状态和服务端日志。</p>
|
||||||
<button type="button" onClick={() => reset()}>重新加载</button>
|
<button type="button" onClick={() => reset()}>重新加载</button>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -53,6 +53,9 @@ func (s *Service) Overview(ctx context.Context, organizationID, accountID string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
|
if ledger == nil {
|
||||||
|
ledger = []LedgerEntry{}
|
||||||
|
}
|
||||||
personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500)
|
personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
@@ -118,6 +121,18 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return AdminOverview{}, err
|
return AdminOverview{}, err
|
||||||
}
|
}
|
||||||
|
if organizations == nil {
|
||||||
|
organizations = []Organization{}
|
||||||
|
}
|
||||||
|
if members == nil {
|
||||||
|
members = []Member{}
|
||||||
|
}
|
||||||
|
if ledger == nil {
|
||||||
|
ledger = []LedgerEntry{}
|
||||||
|
}
|
||||||
|
if rules == nil {
|
||||||
|
rules = []PriceRule{}
|
||||||
|
}
|
||||||
byOrganization := map[string]Wallet{}
|
byOrganization := map[string]Wallet{}
|
||||||
for _, wallet := range wallets {
|
for _, wallet := range wallets {
|
||||||
byOrganization[wallet.OrganizationID] = wallet
|
byOrganization[wallet.OrganizationID] = wallet
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ func TestBillingMemberRoutesUseRefreshedSessionScope(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBillingOverviewResponsesEncodeEmptyCollectionsAsArrays(t *testing.T) {
|
||||||
|
service := billing.NewService(&emptyBillingStore{}, nil)
|
||||||
|
member := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeUser, User: identity.User{ID: "user", ClientID: "platform", OrganizationID: "org", Role: "user"}}, service, nil)
|
||||||
|
admin := billingTestHandler(t, identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "root", ClientID: "platform", Role: "super_admin"}}, service, nil)
|
||||||
|
|
||||||
|
assertJSONArrays(t, serveJSON(t, member, http.MethodGet, "/api/billing", nil), "ledger")
|
||||||
|
assertJSONArrays(t, serveJSON(t, admin, http.MethodGet, "/api/admin/billing", nil), "organizations", "members", "ledger", "priceRules")
|
||||||
|
}
|
||||||
|
|
||||||
func TestBillingQuotePreparesProviderAndModelFromServerOwnedEngineTargets(t *testing.T) {
|
func TestBillingQuotePreparesProviderAndModelFromServerOwnedEngineTargets(t *testing.T) {
|
||||||
service := &billingHTTPServiceStub{}
|
service := &billingHTTPServiceStub{}
|
||||||
h := billingTestHandler(t, identity.Session{User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", Role: "user"}}, service, nil)
|
h := billingTestHandler(t, identity.Session{User: identity.User{ID: "db-user", ClientID: "platform", OrganizationID: "db-org", Role: "user"}}, service, nil)
|
||||||
@@ -165,6 +174,39 @@ func (s *billingAccountStoreStub) Save(_ context.Context, value billing.AccountC
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type emptyBillingStore struct{}
|
||||||
|
|
||||||
|
func (*emptyBillingStore) BillingWallet(context.Context, string) (billing.Wallet, error) {
|
||||||
|
return billing.Wallet{}, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) BillingWallets(context.Context) ([]billing.Wallet, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) BillingLedger(context.Context, string, string, int) ([]billing.LedgerEntry, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) BillingOrganizations(context.Context) ([]billing.Organization, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) BillingMembers(context.Context) ([]billing.Member, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) BillingOrganizationExists(context.Context, string) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) ListBillingPriceRules(context.Context, bool) ([]billing.PriceRule, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) GetBillingPriceRule(context.Context, string) (*billing.PriceRule, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) UpdateBillingPriceRule(context.Context, string, billing.PricePatch) (*billing.PriceRule, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (*emptyBillingStore) PostBillingWalletEntry(context.Context, billing.WalletPostParams) (billing.WalletPosting, error) {
|
||||||
|
return billing.WalletPosting{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func billingTestHandler(t *testing.T, session identity.Session, service BillingHTTPService, accounts BillingAccountStore) http.Handler {
|
func billingTestHandler(t *testing.T, session identity.Session, service BillingHTTPService, accounts BillingAccountStore) http.Handler {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session})
|
authorizer, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: session})
|
||||||
@@ -192,3 +234,19 @@ func serveJSON(t *testing.T, h http.Handler, method, path string, body any) *htt
|
|||||||
h.ServeHTTP(response, req)
|
h.ServeHTTP(response, req)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertJSONArrays(t *testing.T, response *httptest.ResponseRecorder, fields ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
var payload map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
for _, field := range fields {
|
||||||
|
if string(payload[field]) != "[]" {
|
||||||
|
t.Errorf("%s = %s, want [] (body=%s)", field, payload[field], response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
X
|
X
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
|
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
|
||||||
|
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
|
||||||
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
|
||||||
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
|
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
|
||||||
|
|
||||||
@@ -161,8 +162,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
try {
|
try {
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
|
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
|
||||||
const adminPayload = await readApiPayload<AdminPayload>(adminResponse);
|
const rawAdminPayload = await readApiPayload<AdminPayload>(adminResponse);
|
||||||
if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败");
|
if (!adminResponse.ok) throw new Error(rawAdminPayload.error || "读取超管计费数据失败");
|
||||||
|
const adminPayload = parseAdminBillingPayload(rawAdminPayload);
|
||||||
setAdmin(adminPayload);
|
setAdmin(adminPayload);
|
||||||
setBilling(buildAdminBillingPayload(adminPayload));
|
setBilling(buildAdminBillingPayload(adminPayload));
|
||||||
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
|
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
|
||||||
@@ -172,8 +174,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
|
|||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
const billingResponse = await fetch("/api/billing", { cache: "no-store" });
|
const billingResponse = await fetch("/api/billing", { cache: "no-store" });
|
||||||
const billingPayload = await readApiPayload<BillingPayload>(billingResponse);
|
const rawBillingPayload = await readApiPayload<BillingPayload>(billingResponse);
|
||||||
if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败");
|
if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
|
||||||
|
const billingPayload = parseMemberBillingPayload(rawBillingPayload);
|
||||||
setBilling(billingPayload);
|
setBilling(billingPayload);
|
||||||
}
|
}
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
@@ -324,7 +327,7 @@ async function readApiPayload<T extends Record<string, unknown>>(response: Respo
|
|||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
error: response.status >= 500
|
error: response.status >= 500
|
||||||
? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。"
|
? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
|
||||||
: `服务器返回了无效响应(HTTP ${response.status})。`
|
: `服务器返回了无效响应(HTTP ${response.status})。`
|
||||||
} as T & { error?: string };
|
} as T & { error?: string };
|
||||||
}
|
}
|
||||||
|
|||||||
172
lib/client/billing-api.ts
Normal file
172
lib/client/billing-api.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
type PayloadRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function invalid(context: string, path: string, expected: string): never {
|
||||||
|
const separator = path === "响应" ? "" : " ";
|
||||||
|
throw new Error(`${context}数据格式错误:${path}${separator}必须是${expected}。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireRecord(value: unknown, path: string, context: string): PayloadRecord {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
invalid(context, path, "对象");
|
||||||
|
}
|
||||||
|
return value as PayloadRecord;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireArray(record: PayloadRecord, field: string, context: string, path = field): unknown[] {
|
||||||
|
if (!Array.isArray(record[field])) invalid(context, path, "数组");
|
||||||
|
return record[field];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(record: PayloadRecord, field: string, context: string, path: string): void {
|
||||||
|
if (typeof record[field] !== "string") invalid(context, path, "字符串");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireOptionalString(record: PayloadRecord, field: string, context: string, path: string): void {
|
||||||
|
if (record[field] !== undefined && record[field] !== null && typeof record[field] !== "string") {
|
||||||
|
invalid(context, path, "字符串");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireNumber(record: PayloadRecord, field: string, context: string, path: string): void {
|
||||||
|
if (typeof record[field] !== "number" || !Number.isFinite(record[field])) {
|
||||||
|
invalid(context, path, "有限数字");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBoolean(record: PayloadRecord, field: string, context: string, path: string): void {
|
||||||
|
if (typeof record[field] !== "boolean") invalid(context, path, "布尔值");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireScalar(value: unknown, context: string, path: string): void {
|
||||||
|
if (!(["string", "number", "boolean"] as string[]).includes(typeof value)) {
|
||||||
|
invalid(context, path, "字符串、数字或布尔值");
|
||||||
|
}
|
||||||
|
if (typeof value === "number" && !Number.isFinite(value)) invalid(context, path, "有限数字");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateBillingAccount(value: unknown, context: string, path: string): void {
|
||||||
|
const account = requireRecord(value, path, context);
|
||||||
|
for (const field of ["accountName", "bankName", "accountNumber", "contact"]) {
|
||||||
|
requireOptionalString(account, field, context, `${path}.${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateWallet(value: unknown, context: string, path: string): void {
|
||||||
|
const wallet = requireRecord(value, path, context);
|
||||||
|
requireString(wallet, "organizationId", context, `${path}.organizationId`);
|
||||||
|
for (const field of ["balanceFen", "totalRechargedFen", "totalChargedFen"]) {
|
||||||
|
requireNumber(wallet, field, context, `${path}.${field}`);
|
||||||
|
}
|
||||||
|
requireString(wallet, "updatedAt", context, `${path}.updatedAt`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSummary(value: unknown, context: string, path: string): void {
|
||||||
|
const summary = requireRecord(value, path, context);
|
||||||
|
for (const field of ["rechargeFen", "chargedFen", "refundedFen", "netConsumedFen"]) {
|
||||||
|
requireNumber(summary, field, context, `${path}.${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLedgerEntry(value: unknown, context: string, path: string): void {
|
||||||
|
const entry = requireRecord(value, path, context);
|
||||||
|
requireString(entry, "id", context, `${path}.id`);
|
||||||
|
requireString(entry, "organizationId", context, `${path}.organizationId`);
|
||||||
|
requireOptionalString(entry, "accountId", context, `${path}.accountId`);
|
||||||
|
requireOptionalString(entry, "jobId", context, `${path}.jobId`);
|
||||||
|
requireString(entry, "kind", context, `${path}.kind`);
|
||||||
|
requireNumber(entry, "deltaFen", context, `${path}.deltaFen`);
|
||||||
|
requireNumber(entry, "balanceAfterFen", context, `${path}.balanceAfterFen`);
|
||||||
|
requireString(entry, "description", context, `${path}.description`);
|
||||||
|
requireRecord(entry.metadata, `${path}.metadata`, context);
|
||||||
|
requireString(entry, "createdAt", context, `${path}.createdAt`);
|
||||||
|
if (Number.isNaN(Date.parse(entry.createdAt as string))) invalid(context, `${path}.createdAt`, "有效时间字符串");
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateOrganization(value: unknown, context: string, path: string): void {
|
||||||
|
const organization = requireRecord(value, path, context);
|
||||||
|
requireString(organization, "id", context, `${path}.id`);
|
||||||
|
requireString(organization, "name", context, `${path}.name`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdminOrganization(value: unknown, context: string, path: string): void {
|
||||||
|
const organization = requireRecord(value, path, context);
|
||||||
|
requireString(organization, "id", context, `${path}.id`);
|
||||||
|
requireString(organization, "name", context, `${path}.name`);
|
||||||
|
requireString(organization, "status", context, `${path}.status`);
|
||||||
|
validateWallet(organization.wallet, context, `${path}.wallet`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAdminMember(value: unknown, context: string, path: string): void {
|
||||||
|
const member = requireRecord(value, path, context);
|
||||||
|
for (const field of ["id", "displayName", "phone", "role", "status"]) {
|
||||||
|
requireString(member, field, context, `${path}.${field}`);
|
||||||
|
}
|
||||||
|
requireOptionalString(member, "organizationId", context, `${path}.organizationId`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePriceRule(value: unknown, context: string, path: string): void {
|
||||||
|
const rule = requireRecord(value, path, context);
|
||||||
|
for (const field of ["id", "provider", "capability", "unit"]) {
|
||||||
|
requireString(rule, field, context, `${path}.${field}`);
|
||||||
|
}
|
||||||
|
requireOptionalString(rule, "reqKey", context, `${path}.reqKey`);
|
||||||
|
requireOptionalString(rule, "variantKey", context, `${path}.variantKey`);
|
||||||
|
requireOptionalString(rule, "note", context, `${path}.note`);
|
||||||
|
requireNumber(rule, "standardUnitPriceFen", context, `${path}.standardUnitPriceFen`);
|
||||||
|
requireNumber(rule, "markupMultiplier", context, `${path}.markupMultiplier`);
|
||||||
|
requireBoolean(rule, "enabled", context, `${path}.enabled`);
|
||||||
|
|
||||||
|
if (rule.conditions !== undefined && rule.conditions !== null) {
|
||||||
|
requireRecord(rule.conditions, `${path}.conditions`, context);
|
||||||
|
}
|
||||||
|
if (rule.source !== undefined && rule.source !== null) {
|
||||||
|
const source = requireRecord(rule.source, `${path}.source`, context);
|
||||||
|
requireOptionalString(source, "url", context, `${path}.source.url`);
|
||||||
|
}
|
||||||
|
if (rule.parameterDimensions !== undefined && rule.parameterDimensions !== null) {
|
||||||
|
if (!Array.isArray(rule.parameterDimensions)) invalid(context, `${path}.parameterDimensions`, "数组");
|
||||||
|
rule.parameterDimensions.forEach((dimension, dimensionIndex) => {
|
||||||
|
const dimensionPath = `${path}.parameterDimensions[${dimensionIndex}]`;
|
||||||
|
const item = requireRecord(dimension, dimensionPath, context);
|
||||||
|
requireString(item, "key", context, `${dimensionPath}.key`);
|
||||||
|
requireOptionalString(item, "label", context, `${dimensionPath}.label`);
|
||||||
|
requireScalar(item.baselineValue, context, `${dimensionPath}.baselineValue`);
|
||||||
|
if (item.defaultValue !== undefined && item.defaultValue !== null) {
|
||||||
|
requireScalar(item.defaultValue, context, `${dimensionPath}.defaultValue`);
|
||||||
|
}
|
||||||
|
requireArray(item, "tiers", context, `${dimensionPath}.tiers`).forEach((tier, tierIndex) => {
|
||||||
|
const tierPath = `${dimensionPath}.tiers[${tierIndex}]`;
|
||||||
|
const tierItem = requireRecord(tier, tierPath, context);
|
||||||
|
requireScalar(tierItem.value, context, `${tierPath}.value`);
|
||||||
|
requireOptionalString(tierItem, "label", context, `${tierPath}.label`);
|
||||||
|
requireNumber(tierItem, "standardFactor", context, `${tierPath}.standardFactor`);
|
||||||
|
requireNumber(tierItem, "markupMultiplier", context, `${tierPath}.markupMultiplier`);
|
||||||
|
requireBoolean(tierItem, "enabled", context, `${tierPath}.enabled`);
|
||||||
|
requireOptionalString(tierItem, "note", context, `${tierPath}.note`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAdminBillingPayload<T>(payload: T): T {
|
||||||
|
const context = "超管计费";
|
||||||
|
const record = requireRecord(payload, "响应", context);
|
||||||
|
validateBillingAccount(record.billingAccount, context, "billingAccount");
|
||||||
|
requireArray(record, "organizations", context).forEach((item, index) => validateAdminOrganization(item, context, `organizations[${index}]`));
|
||||||
|
requireArray(record, "members", context).forEach((item, index) => validateAdminMember(item, context, `members[${index}]`));
|
||||||
|
requireArray(record, "ledger", context).forEach((item, index) => validateLedgerEntry(item, context, `ledger[${index}]`));
|
||||||
|
requireArray(record, "priceRules", context).forEach((item, index) => validatePriceRule(item, context, `priceRules[${index}]`));
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMemberBillingPayload<T>(payload: T): T {
|
||||||
|
const context = "成员计费";
|
||||||
|
const record = requireRecord(payload, "响应", context);
|
||||||
|
validateOrganization(record.organization, context, "organization");
|
||||||
|
validateBillingAccount(record.billingAccount, context, "billingAccount");
|
||||||
|
validateWallet(record.wallet, context, "wallet");
|
||||||
|
requireArray(record, "ledger", context).forEach((item, index) => validateLedgerEntry(item, context, `ledger[${index}]`));
|
||||||
|
validateSummary(record.summary, context, "summary");
|
||||||
|
validateSummary(record.personal, context, "personal");
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
147
tests/billing-api-contract.test.ts
Normal file
147
tests/billing-api-contract.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
|
||||||
|
|
||||||
|
const wallet = {
|
||||||
|
organizationId: "org-1",
|
||||||
|
balanceFen: 0,
|
||||||
|
totalRechargedFen: 0,
|
||||||
|
totalChargedFen: 0,
|
||||||
|
updatedAt: "2026-08-17T00:00:00.000Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
const ledgerEntry = {
|
||||||
|
id: "ledger-1",
|
||||||
|
organizationId: "org-1",
|
||||||
|
kind: "recharge",
|
||||||
|
deltaFen: 100,
|
||||||
|
balanceAfterFen: 100,
|
||||||
|
description: "充值",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: "2026-08-17T00:00:00.000Z"
|
||||||
|
};
|
||||||
|
|
||||||
|
function validAdminPayload() {
|
||||||
|
return {
|
||||||
|
billingAccount: {},
|
||||||
|
organizations: [],
|
||||||
|
members: [],
|
||||||
|
ledger: [],
|
||||||
|
priceRules: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function validMemberPayload() {
|
||||||
|
return {
|
||||||
|
organization: { id: "org-1", name: "测试组织" },
|
||||||
|
billingAccount: {},
|
||||||
|
wallet,
|
||||||
|
ledger: [],
|
||||||
|
summary: { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 },
|
||||||
|
personal: { rechargeFen: 0, chargedFen: 0, refundedFen: 0, netConsumedFen: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("billing API payload contract", () => {
|
||||||
|
it("accepts valid empty collection payloads", () => {
|
||||||
|
const admin = validAdminPayload();
|
||||||
|
const member = validMemberPayload();
|
||||||
|
|
||||||
|
expect(parseAdminBillingPayload(admin)).toBe(admin);
|
||||||
|
expect(parseMemberBillingPayload(member)).toBe(member);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["organizations", "members", "ledger", "priceRules"])(
|
||||||
|
"rejects a missing admin %s collection",
|
||||||
|
(field) => {
|
||||||
|
const payload: Record<string, unknown> = validAdminPayload();
|
||||||
|
delete payload[field];
|
||||||
|
|
||||||
|
expect(() => parseAdminBillingPayload(payload))
|
||||||
|
.toThrow(`超管计费数据格式错误:${field} 必须是数组。`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects a malformed admin price-rule element", () => {
|
||||||
|
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), priceRules: [null] }))
|
||||||
|
.toThrow("超管计费数据格式错误:priceRules[0] 必须是对象。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed admin organization elements and wallets", () => {
|
||||||
|
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), organizations: [null] }))
|
||||||
|
.toThrow("超管计费数据格式错误:organizations[0] 必须是对象。");
|
||||||
|
expect(() => parseAdminBillingPayload({
|
||||||
|
...validAdminPayload(),
|
||||||
|
organizations: [{ id: "org-1", name: "测试组织", status: "active", wallet: { ...wallet, balanceFen: "0" } }]
|
||||||
|
})).toThrow("超管计费数据格式错误:organizations[0].wallet.balanceFen 必须是有限数字。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed admin member element", () => {
|
||||||
|
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), members: [null] }))
|
||||||
|
.toThrow("超管计费数据格式错误:members[0] 必须是对象。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a fresh super administrator without organizationId", () => {
|
||||||
|
const payload = {
|
||||||
|
...validAdminPayload(),
|
||||||
|
members: [{ id: "root", displayName: "管理员", phone: "13800000000", role: "super_admin", status: "active" }]
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(parseAdminBillingPayload(payload)).toBe(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed admin ledger element", () => {
|
||||||
|
expect(() => parseAdminBillingPayload({ ...validAdminPayload(), ledger: [null] }))
|
||||||
|
.toThrow("超管计费数据格式错误:ledger[0] 必须是对象。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed member objects used by the renderer", () => {
|
||||||
|
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), organization: null }))
|
||||||
|
.toThrow("成员计费数据格式错误:organization 必须是对象。");
|
||||||
|
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), wallet: null }))
|
||||||
|
.toThrow("成员计费数据格式错误:wallet 必须是对象。");
|
||||||
|
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), summary: null }))
|
||||||
|
.toThrow("成员计费数据格式错误:summary 必须是对象。");
|
||||||
|
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), personal: null }))
|
||||||
|
.toThrow("成员计费数据格式错误:personal 必须是对象。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a malformed member ledger element", () => {
|
||||||
|
expect(() => parseMemberBillingPayload({ ...validMemberPayload(), ledger: [null] }))
|
||||||
|
.toThrow("成员计费数据格式错误:ledger[0] 必须是对象。");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts fully populated collection elements", () => {
|
||||||
|
const admin = {
|
||||||
|
...validAdminPayload(),
|
||||||
|
organizations: [{ id: "org-1", name: "测试组织", status: "active", wallet }],
|
||||||
|
members: [{ id: "member-1", displayName: "成员", phone: "13800000000", role: "user", organizationId: "org-1", status: "active" }],
|
||||||
|
ledger: [ledgerEntry],
|
||||||
|
priceRules: [{
|
||||||
|
id: "rule-1",
|
||||||
|
provider: "evolink",
|
||||||
|
capability: "image.generate",
|
||||||
|
unit: "image",
|
||||||
|
standardUnitPriceFen: 100,
|
||||||
|
markupMultiplier: 1.2,
|
||||||
|
enabled: true,
|
||||||
|
parameterDimensions: [{
|
||||||
|
key: "quality",
|
||||||
|
label: "质量",
|
||||||
|
baselineValue: "standard",
|
||||||
|
tiers: [{ value: "standard", label: "标准", standardFactor: 1, markupMultiplier: 1.2, enabled: true }]
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
const member = { ...validMemberPayload(), ledger: [ledgerEntry] };
|
||||||
|
|
||||||
|
expect(parseAdminBillingPayload(admin)).toBe(admin);
|
||||||
|
expect(parseMemberBillingPayload(member)).toBe(member);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-object responses", () => {
|
||||||
|
expect(() => parseAdminBillingPayload("unexpected"))
|
||||||
|
.toThrow("超管计费数据格式错误:响应必须是对象。");
|
||||||
|
expect(() => parseMemberBillingPayload(null))
|
||||||
|
.toThrow("成员计费数据格式错误:响应必须是对象。");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user