"""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 import uuid 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)''') c.execute('''CREATE TABLE IF NOT EXISTS studio_history ( id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, revision INTEGER NOT NULL,kind TEXT NOT NULL, snapshot_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL, actor TEXT NOT NULL,created_at TEXT NOT NULL)''') c.execute('''CREATE INDEX IF NOT EXISTS idx_studio_history_site_page ON studio_history(site_id,entry_id,created_at,revision)''') 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,actor="system"): 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())) _journal(c,site_id,entry_id,next_rev,'draft',plan,actor) 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())) _journal(c,site_id,entry_id,draft['revision'],'published',plan,actor) 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':'页面设计已发布到内容库;需要重新构建静态站点才会上线'} def _journal(connection, site_id, entry_id, revision, kind, plan, actor): # Store a complete, immutable validated snapshot; never executable user code. canonical=json.dumps(plan,ensure_ascii=False,sort_keys=True,separators=(',',':')) connection.execute('''INSERT INTO studio_history VALUES (?,?,?,?,?,?,?,?,?)''', (uuid.uuid4().hex,site_id,entry_id,revision,kind,canonical, hashlib.sha256(canonical.encode('utf-8')).hexdigest(),actor,db.now())) def history(site_id,entry_id,limit=30): _entry(site_id,entry_id) if type(limit) is not int or not 1<=limit<=100:raise HTTPException(400,'历史条数必须为1-100') rows=db.rows('''SELECT id,revision,kind,snapshot_hash,actor,created_at FROM studio_history WHERE site_id=? AND entry_id=? ORDER BY created_at DESC, revision DESC, id DESC LIMIT ?''',(site_id,entry_id,limit)) return {'items':rows,'note':'历史记录不会直接修改已发布页面;恢复时只生成新的草稿修订'} def history_snapshot(site_id,entry_id,history_id): _entry(site_id,entry_id) row=db.one('SELECT * FROM studio_history WHERE site_id=? AND entry_id=? AND id=?', (site_id,entry_id,history_id)) if not row:raise HTTPException(404,'历史记录不存在') if hashlib.sha256(row['snapshot_json'].encode('utf-8')).hexdigest()!=row['snapshot_hash']: raise HTTPException(409,'历史快照完整性校验失败,不允许恢复') plan=_validate(json.loads(row['snapshot_json'])) return {'id':row['id'],'revision':row['revision'],'kind':row['kind'], 'snapshot_hash':row['snapshot_hash'],'plan':plan} def restore_history(site_id,entry_id,history_id,expected_revision,actor): if type(expected_revision) is not int:raise HTTPException(400,'必须指定当前草稿修订号') previous=history_snapshot(site_id,entry_id,history_id) # Optimistic revision is enforced by write(). Restore never directly alters # published content and cannot bypass a conflicting published page. plan={**previous['plan'],'expected_revision':expected_revision} result=write(site_id,entry_id,plan,actor) return {**result,'restored_from':history_id,'requires_publish':True} @router.get('/{site_id}/pages/{entry_id}/studio-history') def api_history(request:Request,site_id:str,entry_id:str,limit:int=30): enterprise.require(request,site_id,'viewer') return history(site_id,entry_id,limit) @router.get('/{site_id}/pages/{entry_id}/studio-history/{history_id}') def api_history_snapshot(request:Request,site_id:str,entry_id:str,history_id:str): enterprise.require(request,site_id,'viewer') return history_snapshot(site_id,entry_id,history_id) @router.post('/{site_id}/pages/{entry_id}/studio-history/{history_id}/restore') def api_history_restore(request:Request,site_id:str,entry_id:str,history_id:str,payload:dict=Body(...)): enterprise.require(request,site_id,'editor') return restore_history(site_id,entry_id,history_id,payload.get('expected_revision'),enterprise.actor(request)) @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,enterprise.actor(request)) @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':'仅生成草稿预设,不改变已发布内容'}