147 lines
7.5 KiB
Python
147 lines
7.5 KiB
Python
|
|
"""Isolated page-design drafts with optimistic published-content protection.
|
||
|
|
|
||
|
|
Existing /blueprint endpoints remain as legacy API compatibility. The V8.6
|
||
|
|
studio uses only /studio-draft endpoints; a build never consumes these drafts.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
import hashlib
|
||
|
|
import json
|
||
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||
|
|
from . import db, blueprints, enterprise
|
||
|
|
from .core import save_entry
|
||
|
|
|
||
|
|
router = APIRouter(prefix='/api/sites', tags=['V8.6 draft studio'])
|
||
|
|
|
||
|
|
|
||
|
|
def setup():
|
||
|
|
with db.connect() as c:
|
||
|
|
c.execute('''CREATE TABLE IF NOT EXISTS studio_drafts (
|
||
|
|
site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
|
||
|
|
revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
|
||
|
|
blueprint_json TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||
|
|
PRIMARY KEY (site_id,entry_id))''')
|
||
|
|
c.execute('''CREATE TABLE IF NOT EXISTS studio_publications (
|
||
|
|
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
|
||
|
|
revision INTEGER NOT NULL, actor TEXT NOT NULL, published_at TEXT NOT NULL,
|
||
|
|
blueprint_hash TEXT NOT NULL)''')
|
||
|
|
|
||
|
|
|
||
|
|
def _entry(site_id, entry_id):
|
||
|
|
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
|
||
|
|
if not row:raise HTTPException(404,'页面不存在')
|
||
|
|
return row
|
||
|
|
|
||
|
|
|
||
|
|
def _hash(row):
|
||
|
|
fields=('meta_json','title','route','status','body','summary','updated_at')
|
||
|
|
return hashlib.sha256(json.dumps([row.get(x) for x in fields],ensure_ascii=False).encode()).hexdigest()
|
||
|
|
|
||
|
|
|
||
|
|
def _validate(payload):
|
||
|
|
if not isinstance(payload,dict):raise HTTPException(400,'蓝图应为 JSON 对象')
|
||
|
|
try:blocks=blueprints.validate_blocks(payload.get('blocks',[]))
|
||
|
|
except (ValueError,TypeError) as e:raise HTTPException(400,str(e))
|
||
|
|
kind=payload.get('page_template','standard')
|
||
|
|
mode=payload.get('layout_mode','append')
|
||
|
|
if kind not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'未知页面类型')
|
||
|
|
if mode not in ('append','replace'):raise HTTPException(400,'未知排版模式')
|
||
|
|
return {'blocks':blocks,'page_template':kind,'layout_mode':mode}
|
||
|
|
|
||
|
|
|
||
|
|
def read(site_id,entry_id):
|
||
|
|
row=_entry(site_id,entry_id)
|
||
|
|
draft=db.one('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
|
||
|
|
if draft:
|
||
|
|
return {**json.loads(draft['blueprint_json']),'revision':draft['revision'],
|
||
|
|
'conflict':draft['base_hash']!=_hash(row),'state':'draft', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
|
||
|
|
meta=json.loads(row['meta_json'] or '{}')
|
||
|
|
return {'blocks':meta.get('blocks',[]),'page_template':meta.get('page_template','home' if row['route']=='/' else 'standard'),
|
||
|
|
'layout_mode':meta.get('layout_mode','append'),'revision':0,'conflict':False,'state':'published', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
|
||
|
|
|
||
|
|
|
||
|
|
def write(site_id,entry_id,payload):
|
||
|
|
row=_entry(site_id,entry_id)
|
||
|
|
plan=_validate(payload)
|
||
|
|
# A draft is detached from the public entry. Only the final publish
|
||
|
|
# operation can modify entries, after checking its base fingerprint.
|
||
|
|
with db.connect() as c:
|
||
|
|
old=c.execute('SELECT revision,base_hash FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id)).fetchone()
|
||
|
|
current=int(old[0]) if old else 0
|
||
|
|
expected=payload.get('expected_revision',0)
|
||
|
|
if type(expected) is not int or expected!=current:raise HTTPException(409,'草稿已由另一操作修改,请重新加载')
|
||
|
|
if old and old[1]!=_hash(row):raise HTTPException(409,'原页面发生变化,不能继续覆盖,请复核')
|
||
|
|
next_rev=current+1
|
||
|
|
base=old[1] if old else _hash(row)
|
||
|
|
c.execute('''INSERT INTO studio_drafts VALUES (?,?,?,?,?,?)
|
||
|
|
ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision,
|
||
|
|
blueprint_json=excluded.blueprint_json,updated_at=excluded.updated_at''',
|
||
|
|
(site_id,entry_id,next_rev,base,json.dumps(plan,ensure_ascii=False),db.now()))
|
||
|
|
return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True}
|
||
|
|
|
||
|
|
|
||
|
|
def publish(site_id,entry_id,expected_revision,actor):
|
||
|
|
row=_entry(site_id,entry_id)
|
||
|
|
draft=db.one('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
|
||
|
|
if not draft:raise HTTPException(409,'没有待发布的设计草稿')
|
||
|
|
if expected_revision!=draft['revision']:raise HTTPException(409,'草稿版本已经改变')
|
||
|
|
if draft['base_hash']!=_hash(row):raise HTTPException(409,'原页面已变更,请先处理冲突')
|
||
|
|
plan=_validate(json.loads(draft['blueprint_json']))
|
||
|
|
meta=json.loads(row['meta_json'] or '{}')
|
||
|
|
meta.update(plan)
|
||
|
|
from .core import entry_dict
|
||
|
|
current=entry_dict(row)
|
||
|
|
# Atomic update + publication journal + removal of draft.
|
||
|
|
import uuid
|
||
|
|
with db.connect() as c:
|
||
|
|
fresh=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id)).fetchone()
|
||
|
|
if not fresh or _hash(dict(fresh))!=draft['base_hash']:
|
||
|
|
raise HTTPException(409,'原页面已在其他窗口更新,请重新加载')
|
||
|
|
c.execute('UPDATE entries SET meta_json=?,updated_at=? WHERE site_id=? AND id=?',
|
||
|
|
(json.dumps(meta,ensure_ascii=False),db.now(),site_id,entry_id))
|
||
|
|
pid=uuid.uuid4().hex
|
||
|
|
c.execute('INSERT INTO studio_publications VALUES (?,?,?,?,?,?,?)',
|
||
|
|
(pid,site_id,entry_id,draft['revision'],actor,db.now(),hashlib.sha256(json.dumps(plan,sort_keys=True).encode()).hexdigest()))
|
||
|
|
c.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
|
||
|
|
return {'published':True,'publication_id':pid,'revision':draft['revision'],
|
||
|
|
'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/{site_id}/pages/{entry_id}/studio-draft')
|
||
|
|
def api_read(request:Request,site_id:str,entry_id:str):
|
||
|
|
enterprise.require(request,site_id,'viewer')
|
||
|
|
return read(site_id,entry_id)
|
||
|
|
|
||
|
|
|
||
|
|
@router.put('/{site_id}/pages/{entry_id}/studio-draft')
|
||
|
|
def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
|
||
|
|
enterprise.require(request,site_id,'editor')
|
||
|
|
return write(site_id,entry_id,payload)
|
||
|
|
|
||
|
|
|
||
|
|
@router.post('/{site_id}/pages/{entry_id}/studio-draft/publish')
|
||
|
|
def api_publish(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
|
||
|
|
enterprise.require(request,site_id,'owner')
|
||
|
|
rev=payload.get('expected_revision')
|
||
|
|
if type(rev) is not int:raise HTTPException(400,'需要草稿版本号')
|
||
|
|
return publish(site_id,entry_id,rev,enterprise.actor(request))
|
||
|
|
|
||
|
|
|
||
|
|
@router.delete('/{site_id}/pages/{entry_id}/studio-draft')
|
||
|
|
def api_discard(request:Request,site_id:str,entry_id:str):
|
||
|
|
enterprise.require(request,site_id,'editor')
|
||
|
|
_entry(site_id,entry_id)
|
||
|
|
db.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
|
||
|
|
return {'discarded':True}
|
||
|
|
|
||
|
|
|
||
|
|
@router.get('/{site_id}/pages/{entry_id}/studio-draft/preset')
|
||
|
|
def api_draft_preset(request:Request,site_id:str,entry_id:str,page_template:str='standard'):
|
||
|
|
from . import theme_store
|
||
|
|
enterprise.require(request,site_id,'viewer')
|
||
|
|
_entry(site_id,entry_id)
|
||
|
|
if page_template not in blueprints.PAGE_TEMPLATES:raise HTTPException(400,'无效模板类型')
|
||
|
|
site=db.one('SELECT theme FROM sites WHERE id=?',(site_id,))
|
||
|
|
pinned=theme_store.pinned(site_id)
|
||
|
|
return {'blocks':blueprints.theme_blocks(site['theme'],page_template,(pinned or {}).get('version')),
|
||
|
|
'page_template':page_template,'note':'仅生成草稿预设,不改变已发布内容'}
|