Compare commits

...
2 Commits
Author SHA1 Message Date
brother7 2ef3da7af5 fix: preserve billing response shape 2026-08-17 11:28:08 +08:00
brother7 600cba023c docs: record billing page diagnosis 2026-08-17 10:42:31 +08:00
8 changed files with 578 additions and 6 deletions

No files matched your search

@@ -0,0 +1,90 @@
# Task: Diagnose billing page unavailable
## Identity
- Task ID: 20260817-diagnose-billing-page-7c3a91e2
- Mode: Feature
- Branch: main
- Worktree: D:\Datas\OthersProjects\NianAIGC
- Base commit: acd929c704ec22b3ac909ecc3cd609b826376f5a
- Owner: codex
- Status: Ready for Integration
## Scope
- Diagnose why the production `/billing` page for a signed-in super
administrator renders the route error boundary instead of billing content.
- Trace the static frontend request and render path through Go billing HTTP and
PostgreSQL collection results.
- Keep this task diagnostic-only; do not apply an application or deployment
fix without a separate user request.
## Intent And Constraints
- Treat the supplied screenshot as confirmed visual evidence, but do not infer
the underlying exception from its generic fallback copy.
- Preserve the accepted static Web + Go-only API boundary.
- Do not inspect or expose authentication cookies, credentials, or Secret
values.
- Separate confirmed source/test evidence from live-cluster assumptions.
## Outcome
- Confirmed the screenshot is the `/billing` route error boundary, which means
an uncaught client render exception occurred; it is not evidence that
`npm run db:migrate` is the cause.
- Traced the super-admin path from `GET /api/auth/me` to
`GET /api/admin/billing`. Normal HTTP/fetch failures are caught inside
`BillingManager` and render an inline alert, so they do not match the
screenshot.
- Reproduced the browser contract failure at the Go HTTP seam: a zero-value
admin overview serializes `organizations`, `members`, `ledger`, and
`priceRules` as JSON `null`, while the frontend unconditionally calls array
methods including `.find`, `.reduce`, `.filter`, `.slice`, and `.map`.
- Confirmed the PostgreSQL adapter returns nil slices for empty result sets.
This is expected on a fresh database because bootstrap creates only the
super administrator; it does not create an organization, ledger entries, or
price rules, and the default price catalog is seeded only from quote flow.
- Confirmed the live public readiness route returns HTTP 200 with PostgreSQL
configured, `/billing` returns the static HTML, and unauthenticated
`/api/admin/billing` reaches Go and returns JSON HTTP 401. The authenticated
live billing response body was not captured, so the exact production
exception remains an evidence-backed inference rather than a directly read
container/browser trace.
- Removed the temporary diagnostic regression file after reproducing the
failure; no application or deployment code was changed.
## Verification
- Temporary red-capable harness:
`go test ./internal/httpapi -run
TestDiagnosisBillingAdminOverviewUsesJSONArraysForEmptyCollections -count=1
-v` — FAIL as expected; all four collection fields were `null` instead of
`[]`.
- Repeated the same harness with `-count=3`; the same four failures reproduced
on all three runs in under one second.
- `curl.exe -sS -i --max-time 15
https://nianxxaigc.nianxx.cn/api/ready` — HTTP 200,
PostgreSQL configured.
- Unauthenticated `GET /api/admin/billing` — HTTP 401 JSON `请先登录。`,
confirming the route reaches Go rather than Nginx static fallback.
- `HEAD /billing` — HTTP 200 static HTML.
- Independent frontend and Go read-only traces agreed on the nil-slice/null
contract violation and on the route-error-versus-inline-error distinction.
- Final read-only `sol_reviewer`: PASS for Standards and Spec; no blocker.
## Follow-ups
- In a separate authorized fix task, normalize all Go billing response
collections to non-nil empty slices and keep a permanent HTTP regression test
asserting JSON arrays for fresh/empty state.
- Add frontend response-shape validation/defaulting so malformed backend data
becomes an inline actionable error instead of crashing the route subtree.
- Replace the stale `npm run db:migrate` route-error copy; the current
production architecture uses manual SQL plus Go APIs and the message obscures
client contract failures.
## Promotion Candidates
- None. This is an implementation defect within the already accepted static
Web + Go API contract, not a new architecture or product decision.
@@ -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.
+1 -1
View File
@@ -12,7 +12,7 @@ export default function BillingError({ reset }: { error: Error & { digest?: stri
<div className="billing-route-error-card">
<span className="billing-route-error-kicker">计费中心</span>
<h1>计费服务暂时不可用</h1>
<p>请先刷新页面。如果问题持续,请检查服务端日志,并确认已通过 <code>npm run db:migrate</code> 完成数据库迁移。</p>
<p>请先刷新页面。如果问题持续,请检查 Go API 状态和服务端日志。</p>
<button type="button" onClick={() => reset()}>重新加载</button>
</div>
</main>
+15
View File
@@ -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
+58
View File
@@ -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())
}
}
}
+8 -5
View File
@@ -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<AdminPayload>(adminResponse);
if (!adminResponse.ok) throw new Error(adminPayload.error || "读取超管计费数据失败");
const rawAdminPayload = await readApiPayload<AdminPayload>(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<BillingPayload>(billingResponse);
if (!billingResponse.ok) throw new Error(billingPayload.error || "读取计费数据失败");
const rawBillingPayload = await readApiPayload<BillingPayload>(billingResponse);
if (!billingResponse.ok) throw new Error(rawBillingPayload.error || "读取计费数据失败");
const billingPayload = parseMemberBillingPayload(rawBillingPayload);
setBilling(billingPayload);
}
} catch (nextError) {
@@ -324,7 +327,7 @@ async function readApiPayload<T extends Record<string, unknown>>(response: Respo
} catch {
return {
error: response.status >= 500
? "计费服务返回了服务器错误,请检查服务端日志,并确认已执行 npm run db:migrate。"
? "计费服务返回了服务器错误,请检查 Go API 状态和服务端日志。"
: `服务器返回了无效响应(HTTP ${response.status})。`
} as T & { error?: string };
}
+172
View 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
View 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("成员计费数据格式错误:响应必须是对象。");
});
});