Publish saved Studio pages as atomic Wiki posts and document agent editing
This commit is contained in:
@@ -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;
|
||||
@@ -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 <JWT>;浏览器写请求必须携带同源 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}
|
||||
|
||||
@@ -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<DesignRow,'id'|'layout_id'>)=>`/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<DesignRow>{
|
||||
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};
|
||||
}
|
||||
@@ -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<string,unknown>){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<typeof designInput>){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<string,unknown>){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<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.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};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'}}};
|
||||
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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),/原生页面/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user