From 3069a797b4accca9cb76f3b1f45265e9cb3d1f68 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Tue, 27 Oct 2026 17:37:22 +0800 Subject: [PATCH] Publish saved Studio pages as atomic Wiki posts and document agent editing --- .../db/010_studio_design_posts.sql | 12 +++++ .../src/lib/studio-design-documentation.ts | 45 ++++++++++++++----- .../src/lib/studio-design-posts.ts | 25 +++++++++++ apps/waterfall-wiki/src/lib/studio-designs.ts | 36 +++++++++++++-- apps/waterfall-wiki/src/pages/api/index.ts | 1 + .../src/pages/api/studio/designs/[id].ts | 12 +++-- .../src/pages/api/studio/designs/[id]/post.ts | 10 +++++ .../tests/api-documentation.test.ts | 11 ++++- apps/waterfall-wiki/tests/integration.test.ts | 29 +++++++++++- 9 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 apps/waterfall-wiki/db/010_studio_design_posts.sql create mode 100644 apps/waterfall-wiki/src/lib/studio-design-posts.ts create mode 100644 apps/waterfall-wiki/src/pages/api/studio/designs/[id]/post.ts diff --git a/apps/waterfall-wiki/db/010_studio_design_posts.sql b/apps/waterfall-wiki/db/010_studio_design_posts.sql new file mode 100644 index 0000000..caa44a7 --- /dev/null +++ b/apps/waterfall-wiki/db/010_studio_design_posts.sql @@ -0,0 +1,12 @@ +BEGIN; +ALTER TABLE studio_designs ADD COLUMN IF NOT EXISTS post_id uuid REFERENCES posts(id) ON DELETE RESTRICT; +CREATE UNIQUE INDEX IF NOT EXISTS studio_designs_post ON studio_designs(post_id) WHERE post_id IS NOT NULL; +-- Adopt explicitly published entries, including the user's page repaired before deployment. +UPDATE studio_designs d SET post_id=p.id +FROM posts p +WHERE d.post_id IS NULL AND p.slug='studio-design-'||d.id::text +AND EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id + WHERE pa.post_id=p.id AND a.kind='text' + AND strpos(a.body,'/studio/layouts/'||d.layout_id||'?id='||d.id::text)>0); +INSERT INTO schema_migrations(version) VALUES('010_studio_design_posts') ON CONFLICT DO NOTHING; +COMMIT; diff --git a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts index f49626e..44e1a28 100644 --- a/apps/waterfall-wiki/src/lib/studio-design-documentation.ts +++ b/apps/waterfall-wiki/src/lib/studio-design-documentation.ts @@ -1,13 +1,36 @@ import type {NextApiRequest,NextApiResponse} from 'next'; -export const designDocs=`# Studio 效果保存 API(与 Wiki 共用 PostgreSQL) -需要现有 PROMPT 登录 Cookie 或 Bearer JWT;浏览器修改请求需要同源 Origin。 -GET /api/studio/designs?layoutId=ref-01&limit=20:列举,nextCursor 用 before 继续。 -POST /api/studio/designs:保存新效果,201 {design};每次生成独立 UUID。 -请求:{"layoutId":"ref-01","name":"第一版","parameters":{"texts":{"T01":{"value":"STUDY"}},"images":{"I01":{"color":"#788880"}},"decorations":{}}} -GET /api/studio/designs/{id}:读取 {design},包含 layoutId/name/parameters/revision/url/renderUrl。 -PATCH /api/studio/designs/{id}:{name,parameters,revision},参数整体替换,layoutId 不变;revision 冲突 409。 -效果页 /studio/designs/{id};27 套模板 /studio/layouts/ref-01 .. ref-27。 -参数与渲染契约:GET /studio/api/layouts/ref-01?agent_mode=True;POST 同地址返回 SVG(无保存副作用)。 -先渲染预览,确认后保存,凭返回 id 再次打开或更新。不同方案用 POST 保存新 ID,更新同一方案使用 PATCH。 -错误:400 参数、构件、图片 ID 不合法;401 未登录;404 效果不存在;409 修订冲突;413 请求过大。`; +export const designDocs=`# Studio 页面与 Wiki post API +所有相对地址均位于当前站点。需要现有 PROMPT 登录 Cookie 或 Authorization: Bearer ;浏览器写请求必须携带同源 Origin。JWT 不能放 URL,也不能写入示例或公开日志。 + +## 发现与读取 +GET /studio/api/layouts:27 套布局目录。 +GET /studio/api/layouts/ref-01:元素 ID、类型、位置与参数契约(T 为文字、I 为图片、D 为装饰)。 +GET /api/studio/designs?layoutId=ref-01&limit=20:列举保存页面,nextCursor 用 before 继续。 +GET /api/studio/designs/{id}:读取 {design}。 +design 含 id/layoutId/name/parameters/revision/url/schemaUrl/postId/postUrl/createdAt/updatedAt。 +url 是确定的原生页面 /studio/layouts/{layoutId}?id={id};postUrl 是 Wiki 入口 /explore/{postId}。 +不带 ID 的 /studio/layouts/ref-01 .. ref-27 展示原生默认排版;ID 不存在或与布局不匹配返回404。旧 /studio/designs/{id} 自动跳转。 + +## 创建与编辑(用户和 agent 操作同一条记录) +POST /api/studio/designs:{layoutId,name,parameters},201 {design}。 +每次创建独立页面 ID,并在同一数据库事务内自动创建一篇 Wiki post;失败整体回滚,不产生孤立页面。 +示例:{"layoutId":"ref-01","name":"第一版","parameters":{"texts":{"T01":{"value":"STUDY"}},"images":{"I01":{"color":"#788880"}},"decorations":{}}} +PATCH /api/studio/designs/{id}:{name,parameters,revision},200 {design}。 +parameters 是整体替换,不是局部合并。agent 应先 GET,保留未修改的参数,再改目标字段后 PATCH。revision 必须为刚读到的版本;冲突409时重新读取合并,不能盲目重试旧版本。 +更新同一页面复用原 Wiki post,不重复发帖;另存副本使用 POST 生成新页面和新 post。没有手动编辑过的 Wiki post 会跟随页面名称更新;手动编辑过的 Wiki 笔记保留正文、标题、标签和封面。 +POST /api/studio/designs/{id}/post:为旧保存页面补建 Wiki 入口,201 {design,created:true};已有入口返回200 {design,created:false}。可重复调用,不改变页面 revision。GET 不产生写入。 + +## 图片与参数 +通过 POST /api/upload(multipart/form-data,kind=image、assetOnly=true、files=文件)上传图片,取返回 assets[0] 作为 assetId。上传只建素材;保存页面时才创建 Wiki post。 +参数形状:{texts:{T01:{value:"标题",color:"#26362f",scale:1,align:"start"}},images:{I01:{color:"#788880",assetId:"已上传图片UUID",fit:"cover"}},decorations:{D01:{color:"#53645b",hidden:false}},paper:"#f4f2eb"}。全部可省略,具体元素必须来自当前布局。 +value 最多500字/8行;scale 0.25–2;align start/middle/end;fit cover/contain;颜色 #RRGGBB。最大请求64KB。不接受任意图片 URL 或注入的 HTML/SVG。 + +## agent 最短工作流 +1. GET /api/studio/designs/{id},读取 design。 +2. 修改 design.parameters.texts.T01.value(先用 schemaUrl 确认元素存在)。 +3. PATCH /api/studio/designs/{id},body 只发 {name:design.name,parameters:design.parameters,revision:design.revision},不能把返回的整个 design 对象直接提交。 +4. 读取返回的新 revision、url 和 postUrl;打开 url 检查实际页面。 + +兼容导出:renderUrl / legacySvgUrl 仍是旧的原图图层 SVG 导出,不是原生页面截图;不可把它当作原生页面的所见即所得证明。保存页面使用 POST/PATCH designs,无需先调用 SVG 渲染。 +错误:400 参数、构件、图片 ID 不合法;401 未登录;404 页面不存在;409 修订冲突;413 请求过大。`; export function studioDesignDocumentation(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||'')||String(req.query.agent_mode).toLowerCase()!=='true')return false;res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');if(req.method==='HEAD')res.status(200).end();else res.status(200).send(designDocs);return true} diff --git a/apps/waterfall-wiki/src/lib/studio-design-posts.ts b/apps/waterfall-wiki/src/lib/studio-design-posts.ts new file mode 100644 index 0000000..6ce8333 --- /dev/null +++ b/apps/waterfall-wiki/src/lib/studio-design-posts.ts @@ -0,0 +1,25 @@ +import {randomUUID} from 'node:crypto'; +import type {PoolClient} from 'pg'; +import type {Parameters} from './layout-parameters'; + +export type DesignRow={id:string;layout_id:string;name:string;parameters:Parameters;revision:number;post_id:string|null;created_at:Date;updated_at:Date}; +export const studioPageUrl=(design:Pick)=>`/studio/layouts/${design.layout_id}?id=${design.id}`; +function postDescription(design:DesignRow){return `已保存的 ${design.layout_id} 排版页面,可直接打开并继续编辑。`} +/** Caller holds the design row lock. All writes share the design's transaction. */ +export async function ensureDesignPost(client:PoolClient,design:DesignRow):Promise{ + if(design.post_id){ + // A manually edited Wiki note keeps its own title, body, tags and cover. + await client.query('UPDATE posts SET title=$2,description=$3,updated_at=now() WHERE id=$1 AND edited_at IS NULL',[design.post_id,design.name,postDescription(design)]); + return design; + } + const postId=randomUUID(),textId=randomUUID(),imageIds=[...new Set(Object.values(design.parameters.images||{}).flatMap(image=>image.assetId?[image.assetId]:[]))]; + const images=imageIds.length?(await client.query("SELECT id FROM assets WHERE id=ANY($1::uuid[]) AND kind='image'",[imageIds])).rows.map(row=>row.id as string):[]; + const body=`[打开页面并继续编辑](${studioPageUrl(design)})\n\n排版:${design.layout_id}\n\n页面 ID:\`${design.id}\`\n\n此笔记关联独立保存的页面;页面内容以链接中的保存记录为准。`; + await client.query("INSERT INTO posts(id,slug,kind,title,description,cover_asset_id) VALUES($1,$2,'article',$3,$4,$5)",[postId,'studio-design-'+design.id,design.name,postDescription(design),images[0]||null]); + await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,body]); + await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'caption')",[postId,textId]); + for(const [index,imageId] of images.entries())await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'content')",[postId,imageId,index+1]); + await client.query("INSERT INTO post_tags(post_id,tag_id) SELECT $1,id FROM tags WHERE name='高级感排版' AND kind='normal' ON CONFLICT DO NOTHING",[postId]); + await client.query('UPDATE studio_designs SET post_id=$2 WHERE id=$1',[design.id,postId]); + return {...design,post_id:postId}; +} diff --git a/apps/waterfall-wiki/src/lib/studio-designs.ts b/apps/waterfall-wiki/src/lib/studio-designs.ts index d0904ea..fde59fc 100644 --- a/apps/waterfall-wiki/src/lib/studio-designs.ts +++ b/apps/waterfall-wiki/src/lib/studio-designs.ts @@ -1,10 +1,38 @@ import {randomUUID} from 'node:crypto'; import {z} from 'zod'; -import {db} from './db'; +import type {PoolClient} from 'pg'; +import {db,transaction} from './db'; import {parameters,LayoutParameterError} from './layout-parameters'; import layouts from '../content/studio-layouts.json'; +import {ensureDesignPost,studioPageUrl,type DesignRow} from './studio-design-posts'; export const designInput=z.object({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 function designOutput(r:Record){return {id:r.id,layoutId:r.layout_id,name:r.name,parameters:r.parameters,revision:r.revision,createdAt:r.created_at,updatedAt:r.updated_at,url:'/studio/designs/'+r.id,renderUrl:'/studio/api/layouts/'+r.layout_id};} -export async function validateDesign(layoutId:string,raw:unknown){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 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);const r=await db().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(r.rows[0]);} +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,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:'/studio/api/layouts/'+r.layout_id,legacySvgUrl:'/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.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}; + }); +} diff --git a/apps/waterfall-wiki/src/pages/api/index.ts b/apps/waterfall-wiki/src/pages/api/index.ts index 10424c3..652fbdc 100644 --- a/apps/waterfall-wiki/src/pages/api/index.ts +++ b/apps/waterfall-wiki/src/pages/api/index.ts @@ -8,6 +8,7 @@ export default function handler(req:NextApiRequest,res:NextApiResponse){ {path:'/studio/api/layouts/{id}',title:'排版参数与 SVG 渲染',methods:'GET, POST',documentation:'/studio/api/layouts/{id}?agent_mode=True'}, {path:'/api/studio/designs',title:'保存与列举 Studio 效果',methods:'GET, POST',documentation:'/api/studio/designs?agent_mode=True'}, {path:'/api/studio/designs/{id}',title:'读取与更新效果 ID',methods:'GET, PATCH',documentation:'/api/studio/designs/{id}?agent_mode=True'}, + {path:'/api/studio/designs/{id}/post',title:'为旧页面补建 Wiki 入口',methods:'POST',documentation:'/api/studio/designs/{id}/post?agent_mode=True'}, {path:'/studio/api/health',title:'创作站健康检查',methods:'GET, HEAD',documentation:'/studio/api/health?agent_mode=True'}]; res.setHeader('Cache-Control','no-store'); const plain=typeof req.query.agent_mode==='string'&&req.query.agent_mode.toLowerCase()==='true'; diff --git a/apps/waterfall-wiki/src/pages/api/studio/designs/[id].ts b/apps/waterfall-wiki/src/pages/api/studio/designs/[id].ts index 9c9e351..0823373 100644 --- a/apps/waterfall-wiki/src/pages/api/studio/designs/[id].ts +++ b/apps/waterfall-wiki/src/pages/api/studio/designs/[id].ts @@ -2,13 +2,17 @@ import type {NextApiRequest,NextApiResponse} from 'next'; import {z} from 'zod'; import {db} from '@/lib/db'; import {sameOrigin,apiError} from '@/lib/api'; -import {designOutput,updateDesignInput,validateDesign} from '@/lib/studio-designs'; +import {designOutput,updateDesignInput,updateDesign,DesignConflict} from '@/lib/studio-designs'; import {LayoutParameterError} from '@/lib/layout-parameters'; import {studioDesignDocumentation} from '@/lib/studio-design-documentation'; export default async function handler(req:NextApiRequest,res:NextApiResponse){ if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store'); - try{if(!['GET','PATCH'].includes(req.method||'')){res.setHeader('Allow','GET, PATCH');return res.status(405).end()}const id=z.uuid().parse(req.query.id),old=(await db().query('SELECT * FROM studio_designs WHERE id=$1',[id])).rows[0];if(!old)return res.status(404).json({error:'效果不存在'}); - if(req.method==='GET')return res.json({design:designOutput(old)});sameOrigin(req);const input=updateDesignInput.parse(req.body),p=await validateDesign(old.layout_id,input.parameters),r=await db().query('UPDATE studio_designs SET name=$2,parameters=$3,revision=revision+1,updated_at=now() WHERE id=$1 AND revision=$4 RETURNING *',[id,input.name,JSON.stringify(p),input.revision]);if(!r.rowCount)return res.status(409).json({error:'效果已被修改,请重新读取后再保存'});return res.json({design:designOutput(r.rows[0])}); - }catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});return apiError(res,e)} + try{ + if(!['GET','PATCH'].includes(req.method||'')){res.setHeader('Allow','GET, PATCH');return res.status(405).end()} + const id=z.uuid().parse(req.query.id); + if(req.method==='GET'){const row=(await db().query('SELECT * FROM studio_designs WHERE id=$1',[id])).rows[0];return row?res.json({design:designOutput(row)}):res.status(404).json({error:'效果不存在'})} + sameOrigin(req);const design=await updateDesign(id,updateDesignInput.parse(req.body)); + return design?res.json({design}):res.status(404).json({error:'效果不存在'}); + }catch(e){if(e instanceof LayoutParameterError)return res.status(400).json({error:e.message});if(e instanceof DesignConflict)return res.status(409).json({error:e.message});return apiError(res,e)} } export const config={api:{bodyParser:{sizeLimit:'64kb'}}}; diff --git a/apps/waterfall-wiki/src/pages/api/studio/designs/[id]/post.ts b/apps/waterfall-wiki/src/pages/api/studio/designs/[id]/post.ts new file mode 100644 index 0000000..2e76d0e --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/studio/designs/[id]/post.ts @@ -0,0 +1,10 @@ +import type {NextApiRequest,NextApiResponse} from 'next'; +import {z} from 'zod'; +import {sameOrigin,apiError} from '@/lib/api'; +import {publishDesignPost} from '@/lib/studio-designs'; +import {studioDesignDocumentation} from '@/lib/studio-design-documentation'; +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + if(studioDesignDocumentation(req,res))return;res.setHeader('Cache-Control','no-store'); + if(req.method!=='POST'){res.setHeader('Allow','POST');return res.status(405).end()} + try{sameOrigin(req);const result=await publishDesignPost(z.uuid().parse(req.query.id));return result?res.status(result.created?201:200).json(result):res.status(404).json({error:'效果不存在'})}catch(e){return apiError(res,e)} +} diff --git a/apps/waterfall-wiki/tests/api-documentation.test.ts b/apps/waterfall-wiki/tests/api-documentation.test.ts index 9acb48e..eb6151f 100644 --- a/apps/waterfall-wiki/tests/api-documentation.test.ts +++ b/apps/waterfall-wiki/tests/api-documentation.test.ts @@ -44,7 +44,7 @@ test('normal calls and mutations do not enter documentation mode',()=>{ test('index exposes every business endpoint and its text URL',()=>{ const {state,res}=response();index(request('GET','True'),res); - for(const path of [...Object.keys(apiDocuments),'/studio/api/export/{slug}','/studio/api/health'])assert.ok(String(state.body).includes(path+'?agent_mode=True')); + for(const path of [...Object.keys(apiDocuments),'/studio/api/export/{slug}','/studio/api/health','/api/studio/designs','/api/studio/designs/{id}','/api/studio/designs/{id}/post'])assert.ok(String(state.body).includes(path+'?agent_mode=True')); }); test('health keeps its regular JSON response',async()=>{ @@ -53,3 +53,12 @@ test('health keeps its regular JSON response',async()=>{ assert.equal(state.status,200);assert.equal((state.body as {status:string}).status,'ok'); }); + + +test('Studio docs describe native editing and atomic Wiki publication without accessing storage',async()=>{ + for(const route of ['studio/designs/index','studio/designs/[id]','studio/designs/[id]/post']){ + const {default:handler}=await import('../src/pages/api/'+route+'.ts'); + const {state,res}=response();await handler(request('GET','True'),res); + assert.equal(state.status,200);assert.match(String(state.body),/postUrl/);assert.match(String(state.body),/整体替换/);assert.match(String(state.body),/同一数据库事务/);assert.match(String(state.body),/原生页面/); + } +}); diff --git a/apps/waterfall-wiki/tests/integration.test.ts b/apps/waterfall-wiki/tests/integration.test.ts index 5afa168..42ae7db 100644 --- a/apps/waterfall-wiki/tests/integration.test.ts +++ b/apps/waterfall-wiki/tests/integration.test.ts @@ -59,17 +59,42 @@ test('real PostgreSQL + S3 notes, flat tags, resource sharing and video processi for(const removed of ['/library/assets','/library/tags'])assert.equal((await fetch(base+removed)).status,404); assert.equal((await db().query("SELECT 1 FROM schema_migrations WHERE version='009_studio_designs'")).rowCount,1,'Studio migration must be recorded for repeat deployments'); // Studio owns no second database: designs use this same isolated PostgreSQL. - for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});} + for(let i=1;i<=27;i++){const layoutId='ref-'+String(i).padStart(2,'0'),saved=await request('/api/studio/designs',{layoutId,name:'布局 '+i,parameters:{}});assert.equal(saved.design.layoutId,layoutId);assert.equal(saved.design.revision,1);assert.ok(saved.design.postId);assert.equal(saved.design.postUrl,'/explore/'+saved.design.postId);assert.equal(saved.design.url,'/studio/layouts/'+layoutId+'?id='+saved.design.id);const wiki=await request('/api/notes/'+saved.design.postId);assert.ok(wiki.entry.editableMarkdown.includes(saved.design.url));const read=await request('/api/studio/designs/'+saved.design.id);assert.deepEqual(read.design.parameters,{texts:{},images:{},decorations:{}});} const design=await request('/api/studio/designs',{layoutId:'ref-01',name:'可修改效果',parameters:{texts:{T01:{value:'新的标题'}},images:{I01:{assetId:first.assets[0],fit:'contain'}}}}); const designEdit={name:'更新效果',parameters:{texts:{T01:{value:'修改后'}}},revision:design.design.revision}; const patches=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(designEdit)})));assert.deepEqual(patches.map(r=>r.status).sort(),[200,409]); const designRead=await request('/api/studio/designs/'+design.design.id);assert.equal(designRead.design.revision,2);assert.equal(designRead.design.parameters.texts.T01.value,'修改后'); + assert.equal(designRead.design.postId,design.design.postId,'Updating a page reuses its Wiki post'); + const linkedWiki=await request('/api/notes/'+design.design.postId);assert.equal(linkedWiki.entry.title,'更新效果'); + assert.equal(linkedWiki.entry.coverAssetId,first.assets[0],'An uploaded page image supplies the initial cover'); + const repeated=await Promise.all([1,2].map(()=>request('/api/studio/designs/'+design.design.id+'/post',{}))); + assert.ok(repeated.every(r=>r.created===false&&r.design.postId===design.design.postId&&r.design.revision===2)); + const manualBody=linkedWiki.entry.editableMarkdown+'\n\n我的补充说明'; + assert.equal((await fetch(base+'/api/notes/'+design.design.postId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'我的页面笔记',description:'独立的笔记说明',markdown:manualBody,tags:[],revision:linkedWiki.entry.revision})})).status,200); + const nextDesign=await fetch(base+'/api/studio/designs/'+design.design.id,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({...designEdit,name:'再改页面名称',revision:2})});assert.equal(nextDesign.status,200); + const retainedWiki=await request('/api/notes/'+design.design.postId);assert.equal(retainedWiki.entry.title,'我的页面笔记');assert.equal(retainedWiki.entry.editableMarkdown,manualBody); + const legacyId=randomUUID();await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','旧保存页面',$2)",[legacyId,JSON.stringify({texts:{},images:{},decorations:{}})]); + const repaired=await Promise.all([1,2].map(()=>fetch(base+'/api/studio/designs/'+legacyId+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}))); + assert.deepEqual(repaired.map(r=>r.status).sort(),[200,201]);const repairs=await Promise.all(repaired.map(r=>r.json()));assert.equal(repairs[0].design.postId,repairs[1].design.postId);assert.equal(repairs[0].design.revision,1); + assert.equal((await db().query("SELECT count(*)::integer AS n FROM posts WHERE slug=$1",['studio-design-'+legacyId])).rows[0].n,1); + assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID()+'/post',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'})).status,404); + // A post failure must roll back the design and text assets as one unit. + const beforeFailure=(await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0]; + await db().query(`CREATE FUNCTION reject_studio_post_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.title='atomic-studio-post-test' THEN RAISE EXCEPTION 'test publication failure'; END IF; RETURN NEW; END $$; CREATE TRIGGER reject_studio_post_test BEFORE INSERT ON posts FOR EACH ROW EXECUTE FUNCTION reject_studio_post_test()`); + try{assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:'ref-01',name:'atomic-studio-post-test',parameters:{}})})).status,500)}finally{await db().query('DROP TRIGGER reject_studio_post_test ON posts; DROP FUNCTION reject_studio_post_test()')} + assert.deepEqual((await db().query('SELECT (SELECT count(*) FROM studio_designs)::integer AS designs,(SELECT count(*) FROM posts)::integer AS posts,(SELECT count(*) FROM assets)::integer AS assets')).rows[0],beforeFailure); + // The migration adopts an existing manual post instead of publishing a duplicate. + const adoptedId=randomUUID(),adoptedUrl='/studio/layouts/ref-01?id='+adoptedId,adoptedPost=await request('/api/posts',{kind:'article',title:'已补录的保存页',slug:'studio-design-'+adoptedId,text:'[打开页面]('+adoptedUrl+')'}); + await db().query("INSERT INTO studio_designs(id,layout_id,name,parameters) VALUES($1,'ref-01','已补录的保存页',$2)",[adoptedId,JSON.stringify({texts:{},images:{},decorations:{}})]); + await db().query(await readFile(new URL('../db/010_studio_design_posts.sql',import.meta.url),'utf8')); + const adopted=await request('/api/studio/designs/'+adoptedId);assert.equal(adopted.design.postId,adoptedPost.id);assert.equal((await request('/api/studio/designs/'+adoptedId+'/post',{})).created,false); + const designPage=await request('/api/studio/designs?layoutId=ref-01&limit=1');assert.equal(designPage.designs.length,1);assert.ok(designPage.nextCursor);assert.equal((await request('/api/studio/designs?layoutId=ref-01&limit=1&before='+designPage.nextCursor)).designs.length,1); for(const body of [{layoutId:'ref-28',name:'bad',parameters:{}},{layoutId:'ref-01',name:'bad',parameters:{texts:{I01:{value:'wrong kind'}}}},{layoutId:'ref-01',name:'bad',parameters:{images:{I01:{assetId:randomUUID()}}}}])assert.equal((await fetch(base+'/api/studio/designs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).status,400); assert.equal((await fetch(base+'/api/studio/designs/'+randomUUID())).status,404); assert.ok((await fetch(base+'/api/studio/designs?agent_mode=True').then(r=>r.text())).includes('revision')); const published=await publishLearningSeries();await publishLearningSeries(); - const articles=(await request('/api/feed?section=articles')).posts; + const articles=(await request('/api/feed?section=articles&path=learning-skills')).posts; assert.equal(articles.length,3,'Repeated publication must not duplicate posts'); assert.equal(new Set(articles.map((p:{cover_asset_id:string})=>p.cover_asset_id)).size,1,'Three posts share one independent image asset'); assert.equal((await db().query('SELECT count(*)::integer AS n FROM post_assets WHERE asset_id=$1',[published.coverId])).rows[0].n,3);