#!/usr/bin/env node // Test environment administrator / 测试环境管理员 // Username: platform_admin // Password: ChangeMe123! import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'phase3-warehouse-rbac', 'phase3-order-splitting'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'contactName', 'contactPhone', 'contactEmail', 'contactWechat', 'servicePackage', 'salesOwnerName', 'paymentTermDays', 'enterpriseType', 'establishedDate', 'registeredCapital', 'registeredCapitalCurrency', 'registeredAddress', 'internalRemark', 'createdAt', ] const BOX_DEFAULTS = [ { boxType: 'SMALL', dimensions: ['30.000', '20.000', '20.000'], remark: '标准小箱' }, { boxType: 'MEDIUM', dimensions: ['40.000', '30.000', '30.000'], remark: '标准中箱' }, { boxType: 'LARGE', dimensions: ['50.000', '40.000', '40.000'], remark: '标准大箱' }, { boxType: 'EXTRA_LARGE', dimensions: ['60.000', '50.000', '50.000'], remark: '特大箱' }, ] function usage() { console.log(`后台管理测试环境 HTTP API 验收 测试管理员: ${DEFAULT_USERNAME} / ${DEFAULT_PASSWORD} 用法: node scripts/admin-test-env-acceptance.mjs [options] 选项: --gateway Gateway 地址,默认 ${DEFAULT_GATEWAY} --username 管理员账号,默认 ${DEFAULT_USERNAME} --password 管理员密码,默认使用上方测试密码 --modules 逗号分隔,默认 ${DEFAULT_MODULES.join(',')} --output JSON 报告路径,默认 reports/ --help 显示帮助 环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) } function parseArguments(argv) { if (argv.includes('--help')) { usage(); process.exit(0) } const values = new Map() for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`) const separator = argument.indexOf('=') const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2) const inline = separator >= 0 ? argument.slice(separator + 1) : undefined if (inline !== undefined) values.set(name, inline) else { const next = argv[index + 1] if (!next || next.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) values.set(name, next); index += 1 } } return { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, modules: (values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean), output: values.get('output') || '', } } function own(value, field) { return value !== null && typeof value === 'object' && Object.hasOwn(value, field) } function hasFields(value, fields) { return fields.every((field) => own(value, field)) } function numberEquals(actual, expected) { return Math.abs(Number(actual) - Number(expected)) < 0.0005 } function halfUp3(value) { return Math.round((Number(value) + Number.EPSILON) * 1000) / 1000 } function derived(length, width, height, edge) { const outerLength = Number(length) + Number(edge) * 2 const outerWidth = Number(width) + Number(edge) * 2 const outerHeight = Number(height) + Number(edge) * 2 return { outerLength, outerWidth, outerHeight, volume: halfUp3(outerLength * outerWidth * outerHeight / 1_000_000) } } class AcceptanceRun { constructor(options) { this.options = options this.marker = `ADMIN-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}` this.startedAt = new Date().toISOString() this.steps = [] this.modules = [] this.scenarios = [] this.createdResources = {} this.deploymentBlockers = [] this.coverageGaps = [] this.context = {} this.token = '' this.fatal = null } async request(method, path, body = undefined, token = this.token) { const headers = { Accept: 'application/json' } if (token) headers.Authorization = `Bearer ${token}` if (body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' try { const response = await fetch(`${this.options.gateway}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is recorded below. */ } return { method, path, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method, path, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null } } } async requestBinary(method, path) { const headers = { Accept: '*/*', Authorization: `Bearer ${this.token}` } try { const response = await fetch(`${this.options.gateway}${path}`, { method, headers, signal: AbortSignal.timeout(120_000) }) const bytes = new Uint8Array(await response.arrayBuffer()) const contentType = response.headers.get('content-type') || '' let payload = null if (contentType.toLowerCase().includes('json')) { try { payload = JSON.parse(new TextDecoder().decode(bytes)) } catch { /* Invalid envelope remains binary evidence. */ } } return { method, path, http: response.status, body: payload, bytes, contentType, raw: payload ? null : `${bytes.length} bytes (${contentType || 'unknown'})`, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method, path, http: 0, body: null, bytes: new Uint8Array(), contentType: '', raw: String(error), requestId: null, traceId: null } } } addStep(module, name, response, passed, evidence = '', options = {}) { const step = { module, scenario: options.scenario || null, name, passed: Boolean(passed), deploymentBlocker: Boolean(options.deploymentBlocker && !passed), request: response ? { method: response.method, path: response.path } : null, http: response?.http ?? null, code: response?.body?.code === undefined ? null : String(response.body.code), message: response?.body?.message || response?.raw || null, requestId: response?.requestId || null, traceId: response?.traceId || null, evidence, checkedAt: new Date().toISOString(), } this.steps.push(step) if (step.deploymentBlocker) this.deploymentBlockers.push({ module, scenario: step.scenario, name, evidence, code: step.code, requestId: step.requestId, traceId: step.traceId }) console.log(`[${step.passed ? 'PASS' : 'FAIL'}] [${module}${step.scenario ? `/${step.scenario}` : ''}] ${name}${evidence ? ` - ${evidence}` : ''}`) return step.passed } addCoverageGap(module, scenario, name, reason, category = 'not-implemented', metadata = {}) { this.coverageGaps.push({ module, scenario, name, reason, category, ...metadata }) } apiSuccess(module, name, response, evidence = '', options = {}) { const passed = response.http === 200 && String(response.body?.code) === '200' this.addStep(module, name, response, passed, evidence, options) return passed ? response.body.data : null } assert(module, name, response, condition, evidence, options = {}) { return this.addStep(module, name, response, Boolean(condition), evidence, options) } expectConflict(module, name, response, scenario) { const passed = response.http === 200 && ['A0409', '409'].includes(String(response.body?.code)) this.addStep(module, name, response, passed, `HTTP=${response.http}, code=${response.body?.code ?? 'missing'}`, { scenario, deploymentBlocker: true }) return passed } async executeModule(name, handler) { const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null try { await handler() } catch (error) { fatal = error instanceof Error ? error.message : String(error) this.addStep(name, '模块执行', null, false, fatal) } const steps = this.steps.slice(start) this.modules.push({ name, startedAt, finishedAt: new Date().toISOString(), success: fatal === null && steps.every((s) => s.passed), passed: steps.filter((s) => s.passed).length, failed: steps.filter((s) => !s.passed).length, fatal }) } async findAvailablePhone(module = 'customers') { const seed = BigInt(Date.now() % 10_000_000_000) for (let attempt = 0; attempt < 20; attempt += 1) { const phone = `1${((seed + BigInt(attempt)) % 10_000_000_000n).toString().padStart(10, '0')}` const response = await this.request('GET', `/api/platform/v1/iam/tenants/contact-phone-availability?contactPhone=${phone}`) if (this.apiSuccess(module, '检查测试联系电话可用性', response, `attempt=${attempt + 1}`) === true) return phone } throw new Error('连续 20 个测试联系电话均不可用') } async runCustomerDimensionRules(tenantId) { const module = 'customers'; const scenario = 'customer-dimension-rules'; const start = this.steps.length const options = { scenario, deploymentBlocker: true } const path = `/api/platform/v1/first-leg/customer-dimension-config?tenantId=${tenantId}` const initialResponse = await this.request('GET', path) const group = this.apiSuccess(module, '新客户尺寸规则可查询', initialResponse, `tenantId=${tenantId}`, options) const rules = Array.isArray(group?.rules) ? group.rules : [] const orderedTypes = rules.map((rule) => rule.boxType) this.assert(module, '固定四箱规顺序完整且无重复', initialResponse, rules.length === 4 && orderedTypes.join(',') === BOX_DEFAULTS.map((item) => item.boxType).join(',') && new Set(orderedTypes).size === 4, `count=${rules.length}, order=${orderedTypes.join(',')}`, options) const defaultsCorrect = BOX_DEFAULTS.every((expected, index) => { const rule = rules[index] return rule?.boxType === expected.boxType && numberEquals(rule.innerLengthCm, expected.dimensions[0]) && numberEquals(rule.innerWidthCm, expected.dimensions[1]) && numberEquals(rule.innerHeightCm, expected.dimensions[2]) && numberEquals(rule.edgeAdditionCm, '2.000') && rule.remark === expected.remark }) this.assert(module, '四箱规默认内径、加边和备注正确', initialResponse, defaultsCorrect, defaultsCorrect ? 'defaults=matched' : '默认尺寸、edge 或备注不匹配', options) const identityCorrect = typeof group?.tenantId === 'string' && group.tenantId === tenantId && rules.length === 4 && rules.every((rule) => typeof rule.id === 'string' && Number(rule.version) === 0) this.assert(module, '客户 ID、规则 ID 为字符串且初始版本为0', initialResponse, identityCorrect, `groupTenantIdType=${typeof group?.tenantId}, rows=${rules.map((r) => `${typeof r.id}/v${r.version}`).join(',')}`, options) const initialDerivedCorrect = rules.length === 4 && rules.every((rule) => { const value = derived(rule.innerLengthCm, rule.innerWidthCm, rule.innerHeightCm, rule.edgeAdditionCm) return numberEquals(rule.outerLengthCm, value.outerLength) && numberEquals(rule.outerWidthCm, value.outerWidth) && numberEquals(rule.outerHeightCm, value.outerHeight) && numberEquals(rule.volumeCbm, value.volume) }) this.assert(module, '默认规则外径与 HALF_UP 三位 CBM 正确', initialResponse, initialDerivedCorrect, `derivedRows=${rules.length}`, options) if (rules.length === 4) { const updatePayload = { rules: rules.map((rule, index) => ({ boxType: rule.boxType, innerLengthCm: Number((Number(rule.innerLengthCm) + index + 0.125).toFixed(3)), innerWidthCm: Number((Number(rule.innerWidthCm) + index + 0.250).toFixed(3)), innerHeightCm: Number((Number(rule.innerHeightCm) + index + 0.375).toFixed(3)), edgeAdditionCm: Number((2.125 + index * 0.125).toFixed(3)), remark: `${this.marker}-${rule.boxType}`, version: Number(rule.version), })) } const allowed = ['boxType', 'innerLengthCm', 'innerWidthCm', 'innerHeightCm', 'edgeAdditionCm', 'remark', 'version'] this.assert(module, 'PUT 每行只发送约定7字段并整组保存', initialResponse, updatePayload.rules.length === 4 && updatePayload.rules.every((row) => Object.keys(row).join(',') === allowed.join(',')), `fields=${Object.keys(updatePayload.rules[0]).join(',')}`, options) const updateResponse = await this.request('PUT', path, updatePayload) const updatedGroup = this.apiSuccess(module, '一次 PUT 保存四条三位小数规则', updateResponse, 'rows=4', options) const updatedRules = Array.isArray(updatedGroup?.rules) ? updatedGroup.rules : [] const updatedCorrect = updatedRules.length === 4 && updatePayload.rules.every((expected, index) => { const actual = updatedRules[index]; const value = derived(expected.innerLengthCm, expected.innerWidthCm, expected.innerHeightCm, expected.edgeAdditionCm) return actual?.boxType === expected.boxType && Number(actual.version) === expected.version + 1 && numberEquals(actual.innerLengthCm, expected.innerLengthCm) && numberEquals(actual.innerWidthCm, expected.innerWidthCm) && numberEquals(actual.innerHeightCm, expected.innerHeightCm) && numberEquals(actual.edgeAdditionCm, expected.edgeAdditionCm) && numberEquals(actual.outerLengthCm, value.outerLength) && numberEquals(actual.outerWidthCm, value.outerWidth) && numberEquals(actual.outerHeightCm, value.outerHeight) && numberEquals(actual.volumeCbm, value.volume) && actual.remark === expected.remark }) this.assert(module, '修改后四行版本+1且派生外径/CBM正确', updateResponse, updatedCorrect, `versions=${updatedRules.map((r) => r.version).join(',')}`, options) const stalePayload = { rules: updatePayload.rules.map((rule) => ({ ...rule, innerLengthCm: Number((Number(rule.innerLengthCm) + 9).toFixed(3)), })) } const staleResponse = await this.request('PUT', path, stalePayload) this.expectConflict(module, '旧 version 重放 PUT 返回冲突', staleResponse, scenario) const afterConflictResponse = await this.request('GET', path) const afterConflict = this.apiSuccess(module, '冲突后重新查询四条规则', afterConflictResponse, '', options) const afterRules = Array.isArray(afterConflict?.rules) ? afterConflict.rules : [] const stableSnapshot = (rule) => JSON.stringify({ id: String(rule.id), boxType: rule.boxType, innerLengthCm: Number(rule.innerLengthCm), innerWidthCm: Number(rule.innerWidthCm), innerHeightCm: Number(rule.innerHeightCm), edgeAdditionCm: Number(rule.edgeAdditionCm), outerLengthCm: Number(rule.outerLengthCm), outerWidthCm: Number(rule.outerWidthCm), outerHeightCm: Number(rule.outerHeightCm), volumeCbm: Number(rule.volumeCbm), remark: rule.remark ?? null, version: Number(rule.version), updatedAt: rule.updatedAt ?? null, }) const atomic = afterRules.length === 4 && updatedRules.length === 4 && afterRules.every((rule, index) => stableSnapshot(rule) === stableSnapshot(updatedRules[index])) this.assert(module, '冲突请求未造成四行部分更新', afterConflictResponse, atomic, `unchangedRows=${atomic ? 4 : 0}`, options) const currentByType = new Map(afterRules.map((rule) => [rule.boxType, rule])) const restorePayload = { rules: rules.map((original) => ({ boxType: original.boxType, innerLengthCm: Number(original.innerLengthCm), innerWidthCm: Number(original.innerWidthCm), innerHeightCm: Number(original.innerHeightCm), edgeAdditionCm: Number(original.edgeAdditionCm), remark: original.remark ?? null, version: Number(currentByType.get(original.boxType)?.version), })) } const restorable = restorePayload.rules.length === 4 && restorePayload.rules.every((rule) => Number.isInteger(rule.version) && rule.version >= 0) if (restorable) { const restoreResponse = await this.request('PUT', path, restorePayload) const restoredGroup = this.apiSuccess(module, '恢复本轮修改前的四箱规配置', restoreResponse, 'restore=original-values', options) const restoredRules = Array.isArray(restoredGroup?.rules) ? restoredGroup.rules : [] const restored = restoredRules.length === 4 && rules.every((original, index) => { const actual = restoredRules[index] return actual?.boxType === original.boxType && numberEquals(actual.innerLengthCm, original.innerLengthCm) && numberEquals(actual.innerWidthCm, original.innerWidthCm) && numberEquals(actual.innerHeightCm, original.innerHeightCm) && numberEquals(actual.edgeAdditionCm, original.edgeAdditionCm) && actual.remark === original.remark && Number(actual.version) === restorePayload.rules[index].version + 1 }) this.assert(module, '尺寸规则恢复后回显与初始值一致', restoreResponse, restored, `restoredRows=${restored ? 4 : 0}`, options) } else { this.assert(module, '恢复本轮修改前的四箱规配置', afterConflictResponse, false, '冲突后未返回完整四行及有效版本,无法安全恢复', options) } } else { this.assert(module, '尺寸规则编辑与并发冲突场景', initialResponse, false, '默认规则不是完整四条,禁止降级跳过', options) } const steps = this.steps.slice(start) this.scenarios.push({ name: scenario, module, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length }) } async runCustomers() { const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12) const payload = { tenantName: `后台客户验收-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, legalRepresentative: '接口验收法人', contactName: '接口验收联系人', contactPhone: phone, contactEmail: `admin-api-${suffix.toLowerCase()}@example.com`, servicePackage: 'API验收套餐', contactWechat: `api_${suffix.toLowerCase()}`, salesOwnerName: '接口验收销售', paymentTermDays: 30, enterpriseType: 'LIMITED_COMPANY', establishedDate: '2020-01-02', registeredCapital: 5000000, registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市南山区接口验收地址', internalRemark: this.marker, } const createResponse = await this.request('POST', '/api/platform/v1/iam/tenants', payload) const createData = this.apiSuccess(module, '创建客户', createResponse) if (!createData) throw new Error('创建客户失败,后续客户模块无法继续') const customer = createData.tenant || createData.customer || createData const tenantId = String(customer.id || createData.tenantId || '') if (!tenantId) throw new Error('创建响应未返回客户 ID') this.context.customerId = tenantId this.createdResources.customer = { tenantId, marker: this.marker, contactPhone: phone } this.assert(module, '创建响应客户字段完整', createResponse, hasFields(customer, CUSTOMER_FIELDS), `missing=${CUSTOMER_FIELDS.filter((f) => !own(customer, f)).join(',') || 'none'}`) this.assert(module, '新客户基础字段正确', createResponse, customer.tenantName === payload.tenantName && customer.contactPhone === phone && customer.status === 'PROFILE_REQUIRED', `tenantId=${tenantId}, status=${customer.status}`) const ownerProvisioningOptions = { scenario: 'customer-owner-provisioning', deploymentBlocker: true } this.assert(module, '创建客户生成初始密码', createResponse, typeof createData.temporaryPassword === 'string' && createData.temporaryPassword.length >= 8 && /[A-Za-z]/.test(createData.temporaryPassword) && /\d/.test(createData.temporaryPassword), `generated=${typeof createData.temporaryPassword === 'string'}, length=${createData.temporaryPassword?.length || 0}`, ownerProvisioningOptions) this.assert(module, '管理员用户名默认使用联系电话', createResponse, createData.administratorUsername === phone, `matches=${createData.administratorUsername === phone}`, ownerProvisioningOptions) this.assert(module, '待完善客户的审核字段保持为空', createResponse, customer.unifiedSocialCreditCode == null && customer.approvedAt == null, `creditCode=${customer.unifiedSocialCreditCode ?? 'omitted'}, approvedAt=${customer.approvedAt ?? 'omitted'}`) await this.runCustomerDimensionRules(tenantId) const listResponse = await this.request('GET', `/api/platform/v1/iam/tenants?page=1&size=20&keyword=${encodeURIComponent(payload.tenantName)}`) const page = this.apiSuccess(module, '查询客户列表', listResponse); const listed = page?.records?.find((item) => String(item.id) === tenantId) this.assert(module, '列表包含新客户且字段完整', listResponse, listed && hasFields(listed, CUSTOMER_FIELDS), `found=${Boolean(listed)}, missing=${listed ? CUSTOMER_FIELDS.filter((f) => !own(listed, f)).join(',') || 'none' : 'all'}`) const detailPath = `/api/platform/v1/iam/tenants/${tenantId}` const detailResponse = await this.request('GET', detailPath); const detail = this.apiSuccess(module, '查询客户详情', detailResponse) this.assert(module, '客户详情基本信息正确', detailResponse, detail && detail.tenantName === payload.tenantName && detail.contactPhone === phone && detail.internalRemark === payload.internalRemark, `tenantId=${tenantId}`) const editedPayload = { ...payload, tenantName: `${payload.tenantName}-已编辑`, contactName: '接口验收联系人-已编辑', contactEmail: `edited-${payload.contactEmail}`, paymentTermDays: 45, internalRemark: `${this.marker}-EDITED` } const editResponse = await this.request('PUT', detailPath, editedPayload); const edited = this.apiSuccess(module, '编辑客户', editResponse) this.assert(module, '客户编辑正确回显', editResponse, edited?.tenantName === editedPayload.tenantName && edited?.contactEmail === editedPayload.contactEmail && Number(edited?.paymentTermDays) === 45, `tenantId=${tenantId}`) const persistedResponse = await this.request('GET', detailPath); const persisted = this.apiSuccess(module, '回查客户编辑结果', persistedResponse) this.assert(module, '客户编辑已持久化', persistedResponse, persisted?.tenantName === editedPayload.tenantName && persisted?.internalRemark === editedPayload.internalRemark, `tenantId=${tenantId}`) const qualificationResponse = await this.request('GET', `${detailPath}/qualifications`); const qualifications = this.apiSuccess(module, '业务概况查询资质', qualificationResponse) this.assert(module, '资质概况结构正确', qualificationResponse, Array.isArray(qualifications), `count=${Array.isArray(qualifications) ? qualifications.length : 'n/a'}`) const memberResponse = await this.request('GET', `${detailPath}/members`); const members = this.apiSuccess(module, '业务概况查询成员', memberResponse) const owner = members?.find?.((item) => item.memberType === 'OWNER') this.assert(module, '联系电话账号是企业管理员', memberResponse, owner?.username === phone, `ownerFound=${Boolean(owner)}, matches=${owner?.username === phone}`, ownerProvisioningOptions) const countResponse = await this.request('GET', `/api/platform/v1/orders/first-leg/tenant-counts?tenantIds=${tenantId}`); const counts = this.apiSuccess(module, '业务概况查询运单数量', countResponse); const count = counts?.find?.((item) => String(item.tenantId) === tenantId) this.assert(module, '业务概况返回当前客户运单数', countResponse, count && Number.isFinite(Number(count.orderCount)), `orderCount=${count?.orderCount ?? 'missing'}`) const ordersResponse = await this.request('GET', `/api/platform/v1/orders/first-leg?tenantId=${tenantId}&page=1&size=10`); const orders = this.apiSuccess(module, '查询客户运单记录', ordersResponse) this.assert(module, '运单记录分页结构正确', ordersResponse, Array.isArray(orders?.records) && Number.isFinite(Number(orders?.total)), `records=${orders?.records?.length ?? 'n/a'}, total=${orders?.total ?? 'n/a'}`) const archiveOptions = { scenario: 'customer-draft-archive', deploymentBlocker: true } const archiveResponse = await this.request('POST', `${detailPath}/archive`) const archived = this.apiSuccess(module, '归档测试客户', archiveResponse, `tenantId=${tenantId}`, archiveOptions) if (archived) { this.assert(module, '测试客户归档后处于停用状态', archiveResponse, archived.status === 'DISABLED', `status=${archived.status ?? 'missing'}`, archiveOptions) } } async prepareInboundMeasurementFixture() { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-MOJIA` const createdKeys = ['orders', 'ocrInbound', 'testInbound'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) this.marker = fixtureMarker const extension = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) await this.executeModule('orders-inbound-measurement-fixture', () => extension.run(this)) const fixture = this.createdResources.orders this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), inboundMeasurement: fixture ? { marker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId, inboundId: fixture.inboundId, } : { marker: fixtureMarker, failed: true }, } return () => { this.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) this.createdResources[key] = snapshot.value else delete this.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) this.context[key] = snapshot.value else delete this.context[key] } } } async preparePhase3ContainerFixture() { const originalMarker = this.marker const fixtureMarker = `${originalMarker}-CAB` const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] const createdSnapshot = new Map(createdKeys.map((key) => [key, { present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], }])) const contextSnapshot = new Map(contextKeys.map((key) => [key, { present: Object.hasOwn(this.context, key), value: this.context[key], }])) this.marker = fixtureMarker const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) await this.executeModule('orders-phase3-container-fixture', () => orders.run(this)) const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this)) const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders this.createdResources.orchestrationFixtures = { ...(this.createdResources.orchestrationFixtures || {}), phase3Container: fixture ? { marker: fixtureMarker, tenantId: fixture.tenantId, orderId: fixture.orderId || this.createdResources.orders?.orderId, inboundId: fixture.inboundId, } : { marker: fixtureMarker, failed: true }, } return () => { this.marker = originalMarker for (const [key, snapshot] of createdSnapshot) { if (snapshot.present) this.createdResources[key] = snapshot.value else delete this.createdResources[key] } for (const [key, snapshot] of contextSnapshot) { if (snapshot.present) this.context[key] = snapshot.value else delete this.context[key] } } } async run() { const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) if (!login?.accessToken) this.fatal = '平台管理员登录失败,未执行业务模块' else { this.token = login.accessToken const handlers = { customers: () => this.runCustomers() } for (let moduleIndex = 0; moduleIndex < this.options.modules.length; moduleIndex += 1) { const module = this.options.modules[moduleIndex] let restoreFixture = null const ocrIndex = this.options.modules.indexOf('inbound-weight-ocr') if (module === 'inbound-measurement' && ocrIndex >= 0 && ocrIndex < moduleIndex) { try { restoreFixture = await this.prepareInboundMeasurementFixture() } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep('orders-inbound-measurement-fixture', '创建独立测量入库单', null, false, reason, { scenario: 'inbound-resource-isolation' }) } } if (module === 'phase3-container-loading') { try { restoreFixture = await this.preparePhase3ContainerFixture() } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep('phase3-container-fixture', '创建独立柜次级入库单', null, false, reason, { scenario: 'phase3-container-resource-isolation' }) } } let handler = handlers[module] if (!handler) { try { const extension = await import(new URL(`./admin-test-env-modules/${module}.mjs`, import.meta.url)) if (typeof extension.run !== 'function') throw new Error('模块必须导出 async function run(context)') handler = () => extension.run(this) } catch (error) { const reason = error instanceof Error ? error.message : String(error) this.addStep(module, '模块注册检查', null, false, `无法加载模块: ${reason}`) this.modules.push({ name: module, success: false, passed: 0, failed: 1, fatal: reason }) if (restoreFixture) restoreFixture() continue } } try { await this.executeModule(module, handler) } finally { if (restoreFixture) restoreFixture() } } } const reportSurface = JSON.stringify({ steps: this.steps, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, }) const forbiddenValues = [this.options.password, this.token].filter((value) => typeof value === 'string' && value.length >= 8) const sensitivePatterns = [ /"(?:password|newPassword|temporaryPassword|accessToken|refreshToken|authorization)"\s*:/i, /\bBearer\s+[A-Za-z0-9._~-]{16,}/i, /\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, /data:(?:image|application)\/[A-Za-z0-9.+-]+;base64,/i, /"(?:bytes|base64|ocrText|ocrRawText)"\s*:/i, ] const sensitive = forbiddenValues.some((value) => reportSurface.includes(value)) || sensitivePatterns.some((pattern) => pattern.test(reportSurface)) this.addStep('report-security', '报告敏感信息扫描', null, !sensitive, sensitive ? '检测到密码、Token、二进制/base64 或 OCR 原文字段' : '未发现密码、Token、文件二进制/base64 或 OCR 原文字段') const failed = this.steps.filter((step) => !step.passed).length return { reportName: '后台管理测试环境 HTTP API 验收', marker: this.marker, gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && failed === 0, fatal: this.fatal, summary: { total: this.steps.length, passed: this.steps.length - failed, failed }, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, createdResources: this.createdResources, excluded: [ { scope: 'dashboard', reason: '首页为静态演示数据,不调用后端 API' }, { scope: 'hidden-prototypes', reason: '隐藏原型页面尚无已确认后端契约' }, ], securityNote: '报告不保存登录密码、Access Token、Refresh Token、图片二进制或 OCR 原文全文。', steps: this.steps, } } } async function main() { const options = parseArguments(process.argv.slice(2)) const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) const outputPath = resolve(options.output || `reports/admin-test-env-acceptance-${timestamp}.json`) const report = await new AcceptanceRun(options).run() await mkdir(dirname(outputPath), { recursive: true }) await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') console.log(`REPORT=${outputPath}`) console.log(`RESULT=${report.success ? 'PASS' : 'FAIL'} total=${report.summary.total} passed=${report.summary.passed} failed=${report.summary.failed}`) process.exitCode = report.success ? 0 : 1 } main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1 })