import { createHash } from 'node:crypto'; /** 仅接收成功上传并校验的预览对象;对象身份不包含 CDN 域名。 */ export function buildAssetMappings(report, assets, config) { if ( report.schemaVersion !== 1 || report.mode !== 'apply' || report.ok !== true || !Array.isArray(report.files) ) throw new Error('INVALID_SYNC_REPORT'); const files = new Map(); for (const file of report.files) { if (!file.relativePath?.startsWith('assets/previews/')) continue; const parts = file.relativePath.split('/'); if ( parts.some((part) => !part || part === '.' || part === '..') || file.relativePath.includes('\\') || !['uploaded', 'skipped'].includes(file.status) || file.verifiedBy !== 'qiniu-etag-and-size' || file.bucket !== config.publicBucket || file.key !== `${config.prefix}${file.relativePath}` || file.visibility !== 'public' || !file.mimeType?.startsWith('image/') || !Number.isSafeInteger(file.size) || file.size < 0 || !/^[a-f0-9]{64}$/.test(file.sha256) || typeof file.etag !== 'string' || files.has(file.relativePath) ) throw new Error('INVALID_REPORT_FILE'); files.set(file.relativePath, file); } const mappings = []; for (const asset of assets) { if ( asset.purpose !== 'preview' || !asset.sourceRef?.startsWith('cloud://') ) continue; const url = new URL(asset.sourceRef); if ( !url.hostname || url.search || url.hash || asset.sourceRefHash !== createHash('sha256').update(asset.sourceRef).digest('hex') ) throw new Error('INVALID_SOURCE_REFERENCE'); const path = decodeURIComponent(url.pathname.slice(1)); const file = files.get(path); if ( !file || asset.visibility !== 'public' || asset.ownerUserId !== null || !['pending', 'ready'].includes(asset.status) ) throw new Error('ASSET_MAPPING_MISSING_OR_INELIGIBLE'); const data = { provider: 'qiniu', bucket: file.bucket, objectKey: file.key, objectRefHash: createHash('sha256') .update(JSON.stringify(['qiniu', file.bucket, file.key])) .digest('hex'), externalUrl: null, mimeType: file.mimeType, sizeBytes: BigInt(file.size), checksum: file.sha256, status: 'ready', lastError: null, }; const unchanged = Object.entries(data).every( ([key, value]) => asset[key] === value, ); // 已就绪对象只能重复确认同一映射,禁止工具悄悄覆盖之后的编辑。 if (asset.status === 'ready' && !unchanged) throw new Error('READY_ASSET_CONFLICT'); mappings.push({ asset, file, data, unchanged }); } if (!mappings.length) throw new Error('NO_MATCHING_ASSETS'); return mappings; }