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 状态和服务端日志。

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("成员计费数据格式错误:响应必须是对象。"); + }); +});