const MODULE = 'phase3-carrier-schedule-queries' const OPTIONS = { scenario: 'carrier-schedule-query-policy', deploymentBlocker: true } const BASE = '/api/platform/v1/carrier-schedule-queries' const OCEAN = '/api/platform/v1/ocean-bookings' const PORT = '/api/platform/v1/port-schedules' import { resolveOceanBookingMaster, oceanBookingDraft } from './ocean-booking-fixture.mjs' function id(value) { return value == null ? '' : String(value) } export function matchesManualScheduleScope(resource, expected) { return Boolean(resource?.id) && id(resource.customerId) === id(expected.customerId) && id(resource.oceanBookingId) === id(expected.oceanBookingId) && id(resource.containerBookingId) === id(expected.containerBookingId) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } function data(response) { return codeOf(response) === '200' ? response.body.data : null } function localIso(value) { return value.toISOString().slice(0, 19) } function at(offsetMinutes) { return localIso(new Date(Date.now() + offsetMinutes * 60_000)) } function step(context, name, response, passed, evidence) { context.addStep(MODULE, name, response, passed, evidence, { ...OPTIONS, deploymentBlocker: !passed }) } async function request(context, method, path, body, headers = {}) { try { const response = await fetch(`${context.options.gateway}${path}`, { method, headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), }) const raw = await response.text(); let payload = null try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } return { method, path, http: response.status, body: payload, raw: payload === null ? raw.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: String(error), requestId: null, traceId: null } } } function oceanDraft(context, suffix, master) { return { ...oceanBookingDraft(context, suffix, master), shippingCompanyName: 'ZIM', bookingAgentName: `船司查询代理-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', serviceTerm: 'CY-CY', freightTerm: 'NINGBO', remark: context.marker, parties: [ { partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }, ], containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 68, maxWeightKg: 28000, quantity: 1 }], } } async function ensureOcean(context, suffix, master) { const existing = context.createdResources?.oceanBooking if (existing?.id && id(existing.customerId) === id(master.customerId)) return { id: id(existing.id), containerBookingId: null } const response = await request(context, 'POST', OCEAN, { expectedVersion: 0, data: oceanDraft(context, suffix, master) }, { 'Idempotency-Key': `${context.marker}-carrier-query-ocean`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建船司查询隔离订舱', response, '', OPTIONS) return { id: id(detail?.oceanBooking?.id), containerBookingId: null } } async function createManualSchedule(context, suffix, oceanBookingId, customerId) { const existing = context.createdResources?.portSchedule const expectedScope = { customerId, oceanBookingId, containerBookingId: null } if (matchesManualScheduleScope(existing, expectedScope)) return id(existing.id) const body = { expectedVersion: 0, data: { customerId, oceanBookingId, containerBookingId: null, sourceType: 'MANUAL', sourceName: `船司查询人工兜底-${suffix}`, sourceRef: `${context.marker}-CARRIER-MANUAL`, vesselName: `人工船期船-${suffix}`, voyageNo: `CM${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', etdAt: at(10 * 24 * 60), etaAt: at(30 * 24 * 60), bookingCutoffAt: at(7 * 24 * 60), customsCutoffAt: at(8 * 24 * 60), portCutoffAt: at(9 * 24 * 60), observedAt: at(-1), expiresAt: at(3 * 24 * 60), remark: context.marker, }, reason: '船司官网人工查询兜底', } const response = await request(context, 'POST', PORT, body, { 'Idempotency-Key': `${context.marker}-carrier-manual-schedule`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, '创建真实人工船期兜底结果', response, '', OPTIONS) return id(detail?.portSchedule?.schedule?.id) } export async function run(context) { context.addCoverageGap(MODULE, OPTIONS.scenario, 'ZIM官网自动查询', 'ZIM启用hCaptcha,本期禁止绕过;真实契约返回MANUAL_REQUIRED并提供官方入口和人工结果绑定', 'external-third-party') context.addCoverageGap(MODULE, OPTIONS.scenario, 'COSCO官网自动查询', 'COSCO为动态页面/iframe且没有稳定公开机器接口;真实契约返回MANUAL_REQUIRED,不伪造自动成功', 'external-third-party') const suffix = context.marker.replace(/\D/g, '').slice(-10) const carriersResponse = await request(context, 'GET', `${BASE}/carriers`) const carriers = context.apiSuccess(MODULE, '六家船司查询策略入口已部署', carriersResponse, '', OPTIONS) const byCode = new Map((Array.isArray(carriers) ? carriers : []).map((item) => [item.carrierCode, item])) context.assert(MODULE, '六家官方来源及合规模式完整', carriersResponse, ['MATSON', 'SML', 'ZIM', 'COSCO', 'EMC', 'HEDE'].every((code) => byCode.has(code)) && byCode.get('ZIM')?.accessMode === 'HCAPTCHA' && byCode.get('ZIM')?.coverageGap === true && byCode.get('COSCO')?.accessMode === 'DYNAMIC_IFRAME' && byCode.get('COSCO')?.coverageGap === true && [...byCode.values()].every((item) => item.officialUrl?.startsWith('http') && item.automated === false), `codes=${[...byCode.keys()].join(',') || 'missing'}`, OPTIONS) const master = await resolveOceanBookingMaster(context, MODULE) if (!master) return const ocean = await ensureOcean(context, suffix, master) if (!ocean.id) return const createBody = { expectedVersion: 0, customerId: master.customerId, oceanBookingId: ocean.id, containerBookingId: ocean.containerBookingId, carrierCode: 'ZIM', trackingType: 'BILL_OF_LADING', trackingNo: `${context.marker}-ZIM`, remark: context.marker, } const createKey = `${context.marker}-carrier-query-create`.slice(0, 128) const createResponse = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) const created = context.apiSuccess(MODULE, '创建真实船司查询任务', createResponse, '', OPTIONS) const queryId = id(created?.query?.query?.id) context.assert(MODULE, '查询任务初始来源、频控和过期字段稳定', createResponse, Boolean(queryId) && created?.query?.query?.status === 'READY' && created?.query?.query?.attemptCount === 0 && created?.query?.maxAttempts === 3 && created?.query?.expired === true && Boolean(created?.query?.nextAllowedAt) && created?.query?.query?.accessMode === 'HCAPTCHA', `queryId=${queryId || 'missing'}, status=${created?.query?.query?.status || 'missing'}`, OPTIONS) if (!queryId) return const replay = await request(context, 'POST', BASE, createBody, { 'Idempotency-Key': createKey }) context.assert(MODULE, '创建任务幂等重放返回同一资源', replay, id(data(replay)?.query?.query?.id) === queryId, `first=${queryId}, replay=${id(data(replay)?.query?.query?.id) || 'missing'}`, OPTIONS) let version = Number(created.query.query.version) for (let attemptNo = 1; attemptNo <= 3; attemptNo += 1) { const response = await request(context, 'POST', `${BASE}/${queryId}/attempts`, { expectedVersion: version, reason: `第${attemptNo}次官网查询` }, { 'Idempotency-Key': `${context.marker}-carrier-attempt-${attemptNo}`.slice(0, 128) }) const detail = context.apiSuccess(MODULE, `第${attemptNo}次查询返回结构化人工兜底`, response, '', OPTIONS) context.assert(MODULE, `第${attemptNo}次查询记录来源和审计状态`, response, detail?.query?.query?.status === 'MANUAL_REQUIRED' && Number(detail?.query?.query?.attemptCount) === attemptNo && detail?.attempts?.length === attemptNo && detail?.attempts?.at(-1)?.coverageGap === true && detail?.attempts?.at(-1)?.coverageGapCode === 'HCAPTCHA_REQUIRED' && Boolean(detail?.query?.query?.lastAttemptAt) && Boolean(detail?.query?.query?.manualReason), `status=${detail?.query?.query?.status || 'missing'}, attempts=${detail?.query?.query?.attemptCount ?? 'missing'}`, OPTIONS) version = Number(detail?.query?.query?.version) } const fourth = await request(context, 'POST', `${BASE}/${queryId}/attempts`, { expectedVersion: version, reason: '超过窗口上限' }, { 'Idempotency-Key': `${context.marker}-carrier-attempt-4`.slice(0, 128) }) step(context, '同一3天窗口第4次查询被服务端拒绝', fourth, fourth.http === 200 && ['A0409', '409'].includes(codeOf(fourth)), `HTTP=${fourth.http}, code=${codeOf(fourth)}`) const unknownCarrier = await request(context, 'POST', BASE, { ...createBody, carrierCode: 'UNKNOWN', trackingNo: `${context.marker}-UNKNOWN` }, { 'Idempotency-Key': `${context.marker}-carrier-unknown`.slice(0, 128) }) step(context, '非法船司代码被拒绝', unknownCarrier, unknownCarrier.http === 200 && ['A0400', '400'].includes(codeOf(unknownCarrier)), `HTTP=${unknownCarrier.http}, code=${codeOf(unknownCarrier)}`) const forgedCustomer = await request(context, 'POST', BASE, { ...createBody, customerId: '10002', trackingNo: `${context.marker}-FORGED` }, { 'Idempotency-Key': `${context.marker}-carrier-forged-customer`.slice(0, 128) }) step(context, '伪造客户与订舱关联被拒绝', forgedCustomer, forgedCustomer.http === 200 && ['A0400', '400'].includes(codeOf(forgedCustomer)), `HTTP=${forgedCustomer.http}, code=${codeOf(forgedCustomer)}`) const scheduleId = await createManualSchedule(context, suffix, ocean.id, master.customerId) if (!scheduleId) return const manual = await request(context, 'POST', `${BASE}/${queryId}/manual-results`, { expectedVersion: version, portScheduleId: scheduleId, reason: '人工完成官网查询并复核船期' }, { 'Idempotency-Key': `${context.marker}-carrier-manual-result`.slice(0, 128) }) const completed = context.apiSuccess(MODULE, '绑定真实人工船期结果', manual, '', OPTIONS) context.assert(MODULE, '人工兜底保留官网尝试且形成有效更新时间和过期状态', manual, completed?.query?.query?.status === 'SUCCESS' && id(completed?.query?.query?.resultPortScheduleId) === scheduleId && Boolean(completed?.query?.query?.lastSuccessAt) && Boolean(completed?.query?.query?.sourceUpdatedAt) && Boolean(completed?.query?.nextAllowedAt) && completed?.query?.expired === false && completed?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `status=${completed?.query?.query?.status || 'missing'}, result=${id(completed?.query?.query?.resultPortScheduleId) || 'missing'}`, OPTIONS) const early = await request(context, 'POST', `${BASE}/${queryId}/attempts`, { expectedVersion: Number(completed?.query?.query?.version), reason: '未到三天再次查询' }, { 'Idempotency-Key': `${context.marker}-carrier-too-early`.slice(0, 128) }) step(context, '成功后未到3天禁止再次查询', early, early.http === 200 && ['A0409', '409'].includes(codeOf(early)), `HTTP=${early.http}, code=${codeOf(early)}`) const detailResponse = await request(context, 'GET', `${BASE}/${queryId}`) const detail = context.apiSuccess(MODULE, '回查船司查询任务详情与历史', detailResponse, '', OPTIONS) context.assert(MODULE, '查询尝试和版本历史完整且不伪装自动成功', detailResponse, detail?.attempts?.length === 3 && detail?.attempts?.every((item) => item.attemptStatus === 'MANUAL_REQUIRED') && detail?.histories?.some((item) => item.action === 'CREATE') && detail?.histories?.filter((item) => item.action === 'ATTEMPT').length === 3 && detail?.histories?.some((item) => item.action === 'MANUAL_RESULT'), `attempts=${detail?.attempts?.length ?? 'missing'}, histories=${detail?.histories?.length ?? 'missing'}`, OPTIONS) }