Files
baiyue-firstleg/scripts/admin-test-env-modules/phase4-cross-region-events.mjs

159 lines
10 KiB
JavaScript

import { validDirectDeliveryReplayProof } from './direct-delivery-replay-proof.mjs'
const MODULE = 'phase4-cross-region-events'
const OPTIONS = { scenario: 'phase4-cross-region-events', deploymentBlocker: true }
function id(value) { return value == null ? '' : String(value) }
function records(data) { return Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : [] }
function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' }
function denied(response) {
return response?.http === 401 || response?.http === 403
|| ['401', '403', 'A0401', 'A0403'].includes(String(response?.body?.code))
}
function traced(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) }
function evidence(response, extra = '') {
return `${extra}${extra ? '; ' : ''}HTTP=${response?.http ?? 'missing'}, code=${response?.body?.code ?? 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}`
}
function blockingGap(context, name, reason, category, response = null) {
context.addCoverageGap(MODULE, OPTIONS.scenario, name, reason, category, { deploymentBlocker: true })
context.addStep(MODULE, name, response, false, reason, OPTIONS)
throw new Error(`blocking coverage gap: ${name}: ${reason}`)
}
async function poll(request, matches, attempts = 12) {
let last = null
for (let index = 0; index < attempts; index += 1) {
last = await request()
if (matches(last)) return { response: last, attempts: index + 1 }
if (index + 1 < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response: last, attempts }
}
function sameIds(left, right) {
const ids = (value) => records(value).map((item) => id(item?.id)).filter(Boolean).sort()
return JSON.stringify(ids(left)) === JSON.stringify(ids(right))
}
export async function run(context) {
if (!context.token) blockingGap(context, '统一会话前置', '平台访问令牌缺失', 'missing-session')
const authorized = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1')
context.assert(MODULE, '统一平台会话可访问Lastleg授权列表', authorized,
success(authorized) && traced(authorized), evidence(authorized), OPTIONS)
const anonymous = await context.request('GET', '/api/platform/v1/last-leg/orders?page=1&size=1', undefined, '')
context.assert(MODULE, '无会话不得访问Lastleg', anonymous, denied(anonymous) && traced(anonymous), evidence(anonymous), OPTIONS)
const phase3 = context.createdResources?.phase3Loading
if (phase3?.marker !== context.marker || !phase3?.finalizeSucceeded
|| !phase3?.containerBookingId || !phase3?.containerNo || !phase3?.loadingPlanId) {
blockingGap(context, '国内装柜完成事件前置',
'缺少本轮marker的LOADED装柜事实;必须先完成三期装柜链,禁止借用历史事件',
'missing-marker-loading-completed')
}
const domestic = await context.request('GET',
`/api/platform/v1/containers/domestic/${encodeURIComponent(id(phase3.containerBookingId))}`)
context.assert(MODULE, '同一平台会话可访问国内授权详情', domestic,
success(domestic) && traced(domestic), evidence(domestic), OPTIONS)
const taillegPath = `/api/platform/v1/last-leg/orders?page=1&size=200&containerNo=${encodeURIComponent(phase3.containerNo)}`
const delivered = await poll(
() => context.request('GET', taillegPath),
(response) => success(response) && records(response.body?.data)
.some((item) => item?.containerNo === phase3.containerNo))
const deliveredRows = records(delivered.response?.body?.data)
.filter((item) => item?.containerNo === phase3.containerNo)
context.assert(MODULE, '国内装柜完成经Outbox直接投递生成Lastleg订单', delivered.response,
deliveredRows.length > 0 && traced(delivered.response),
evidence(delivered.response, `pollRequests=${delivered.attempts}, containerNo=${phase3.containerNo}, orders=${deliveredRows.length}`), OPTIONS)
if (deliveredRows.length === 0) {
blockingGap(context, '国内到海外事件闭环',
`本轮柜号${phase3.containerNo}在轮询后仍无Lastleg订单;需结合requestId/traceId检查生产服务Outbox重试与下游诊断`,
'cn-overseas-event-not-delivered', delivered.response)
}
const deliveredAgain = await context.request('GET', taillegPath)
context.assert(MODULE, '装柜结果重复读取稳定(非事件重放证据)', deliveredAgain,
success(deliveredAgain) && sameIds(delivered.response.body?.data, deliveredAgain.body?.data)
&& traced(deliveredAgain),
evidence(deliveredAgain, `firstOrders=${deliveredRows.map((item) => id(item.id)).join(',')}`), OPTIONS)
const overseasPda = context.createdResources?.phase4OverseasPda
if (overseasPda?.marker !== context.marker || overseasPda?.finalStatus !== 'COMPLETED'
|| !overseasPda?.customerId || !overseasPda?.orderId) {
blockingGap(context, '海外柜完成事件前置',
'缺少本轮marker的海外PDA COMPLETED柜事实;必须先完成四期海外PDA闭环',
'missing-marker-overseas-container-completed')
}
const orderDetailPath = `/api/platform/v1/last-leg/orders/${encodeURIComponent(overseasPda.orderId)}?customerId=${encodeURIComponent(overseasPda.customerId)}`
const orderDetail = await context.request('GET', orderDetailPath)
const order = orderDetail.body?.data?.order || orderDetail.body?.data
const completedContainerNo = order?.containerNo || ''
context.assert(MODULE, '读取海外完成柜来源尾程单', orderDetail,
success(orderDetail) && Boolean(completedContainerNo) && traced(orderDetail),
evidence(orderDetail, `orderId=${overseasPda.orderId}, containerNo=${completedContainerNo || 'missing'}`), OPTIONS)
if (!completedContainerNo) {
blockingGap(context, '海外完成柜来源身份', '尾程订单未返回containerNo,无法核对国内回写',
'missing-overseas-container-identity', orderDetail)
}
const containerPagePath = `/api/platform/v1/containers/domestic?scope=HISTORY&containerNo=${encodeURIComponent(completedContainerNo)}&page=1&size=20`
const containerPage = await context.request('GET', containerPagePath)
const domesticMatches = records(containerPage.body?.data)
.map((item) => item?.container).filter((item) => item?.containerNo === completedContainerNo)
context.assert(MODULE, '物理柜号唯一定位国内柜聚合', containerPage,
success(containerPage) && domesticMatches.length === 1 && traced(containerPage),
evidence(containerPage, `containerNo=${completedContainerNo}, matches=${domesticMatches.length}`), OPTIONS)
if (domesticMatches.length !== 1) {
blockingGap(context, '海外到国内柜身份映射',
`物理柜号${completedContainerNo}匹配国内柜数量为${domesticMatches.length},无法验证回写`,
'domestic-container-identity-not-unique', containerPage)
}
const overviewPath = `/api/platform/v1/containers/${encodeURIComponent(id(domesticMatches[0].id))}/overview`
const writtenBack = await poll(
() => context.request('GET', overviewPath),
(response) => {
const container = response?.body?.data?.container
return success(response) && Boolean(container?.unloadingCompletedAt)
&& Boolean(container?.unloadingPhotoUploadedAt) && Boolean(container?.estimatedEmptyReportedAt)
})
const snapshot = writtenBack.response?.body?.data?.container || {}
const completedAt = Date.parse(snapshot.unloadingCompletedAt)
const uploadedAt = Date.parse(snapshot.unloadingPhotoUploadedAt)
const estimatedAt = Date.parse(snapshot.estimatedEmptyReportedAt)
const validTimeline = Number.isFinite(completedAt) && Number.isFinite(uploadedAt)
&& Number.isFinite(estimatedAt) && completedAt >= uploadedAt
&& estimatedAt - completedAt === 72 * 60 * 60 * 1_000
context.assert(MODULE, '海外柜完成经Outbox直接投递回写Firstleg时间事实', writtenBack.response,
validTimeline && traced(writtenBack.response),
evidence(writtenBack.response,
`pollRequests=${writtenBack.attempts}, completed=${snapshot.unloadingCompletedAt || 'missing'}, photo=${snapshot.unloadingPhotoUploadedAt || 'missing'}, estimatedEmpty=${snapshot.estimatedEmptyReportedAt || 'missing'}`), OPTIONS)
if (!validTimeline) {
blockingGap(context, '海外到国内事件闭环',
`柜号${completedContainerNo}轮询后仍无有效卸柜完成/拍照/预计报空回写;需检查生产服务Outbox重试与诊断证据`,
'overseas-cn-event-not-delivered', writtenBack.response)
}
const writtenBackAgain = await context.request('GET', overviewPath)
const replaySnapshot = writtenBackAgain.body?.data?.container || {}
const frozen = ['unloadingCompletedAt', 'unloadingPhotoUploadedAt', 'estimatedEmptyReportedAt',
'actualEmptyReportedAt', 'emptyReportSourceVersion']
context.assert(MODULE, '卸柜时间事实重复读取稳定(非事件重放证据)', writtenBackAgain,
success(writtenBackAgain) && frozen.every((field) => (replaySnapshot[field] ?? null) === (snapshot[field] ?? null))
&& traced(writtenBackAgain),
evidence(writtenBackAgain, `containerId=${id(domesticMatches[0].id)}, sourceVersion=${snapshot.emptyReportSourceVersion ?? 'missing'}`), OPTIONS)
const reader = context.context?.directDeliveryReplayEvidenceReader
if (typeof reader !== 'function') blockingGap(context, '真实投递重试与同事件幂等证据',
'两次GET仅证明读取稳定;需按event-outbox-operations.mjs的受保护内部运维流程,在本轮事件上验证故障后有限重试/DEAD及同eventId+payloadHash重复消费,保存逐次尝试与业务计数证据。不得重放SENT、改表或伪造新事件。',
'missing-direct-delivery-replay-evidence')
const scope = { marker: context.marker, loadingPlanId: id(phase3.loadingPlanId),
customerId: id(phase3.tenantId), containerBookingId: id(phase3.containerBookingId) }
const proof = await reader(scope)
context.createdResources.directDeliveryReplayProof = proof
const valid = validDirectDeliveryReplayProof(proof, scope)
context.assert(MODULE, '本轮原事件12次失败DEAD人工恢复DUPLICATE且业务零新增', null, valid,
`loadingPlanId=${scope.loadingPlanId}, attempts=${proof?.attempts?.length || 0}, restored=${proof?.restored === true}`, OPTIONS)
if (!valid) blockingGap(context, '真实投递重试证据不完整或身份漂移',
'本轮事件身份/摘要、12次有限重试、DEAD、人工恢复审计、重复消费及业务计数和恢复直连必须同时核平',
'invalid-direct-delivery-replay-evidence')
}