import {randomUUID} from 'node:crypto'; import {z} from 'zod'; import type {PoolClient} from 'pg'; import {db,transaction} from './db'; import {parameters,LayoutParameterError} from './layout-parameters'; import fixedLayouts from '../content/studio-layouts.json'; import standardLayout from '../content/standard-layout.json'; const layouts=[...fixedLayouts,standardLayout]; import {ensureDesignPost,studioPageUrl,type DesignRow} from './studio-design-posts'; export const designInput=z.object({schemaVersion:z.literal(1).optional(),layoutId:z.string(),name:z.string().trim().min(1).max(120),parameters:z.unknown()}).strict(); export const updateDesignInput=designInput.omit({layoutId:true}).extend({revision:z.number().int().positive()}).strict(); export class DesignConflict extends Error{} export function designOutput(r:Record){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,schemaVersion:r.document?2:1,...(r.document?{document:r.document}:{}),family:r.layout_id==='standard-01'?'standard':'fixed',archived:r.archived||false,archivedAt:r.archived_at||null,createdAt:r.created_at,updatedAt:r.updated_at,url:studioPageUrl(r as DesignRow),schemaUrl:'/studio/api/layouts/'+r.layout_id,postId:r.post_id||null,postUrl:r.post_id?'/explore/'+r.post_id:null,renderUrl:studioPageUrl(r as DesignRow),legacySvgUrl:r.layout_id==='standard-01'?null:'/studio/api/layouts/'+r.layout_id+'?format=svg'};} export async function validateDesign(layoutId:string,raw:unknown,client?:PoolClient){const layout=layouts.find(l=>l.id===layoutId);if(!layout)throw new LayoutParameterError('排版不存在');const p=parameters(layout,raw),images=[...new Set(Object.values(p.images).flatMap(i=>i.assetId?[i.assetId]:[]))];if(images.length){const found=await (client||db()).query("SELECT id FROM assets WHERE kind='image' AND id=ANY($1::uuid[])",[images]);if(found.rowCount!==images.length)throw new LayoutParameterError('引用的图片素材不存在')}return p;} export async function createDesign(input:z.infer){ const p=await validateDesign(input.layoutId,input.parameters); return transaction(async client=>{ const result=await client.query('INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,$2,$3,$4) RETURNING *',[randomUUID(),input.layoutId,input.name,JSON.stringify(p)]); return designOutput(await ensureDesignPost(client,result.rows[0])); }); } export async function updateDesign(id:string,input:z.infer){ return transaction(async client=>{ const old=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0] as DesignRow|undefined; if(!old)return null; if((old as DesignRow&{document?:unknown}).document)throw new DesignConflict('多页作品请使用 /api/studio/series/{id} 更新'); if(old.archived)throw new DesignConflict('页面已归档,请先恢复'); if(old.revision!==input.revision)throw new DesignConflict('效果已被修改,请重新读取后再保存'); const p=await validateDesign(old.layout_id,input.parameters,client); const result=await client.query('UPDATE studio_designs SET name=$2,parameters=$3,revision=revision+1,updated_at=now() WHERE id=$1 RETURNING *',[id,input.name,JSON.stringify(p)]); return designOutput(await ensureDesignPost(client,result.rows[0])); }); } /** Explicit, repeatable repair for pre-existing designs; never changes their revision. */ export async function publishDesignPost(id:string){ return transaction(async client=>{ const row=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0] as DesignRow|undefined; if(!row)return null; const created=!row.post_id; return {design:designOutput(await ensureDesignPost(client,row)),created}; }); } export const archiveDesignInput=z.object({revision:z.number().int().positive(),archived:z.boolean().optional()}).strict(); export async function archiveDesign(id:string,revision:number,archived:boolean){ return transaction(async client=>{ const old=(await client.query('SELECT * FROM studio_designs WHERE id=$1 FOR UPDATE',[id])).rows[0]; if(!old)return null; if(old.revision!==revision)throw new DesignConflict('修订冲突,请重新读取'); const result=await client.query(`UPDATE studio_designs SET archived=$2,archived_at=CASE WHEN $2 THEN COALESCE(archived_at,now()) ELSE NULL END,revision=revision+1,updated_at=now() WHERE id=$1 RETURNING *`,[id,archived]); if(old.post_id)await client.query('UPDATE posts SET archived=$2,archived_at=CASE WHEN $2 THEN COALESCE(archived_at,now()) ELSE NULL END,updated_at=now() WHERE id=$1',[old.post_id,archived]); return designOutput(result.rows[0]); }); }