Files
probably-rendering-one-more…/apps/waterfall-wiki/src/lib/studio-designs.ts
T
codex-bot 174a3ef266
studio CI and deployment / verify-and-deploy (pull_request) Failing after 3m51s
waterfall-wiki CI and deployment / verify-and-deploy (pull_request) Failing after 2m58s
Add versioned multi-page Studio artwork documents and native exports
2026-10-28 23:36:54 +08:00

55 lines
4.6 KiB
TypeScript

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<string,unknown>){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<typeof designInput>){
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<typeof updateDesignInput>){
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]);
});
}