// Shared data contract. No database, filesystem, credentials or model transport. const fail = message => { const error = new Error(message); error.status = 400; error.code = 'invalid_creation'; throw error } const string = (value, max, label) => typeof value === 'string' && value.length <= max ? value : fail(label + '格式无效') const list = (value, max, label) => Array.isArray(value) && value.length <= max ? value : fail(label + '超出限制') const object = value => value && typeof value === 'object' && !Array.isArray(value) ? value : fail('创作资料格式无效') const localSource = value => /^local:[a-zA-Z0-9-]{1,100}$/.test(value || '') ? value : fail('产品图片必须属于本地项目') const identifier = value => typeof value === 'string' && /^[\w.:-]{1,150}$/.test(value) ? value : fail('资料编号无效') const date = value => value === null || typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : fail('资料时间无效') const states = (value, allowed) => allowed.includes(value) ? value : fail('创作状态无效') const stable=value=>JSON.stringify(value,(_,item)=>item&&typeof item==='object'&&!Array.isArray(item)?Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])):item) /** Three-way canvas merge: only actual edits against the renderer's base can * replace model-owned pixels. A stale placeholder is never a user deletion. */ export function mergeCreationCanvas(local,canonical,base){ if(!base||stable(local.screens)===stable(base.screens))return canonical.screens const fields=(value,current,before,skip=[])=>{const result={...current};for(const [key,item]of Object.entries(value))if(!skip.includes(key)&&stable(item)!==stable(before?.[key]))result[key]=item;return result} const screens=canonical.screens.flatMap(current=>{ const before=base.screens.find(item=>item.id===current.id),value=local.screens.find(item=>item.id===current.id) if(!before)return [current] if(!value)return [] const result=fields(value,current,before,['layers']) result.layers=current.layers.flatMap(layer=>{ const old=before.layers.find(item=>item.id===layer.id),edited=value.layers.find(item=>item.id===layer.id) if(!old)return [layer] if(!edited)return [] return [fields(edited,layer,old)] }) for(const layer of value.layers)if(!before.layers.some(item=>item.id===layer.id)&&!result.layers.some(item=>item.id===layer.id))result.layers.push(layer) return [result] }) for(const screen of local.screens)if(!base.screens.some(item=>item.id===screen.id)&&!screens.some(item=>item.id===screen.id))screens.push(screen) return screens } export function emptyBrief() { return { source: 'upload', name: '', brandId: '', brandName: '', categoryId: '', categoryName: '', identifierType: 'sku', identifier: '', variant: '', colorMaterialId: '', colorName: '', dimensions: { length: '', width: '', height: '', unit: 'mm' }, images: [], note: '' } } export function emptyCreation() { return { creationRevision: 0, brief: emptyBrief(), facts: { status: 'idle', items: [], missing: [] }, sellingPoints: { batches: [], selected: [], direction: '', stale: false }, plan: { status: 'idle', screens: [], invalidReason: '', confirmedAt: null, confirmedRevision: null }, designSnapshot: null } } export function validateBrief(value) { const input = object(value), dimensions = object(input.dimensions) const clean = { source: states(input.source, ['upload', 'library']), name: string(input.name, 120, '产品名称'), brandId: string(input.brandId, 150, '品牌'), brandName: string(input.brandName, 120, '品牌名称'), categoryId: string(input.categoryId ?? '', 150, '品类'), categoryName: string(input.categoryName ?? '', 120, '品类名称'), identifierType: states(input.identifierType, ['sku', 'spu', 'barcode']), identifier: string(input.identifier, 200, '产品编号'), variant: string(input.variant, 200, '产品款式'), colorMaterialId: string(input.colorMaterialId, 150, '材质编号'), colorName: string(input.colorName, 200, '材质颜色'), dimensions: { unit: states(dimensions.unit, ['mm', 'cm', 'm']) }, note: string(input.note, 6000, '补充说明'), images: [] } for (const axis of ['length', 'width', 'height']) { const number = string(dimensions[axis], 12, '尺寸') if (number && (!/^\d{1,6}(?:\.\d{1,3})?$/.test(number) || Number(number) <= 0)) fail('尺寸请填写大于 0 的数字,最多三位小数') clean.dimensions[axis] = number } const seen = new Set() clean.images = list(input.images, 8, '产品图与场景参考图合计').map(image => { object(image) const source = localSource(image.source), purpose = states(image.purpose, ['product', 'scene']) if (seen.has(source)) fail('同一图片只能指定一种用途') seen.add(source) const result = { source, purpose, name: string(image.name, 200, '图片名称') } if (image.origin) { object(image.origin) result.origin = { materialId: identifier(image.origin.materialId), kind: states(image.origin.kind, ['asset', 'sku']), id: identifier(image.origin.id), productModel: string(image.origin.productModel, 200, '产品型号'), variant: string(image.origin.variant, 200, '产品款式') } } return result }) return clean } function snapshot(value) { if (value === null) return null const encoded = JSON.stringify(value) if (!encoded || encoded.length > 256000) fail('知识快照过大') const check = (item, depth = 0) => { if (depth > 16) fail('知识快照嵌套过深') if (item && typeof item === 'object') for (const [key, child] of Object.entries(item)) { if (/^(?:__proto__|constructor|prototype|api_?key|access_?token|authorization|password|secret)$/i.test(key)) fail('项目快照不能包含密钥或无效字段') check(child, depth + 1) } } check(value) return JSON.parse(encoded) } export function validateCreation(value) { if (!Number.isSafeInteger(value.creationRevision) || value.creationRevision < 0 || value.creationRevision > 2147483646) fail('创作版本无效') const facts = object(value.facts), points = object(value.sellingPoints), plan = object(value.plan) const batches = list(points.batches, 12, '卖点批次').map(batch => ({ id: identifier(batch.id), direction: string(batch.direction, 500, '表达方向'), items: list(batch.items, 6, '每批卖点').map(item => string(item, 500, '卖点')), ...(batch.factsHash ? { factsHash: string(batch.factsHash, 64, '事实版本') } : {}), ...(batch.warnings ? { warnings: list(batch.warnings, 20, '卖点待确认项').map(item => string(item, 500, '待确认项')) } : {}) })) if (new Set(batches.map(batch => batch.id)).size !== batches.length) fail('卖点批次编号重复') const possible = new Set(batches.flatMap(batch => batch.items.map((_, index) => `${batch.id}:${index}`))) const selected = list(points.selected, 20, '已选卖点').map(identifier) if (new Set(selected).size !== selected.length || selected.some(id => !possible.has(id))) fail('选择了不存在或重复的卖点') const screens = list(plan.screens, 12, '页面方案').map(screen => ({ id: identifier(screen.id), name: string(screen.name, 120, '页面名称'), module: string(screen.module, 120, '页面模块'), image: Boolean(screen.image), title: string(screen.title || '', 120, '页面标题'), body: string(screen.body || '', 1800, '页面正文'), imagePrompt: string(screen.imagePrompt || '', 8000, '图片要求') })) if (new Set(screens.map(screen => screen.id)).size !== screens.length) fail('方案屏幕编号重复') const confirmedRevision = plan.confirmedRevision if (confirmedRevision !== null && (!Number.isSafeInteger(confirmedRevision) || confirmedRevision < 0)) fail('方案确认版本无效') return { creationRevision: value.creationRevision, brief: validateBrief(value.brief), facts: { status: states(facts.status, ['idle', 'ready', 'stale']), items: list(facts.items, 100, '产品事实').map(item => ({ text: string(item.text, 1000, '事实'), source: string(item.source, 500, '事实依据') })), missing: list(facts.missing, 50, '待确认项').map(item => string(item, 500, '待确认项')) }, sellingPoints: { batches, selected, direction: string(points.direction, 500, '表达方向'), stale: Boolean(points.stale) }, plan: { status: states(plan.status, ['idle', 'needs-confirm', 'confirmed', 'stale']), screens, invalidReason: string(plan.invalidReason, 500, '失效原因'), confirmedAt: date(plan.confirmedAt), confirmedRevision, ...(plan.warnings ? { warnings: list(plan.warnings, 20, '方案待确认项').map(item => string(item, 500, '待确认项')) } : {}) }, designSnapshot: snapshot(value.designSnapshot), ...(value.generation ? {generation:validateGeneration(value.generation)} : {}) } } function validateGeneration(value){ object(value) if(!/^[a-f0-9]{64}$/.test(value.planHash||''))fail('生成方案版本无效') const screens={} for(const [id,item]of Object.entries(object(value.screens))){ identifier(id);object(item) if(Object.keys(screens).length>=12||!/^[a-f0-9]{64}$/.test(item.planHash||''))fail('生成屏幕记录无效') screens[id]={taskId:identifier(item.taskId),source:localSource(item.source),planHash:item.planHash} } return {planHash:value.planHash,anchorSource:value.anchorSource?localSource(value.anchorSource):null,screens} } export function upgradeCreation(document) { if (document.version === 2) return { ...document, ...validateCreation(document) } if (document.version !== 1) fail('项目版本暂不支持') return { ...document, version: 2, ...emptyCreation() } } /** Preserve historical work, invalidate its authority; never dispatch anything here. */ export function prepareCreationEdit(next, previous) { if (next.version !== 2) { if (previous?.version === 2) fail('创作项目不能降级为旧画布格式') return next } const result = upgradeCreation(next) const before = previous?.version === 2 ? previous : null // Variant is a binding from the original lookup, not a second editable SKU. // A deliberate identifier edit must not silently keep a different hidden variant. if (before && result.brief.identifier !== before.brief.identifier && result.brief.variant === before.brief.variant) result.brief.variant = '' const briefChanged = before && stable(result.brief) !== stable(before.brief) const selectedChanged = before && stable(result.sellingPoints.selected) !== stable(before.sellingPoints.selected) const snapshotChanged = before && stable(result.designSnapshot) !== stable(before.designSnapshot) const widthChanged = before && result.screens[0]?.width !== before.screens[0]?.width const factsChanged = before && stable(result.facts) !== stable(before.facts) if (briefChanged) { if (result.facts.items.length) result.facts = { ...result.facts, status: 'stale' } if (result.sellingPoints.batches.length) result.sellingPoints = { ...result.sellingPoints, stale: true } if (result.brief.brandId !== before.brief.brandId && result.designSnapshot?.brand?.id !== result.brief.brandId) result.designSnapshot = null } if ((briefChanged || selectedChanged || widthChanged || snapshotChanged || factsChanged) && result.plan.screens.length) result.plan = { ...result.plan, status: 'stale', invalidReason: briefChanged ? '产品资料已变更,请重新核对事实并确认页面方案' : selectedChanged ? '已选卖点已变更,请更新并确认页面方案' : snapshotChanged ? '品牌规范、规则、模板或模型配置已变更,请重新确认页面方案' : factsChanged ? '产品事实已更新,请重新挑选卖点并确认页面方案' : '画布尺寸已变更,请重新确认页面方案', confirmedAt: null, confirmedRevision: null } result.creationRevision = before?.creationRevision || 0 if (!before || stable(result) !== stable(before)) result.creationRevision++ return result }