From 2ef3da7af518b9ff3f1e904293b38982803a5b0c Mon Sep 17 00:00:00 2001
From: brother7 <7brother7@gmail.com>
Date: Mon, 17 Aug 2026 11:28:08 +0800
Subject: [PATCH] fix: preserve billing response shape
---
...60817-fix-billing-empty-arrays-4f9d2a7c.md | 87 +++++++++
app/billing/error.tsx | 2 +-
backend/internal/billing/service.go | 15 ++
backend/internal/httpapi/billing_test.go | 58 ++++++
components/billing-manager.tsx | 13 +-
lib/client/billing-api.ts | 172 ++++++++++++++++++
tests/billing-api-contract.test.ts | 147 +++++++++++++++
7 files changed, 488 insertions(+), 6 deletions(-)
create mode 100644 .project-docs/30-worklog/tasks/20260817-fix-billing-empty-arrays-4f9d2a7c.md
create mode 100644 lib/client/billing-api.ts
create mode 100644 tests/billing-api-contract.test.ts
diff --git a/.project-docs/30-worklog/tasks/20260817-fix-billing-empty-arrays-4f9d2a7c.md b/.project-docs/30-worklog/tasks/20260817-fix-billing-empty-arrays-4f9d2a7c.md
new file mode 100644
index 0000000..e7794ae
--- /dev/null
+++ b/.project-docs/30-worklog/tasks/20260817-fix-billing-empty-arrays-4f9d2a7c.md
@@ -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.
diff --git a/app/billing/error.tsx b/app/billing/error.tsx
index 8e0be3a..1d5f84f 100644
--- a/app/billing/error.tsx
+++ b/app/billing/error.tsx
@@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
计费中心
计费服务暂时不可用
-
请先刷新页面。如果问题持续,请检查服务端日志,并确认已通过 npm run db:migrate 完成数据库迁移。
+
请先刷新页面。如果问题持续,请检查 Go API 状态和服务端日志。
reset()}>重新加载
diff --git a/backend/internal/billing/service.go b/backend/internal/billing/service.go
index 3c40aee..c8501ee 100644
--- a/backend/internal/billing/service.go
+++ b/backend/internal/billing/service.go
@@ -53,6 +53,9 @@ func (s *Service) Overview(ctx context.Context, organizationID, accountID string
if err != nil {
return Overview{}, err
}
+ if ledger == nil {
+ ledger = []LedgerEntry{}
+ }
personal, err := s.store.BillingLedger(ctx, organizationID, accountID, 500)
if err != nil {
return Overview{}, err
@@ -118,6 +121,18 @@ func (s *Service) AdminOverview(ctx context.Context) (AdminOverview, error) {
if err != nil {
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{}
for _, wallet := range wallets {
byOrganization[wallet.OrganizationID] = wallet
diff --git a/backend/internal/httpapi/billing_test.go b/backend/internal/httpapi/billing_test.go
index 97c799f..89379a5 100644
--- a/backend/internal/httpapi/billing_test.go
+++ b/backend/internal/httpapi/billing_test.go
@@ -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) {
service := &billingHTTPServiceStub{}
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
}
+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 {
t.Helper()
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)
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())
+ }
+ }
+}
diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx
index a8dfd53..fcc6ec5 100644
--- a/components/billing-manager.tsx
+++ b/components/billing-manager.tsx
@@ -20,6 +20,7 @@ import {
X
} from "lucide-react";
import { billingUnitLabel, formatBillingAmount } from "@/lib/billing";
+import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api";
import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion";
import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types";
@@ -161,8 +162,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
try {
if (isSuperAdmin) {
const adminResponse = await fetch("/api/admin/billing", { cache: "no-store" });
- const adminPayload = await readApiPayload(adminResponse);
- if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败");
+ const rawAdminPayload = await readApiPayload(adminResponse);
+ if (!adminResponse.ok) throw new Error(rawAdminPayload.error || "读取超管计费数据失败");
+ const adminPayload = parseAdminBillingPayload(rawAdminPayload);
setAdmin(adminPayload);
setBilling(buildAdminBillingPayload(adminPayload));
setAccountDraft({ ...emptyAccountDraft, ...adminPayload.billingAccount });
@@ -172,8 +174,9 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) {
}));
} else {
const billingResponse = await fetch("/api/billing", { cache: "no-store" });
- const billingPayload = await readApiPayload(billingResponse);
- if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败");
+ const rawBillingPayload = await readApiPayload(billingResponse);
+ if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
+ const billingPayload = parseMemberBillingPayload(rawBillingPayload);
setBilling(billingPayload);
}
} catch (nextError) {
@@ -324,7 +327,7 @@ async function readApiPayload>(response: Respo
} catch {
return {
error: response.status >= 500
- ? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。"
+ ? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
: `服务器返回了无效响应(HTTP ${response.status})。`
} as T & { error?: string };
}
diff --git a/lib/client/billing-api.ts b/lib/client/billing-api.ts
new file mode 100644
index 0000000..5ebaa00
--- /dev/null
+++ b/lib/client/billing-api.ts
@@ -0,0 +1,172 @@
+type PayloadRecord = Record;
+
+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(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(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;
+}
diff --git a/tests/billing-api-contract.test.ts b/tests/billing-api-contract.test.ts
new file mode 100644
index 0000000..288923f
--- /dev/null
+++ b/tests/billing-api-contract.test.ts
@@ -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 = 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("成员计费数据格式错误:响应必须是对象。");
+ });
+});