diff --git a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java index d608a51f3..131d9a58d 100644 --- a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java @@ -45,8 +45,9 @@ class OwnedBusinessPhotoFileTest { verifyNoInteractions(files, storage); } + /** 装车驾驶证允许 PDF;油耗仍限图片,两类都拒绝空文件、伪造内容和超限文件。 */ @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) - void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { + void enforcesLoadingAndFuelFormatPoliciesAndRejectsInvalidFiles(String type) throws Exception { var storage = new BusinessFileStorage(); ReflectionTestUtils.setField(storage, "configuredRoot", root); storage.initializeRoot(); @@ -60,8 +61,14 @@ class OwnedBusinessPhotoFileTest { for (var upload : new MockMultipartFile[]{ new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), - new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())}) + new MockMultipartFile("file", "spoof.pdf", "application/pdf", "not-pdf".getBytes())}) assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class); + var pdf = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.7\nTEST license".getBytes()); + if (LOADING.equals(type)) { + var saved = storage.store(0, "71", pdf, category); + assertThat(saved.contentType()).isEqualTo("application/pdf"); + assertThat(storage.read(saved.objectKey())).isEqualTo(pdf.getBytes()); + } else assertThatThrownBy(() -> storage.store(0, "71", pdf, category)).isInstanceOf(BizException.class); MultipartFile oversized = mock(MultipartFile.class); when(oversized.isEmpty()).thenReturn(false); when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); diff --git a/scripts/README.md b/scripts/README.md index 6d8f5b8bb..fe8eef497 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,6 +8,8 @@ Admin 全量可显式传 `--stop-file <本轮唯一停止文件>`。运行前文 `dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及服务端幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据、固定客户、`ADMIN_TEST_OVERSEAS_PDA_ACTORS` 和 `ADMIN_TEST_FLEET_RESOURCES`;不自动创建账号或车辆司机主档。可加 `--stop-after-draft` 准备浏览器验收样本:新建两托及正式预约后停在零已拣草稿,不分配车辆、不送审、不拣货;报告标记 `DRAFT_FIXTURE_ONLY`,不代表完整链路通过,不与 `--resume-report` 混用。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑。`--resume-report <原报告>` 仅接受准备写入前失败或全部准备写入均收到明确拒绝的失败报告,回查原单仍为零拣货草稿后重新准备车辆、三类审核照片与审核,不重复建货或建单;未知或成功写入不能使用此恢复入口。固定PDA账号仅注销会话,不停用;`--keep-pda-for-ui` 在成功后将账号交接信息保存到系统临时目录,页面验收使用私有配置中的既有凭据重新登录,不能将已注销Token当有效会话。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 +该入口可追加 `--verify-loading-documents --replacement-vehicle-id <独立TEST车辆ID> --replacement-driver-id <独立TEST司机ID>`,在本轮零拣货样本上验证审核自动带入、换车换司机后五类旧资料失效、旧轮次上传/绑定拒绝、平台/PDA混合上传至每类5张、第6张拒绝、满额替换及幂等重放、双端25张内容一致,再继续拣货与自动派送。替换资源必须已维护车牌照片、司机照片与已审核驾驶证,且与初次资源不同;脚本不创建或修改主档,不与草稿模式或旧报告恢复混用。 + Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 ## TEST 业务数据重置准备 diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs index 7ad3f8475..17dfb13b4 100644 --- a/scripts/dispatch-loading-flow-test-env.mjs +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -20,13 +20,16 @@ export function parseFlowArguments(args) { if(args[i]==='--execute')config.execute=true else if(args[i]==='--keep-pda-for-ui')config.keepPda=true else if(args[i]==='--stop-after-draft')config.stopAfterDraft=true + else if(args[i]==='--verify-loading-documents')config.verifyLoadingDocuments=true else { - const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport'}[args[i]] + const key={'--run-id':'runId','--warehouse-id':'warehouseId','--destination-warehouse-id':'destinationId','--test-image':'image','--resume-report':'resumeReport','--replacement-vehicle-id':'replacementVehicleId','--replacement-driver-id':'replacementDriverId'}[args[i]] if(!key || config[key] || !args[i+1] || args[i+1].startsWith('--'))throw Error('Unsupported or duplicate argument') config[key]=args[++i] } } if(config.execute && (!/^[A-Za-z0-9_-]{1,48}$/.test(config.runId || '') || !/^[1-9]\d*$/.test(config.warehouseId || '') || !/^[1-9]\d*$/.test(config.destinationId || '') || !config.image))throw Error('Explicit run, warehouses and synthetic image required') + if(config.verifyLoadingDocuments && (config.stopAfterDraft || config.resumeReport || !/^[1-9]\d*$/.test(config.replacementVehicleId || '') || !/^[1-9]\d*$/.test(config.replacementDriverId || '')))throw Error('Loading document verification requires explicit replacement resources and a fresh full flow') + if(!config.verifyLoadingDocuments && (config.replacementVehicleId || config.replacementDriverId))throw Error('Replacement resources require loading document verification') if(config.stopAfterDraft && config.resumeReport)throw Error('Draft UI preparation requires a fresh attempt') return config } @@ -69,6 +72,8 @@ export async function runLoadingFlow(config, env=process.env) { if(r.http!==200 || id(r.body?.code)!=='200')throw Error(`Upload failed ${path}: ${r.body?.message}, requestId=${r.requestId}`) return r.body.data } + // 二进制助手使用当前会话;串行切换后立即恢复,确保实际覆盖 PDA 文件权限。 + const binary=async(path,token=context.token)=>{const previous=context.token;try{context.token=token;return await context.requestBinary('GET',path)}finally{context.token=previous}} const phase=value=>{report.phase=value;save();console.log(JSON.stringify({phase:value,dispatchNo:report.dispatchNo}))} try { const auth=await send('POST','/api/auth/login',{loginId:options.username,password:options.password},'');context.token=auth.accessToken;context.refreshToken=auth.refreshToken @@ -130,9 +135,84 @@ export async function runLoadingFlow(config, env=process.env) { assert((previous.createdResources.fleetTripCommands||[]).every(c=>c.status==='RESPONDED' && c.http===200 && String(c.code)!=='200'),'Only a pre-write failure or definite rejected preparation may resume') report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId) }else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId) + if(config.verifyLoadingDocuments){ + phase('RESOURCE_PHOTO_ROUNDS_AND_FIVE_PER_CATEGORY') + const read=()=>send('GET',path+'/loading-documents') + const first=await read(), firstRound=id(first.reviewRound) + check('approved trip evidence automatically imported',first.photos.some(p=>p.slot==='LOADING_SCENE') && first.photos.some(p=>p.slot==='LOADING_LIST') && first.photos.every(p=>['LOADING_SCENE','LOADING_LIST'].includes(p.slot))) + const append=async(slot,label,round,platform=false,replaceFileId)=>{ + const base=(platform?path:pp)+'/loading-documents',token=platform?context.token:picker.token,uploadKey=key('UP-'+label) + const photo=await upload(`${base}/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${round}`,`${config.runId}-TEST-${label}.png`,token) + const current=await read(),body={recordVersion:current.recordVersion,idempotencyKey:key('SAVE-'+label),fileId:id(photo.fileId),uploadKey,...(replaceFileId?{replaceFileId}:{})} + return {photo,body,base,token} + } + for(const slot of slots.slice(2)){ + const p=await append(slot,'BEFORE-'+slot,firstRound) + await send('PUT',`${p.base}/${slot}`,p.body,p.token) + } + const pending=await append('PRE_DEPARTURE_CHECK','OLD-PENDING',firstRound) + const beforeChange=await read(),oldFiles=new Set(beforeChange.photos.map(p=>id(p.fileId))) + let detail=await send('GET',path) + check('replacement resources differ from original',id(detail.vehicleInfo.vehicle.vehicleId)!==config.replacementVehicleId && id(detail.vehicleInfo.driver.driverId)!==config.replacementDriverId) + const vehicleArchive=await send('GET',`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}`) + const driverArchive=await send('GET',`${ROOT}/fleet/drivers/${config.replacementDriverId}`) + const driverPortrait=await send('GET',`${ROOT}/fleet/resource-photos/DRIVER/${config.replacementDriverId}`) + assert(vehicleArchive.photo?.fileId && driverArchive.licenseFileId && driverPortrait.photo?.fileId,'Replacement resources require maintained vehicle photo, approved driver license and driver portrait') + await send('POST',path+'/return-to-review',{recordVersion:detail.recordVersion,idempotencyKey:key('RETURN'),reason:'TEST zero-pick resource evidence invalidation'}) + detail=await send('GET',path) + let result=await send('PUT',path,{recordVersion:detail.recordVersion,idempotencyKey:key('SWAP'),maintenance:detail.vehicleInfo.maintenance, + vehicle:{mode:'MASTER',vehicleId:config.replacementVehicleId},driver:{mode:'MASTER',driverId:config.replacementDriverId,evidence:{expectedTaskEndAt:report.trip.expectedDriverEndAt}}, + plannedDepartureAt:report.trip.updateRequest.plannedDepartureAt,estimatedArrivalAt:report.trip.updateRequest.estimatedArrivalAt,remark:'TEST replacement with maintained archives'}) + for(const action of ['submit-review','dispatch-review','loading-review'])result=await send('POST',`${path}/${action}`,{recordVersion:result.recordVersion,idempotencyKey:key('SECOND-'+action),...(action==='submit-review'?{remark:'TEST second review'}:{decision:'APPROVED',reason:'TEST second review'})}) + const second=await read(),round=id(second.reviewRound) + check('new review round invalidates all old resource evidence',Number(round)>Number(firstRound) && second.photos.length===2 && second.photos.every(p=>!oldFiles.has(id(p.fileId))) && ['LOADING_SCENE','LOADING_LIST'].every(s=>second.photos.some(p=>p.slot===s))) + const sourcePaths={LOADING_SCENE:`${ROOT}/fleet/drivers/${config.replacementDriverId}/license/${driverArchive.licenseFileId}`,LOADING_LIST:`${ROOT}/fleet/resource-photos/VEHICLE/${config.replacementVehicleId}/${vehicleArchive.photo.fileId}/content`} + report.importedArchives=[] + for(const photo of second.photos){ + const original=await context.requestBinary('GET',sourcePaths[photo.slot]) + const copied=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`) + const pdaCopy=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token) + report.importedArchives.push({slot:photo.slot,fileId:photo.fileId,contentType:copied.contentType,sourceHttp:original.http,copyHttp:copied.http,pdaHttp:pdaCopy.http,sha256:hash(copied.bytes),requestId:copied.requestId,traceId:copied.traceId}) + check('maintained archive copied exactly '+photo.slot,original.http===200 && copied.http===200 && pdaCopy.http===200 && original.bytes.length>0 && hash(original.bytes)===hash(copied.bytes) && hash(original.bytes)===hash(pdaCopy.bytes)) + } + await send('PUT',`${pending.base}/PRE_DEPARTURE_CHECK`,{...pending.body,recordVersion:second.recordVersion},pending.token,true) + check('stale upload bind preserves current photos',JSON.stringify(await read())===JSON.stringify(second)) + const stale=await context.requestMultipart('POST',`${pp}/loading-documents/PRE_DEPARTURE_CHECK/uploads?idempotencyKey=${key('STALE-ROUND')}&reviewRound=${firstRound}`,{name:'TEST-stale-round.png',type:'image/png',content:image},picker.token) + check('stale review round upload rejected',id(stale.body?.code)==='A0409') + for(const [slotIndex,slot] of slots.entries()){ + let docs=await read(),count=docs.photos.filter(p=>p.slot===slot).length + for(let index=count;index<5;index++){ + const p=await append(slot,`${slot}-${index}`,round,(index+slotIndex)%2===0) + await send('PUT',`${p.base}/${slot}`,p.body,p.token) + } + docs=await read();check('five saved '+slot,docs.photos.filter(p=>p.slot===slot).length===5) + const excess=await append(slot,'EXCESS-'+slot,round) + const rejected=await context.request('PUT',`${excess.base}/${slot}`,excess.body,excess.token,{}) + check('sixth rejected '+slot,['A0400','A0409'].includes(id(rejected.body?.code))) + check('sixth rejection preserves version '+slot,JSON.stringify(await read())===JSON.stringify(docs)) + const replace=await append(slot,'REPLACE-'+slot,round,true,id(docs.photos.find(p=>p.slot===slot).fileId)) + const replaced=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token) + const replay=await send('PUT',`${replace.base}/${slot}`,replace.body,replace.token) + check('full category replacement and replay '+slot,replaced.photos.filter(p=>p.slot===slot).length===5 && replaced.photos.some(p=>id(p.fileId)===id(replace.photo.fileId)) && !replaced.photos.some(p=>id(p.fileId)===replace.body.replaceFileId) && JSON.stringify(replay)===JSON.stringify(replaced)) + } + const adminDocs=await read(),pdaDocs=await send('GET',pp+'/loading-documents',undefined,picker.token) + check('Admin and PDA share all 25 photos',adminDocs.photos.length===25 && adminDocs.complete && JSON.stringify(adminDocs)===JSON.stringify(pdaDocs)) + report.sharedFiles=[] + for(const photo of adminDocs.photos){ + const admin=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`) + const pda=await binary(`${pp}/loading-documents/${photo.slot}/files/${photo.fileId}/content`,picker.token) + report.sharedFiles.push({slot:photo.slot,fileId:photo.fileId,adminHttp:admin.http,pdaHttp:pda.http,adminSha256:hash(admin.bytes),pdaSha256:hash(pda.bytes),requestId:pda.requestId,traceId:pda.traceId}) + check('Admin and PDA file bytes '+photo.slot+' '+photo.fileId,admin.http===200 && pda.http===200 && hash(admin.bytes)===hash(image) && hash(pda.bytes)===hash(image)) + } + report.loadingDocumentVerification={reviewRound:round,photos:25,replacementVehicleId:config.replacementVehicleId,replacementDriverId:config.replacementDriverId} + } for(const [index,m] of report.members.entries()) { const task=await send('GET',pp,undefined,picker.token),item=task.items.find(i=>i.palletNo===m.palletNo);assert(item);m.dispatchItemId=id(item.dispatchItemId) const body={recordVersion:task.recordVersion,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key('PICK-'+index),remark:context.marker} + // 新实收库存仍须走当前仓租预校验,不能用旧脚本绕过正式扫码前置。 + let preparation=await send('POST',PDA+'/storage-rent/preparations',{mode:'DISPATCH',businessNo:report.dispatchNo,itemId:m.dispatchItemId,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:body.idempotencyKey,cartonMarks:[]},picker.token) + for(let attempt=0;!preparation.ready && preparation.status==='FREEZE_PENDING' && attempt<20;attempt++){await new Promise(resolve=>setTimeout(resolve,500));preparation=await send('GET',PDA+'/storage-rent/preparations/'+preparation.preparationId,undefined,picker.token)} + check('storage rent ready before pick '+index,preparation.ready===true) const picked=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token) const replay=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token);check('pick replay '+index,replay.replayed===true && id(replay.recordVersion)===id(picked.recordVersion)) if(index===0){const preview=await send('GET',pp+'/loading',undefined,picker.token);await send('POST',pp+'/loading-complete',{recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('INCOMPLETE')},picker.token,true)} @@ -142,8 +222,8 @@ export async function runLoadingFlow(config, env=process.env) { phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK') const initialDocuments=await send('GET',pp+'/loading-documents',undefined,picker.token),reviewRound=id(initialDocuments.reviewRound),uploadedFiles=new Set() assert.match(reviewRound,/^[1-9]\d*$/) - for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} - const docs=await send('GET',path+'/loading-documents');check('Admin sees imported and five added photos',docs.complete && docs.photos.length===initialDocuments.photos.length+5) + if(!config.verifyLoadingDocuments)for(const slot of slots){const uploadKey=key('UPLOAD-'+slot);const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${uploadKey}&reviewRound=${reviewRound}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);assert.equal(id(docs.reviewRound),reviewRound);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId),uploadKey},picker.token);uploadedFiles.add(id(up.fileId))} + const docs=await send('GET',path+'/loading-documents');check('Admin sees all saved photos',docs.complete && docs.photos.length===initialDocuments.photos.length+(config.verifyLoadingDocuments?0:5)) report.photos=[] for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && r.bytes.length>0 && (!uploadedFiles.has(id(photo.fileId)) || hash(r.bytes)===hash(image)));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} phase('AUTOMATIC_OUTBOUND_AND_DELIVERY')