2026-10-26 09:43:17 +08:00
|
|
|
|
"""Versioned, site-scoped reusable blocks with opt-in, draft-only propagation.
|
|
|
|
|
|
|
|
|
|
|
|
Published pages are NEVER rewritten by a shared-component update. A linked
|
|
|
|
|
|
instance is a validated, materialized block with shared_ref metadata. Updates
|
|
|
|
|
|
require a fresh preview token, exact page revision and an unmodified instance.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import copy
|
|
|
|
|
|
import hashlib
|
2026-10-22 16:49:25 +08:00
|
|
|
|
import json
|
2026-10-26 09:43:17 +08:00
|
|
|
|
import re
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from fastapi import APIRouter, Body, HTTPException, Request
|
|
|
|
|
|
from . import db, enterprise, blueprints, studio_v86
|
2026-10-22 16:49:25 +08:00
|
|
|
|
from .core import uid
|
|
|
|
|
|
|
|
|
|
|
|
router=APIRouter(prefix='/api/sites',tags=['Reusable Components'])
|
|
|
|
|
|
|
2026-10-26 09:43:17 +08:00
|
|
|
|
|
|
|
|
|
|
def canonical(value):
|
|
|
|
|
|
return json.dumps(value,ensure_ascii=False,sort_keys=True,separators=(',',':'))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _content(block):
|
|
|
|
|
|
"""Content fingerprint independent of generated instance IDs and provenance."""
|
|
|
|
|
|
def strip(item):
|
|
|
|
|
|
if isinstance(item,dict):return {k:strip(v) for k,v in item.items() if k not in ('id','shared_ref')}
|
|
|
|
|
|
if isinstance(item,list):return [strip(v) for v in item]
|
|
|
|
|
|
return item
|
|
|
|
|
|
return strip(block)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def fingerprint(block):
|
|
|
|
|
|
return hashlib.sha256(canonical(_content(block)).encode()).hexdigest()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
def setup():
|
|
|
|
|
|
with db.connect() as c:
|
|
|
|
|
|
c.executescript('''CREATE TABLE IF NOT EXISTS reusable_components(
|
|
|
|
|
|
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
|
|
|
|
|
name TEXT NOT NULL,version INTEGER NOT NULL DEFAULT 1,block_json TEXT NOT NULL,
|
|
|
|
|
|
updated_at TEXT NOT NULL);
|
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reuse_site ON reusable_components(site_id);
|
2026-10-26 09:43:17 +08:00
|
|
|
|
CREATE TABLE IF NOT EXISTS reusable_component_versions(
|
|
|
|
|
|
component_id TEXT NOT NULL REFERENCES reusable_components(id) ON DELETE CASCADE,
|
|
|
|
|
|
version INTEGER NOT NULL,block_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL,
|
|
|
|
|
|
created_at TEXT NOT NULL,PRIMARY KEY(component_id,version));
|
2026-10-22 16:49:25 +08:00
|
|
|
|
''')
|
2026-10-26 09:43:17 +08:00
|
|
|
|
# Safe additive migration of old version-1 reusable blocks.
|
|
|
|
|
|
legacy=c.execute('SELECT id,version,block_json,updated_at FROM reusable_components').fetchall()
|
|
|
|
|
|
for row in legacy:
|
|
|
|
|
|
block=json.loads(row['block_json'])
|
|
|
|
|
|
c.execute('''INSERT INTO reusable_component_versions
|
|
|
|
|
|
(component_id,version,block_json,snapshot_hash,created_at)
|
|
|
|
|
|
VALUES(?,?,?,?,?) ON CONFLICT(component_id,version) DO NOTHING''',
|
|
|
|
|
|
(row['id'],row['version'],row['block_json'],fingerprint(block),row['updated_at']))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _component(site_id,component_id,c=None):
|
|
|
|
|
|
sql='SELECT * FROM reusable_components WHERE site_id=? AND id=?'
|
|
|
|
|
|
row=c.execute(sql,(site_id,component_id)).fetchone() if c else db.one(sql,(site_id,component_id))
|
|
|
|
|
|
if not row:raise HTTPException(404,'本站组件不存在')
|
|
|
|
|
|
return dict(row)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_block(block):
|
|
|
|
|
|
try:
|
|
|
|
|
|
validated=blueprints.validate_blocks([block])[0]
|
|
|
|
|
|
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
|
|
|
|
|
|
# A library source is an independent snapshot. Embedded source links are
|
|
|
|
|
|
# removed recursively; only insertion as a linked instance adds provenance.
|
|
|
|
|
|
def detach(node):
|
|
|
|
|
|
node.pop('shared_ref',None)
|
|
|
|
|
|
if node.get('type')=='layout':
|
|
|
|
|
|
for col in node.get('columns',[]):
|
|
|
|
|
|
for sub in col.get('blocks',[]):detach(sub)
|
|
|
|
|
|
detach(validated)
|
|
|
|
|
|
return validated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _name(value):
|
|
|
|
|
|
name=str(value or '').strip()
|
|
|
|
|
|
if not name or len(name)>100:raise HTTPException(400,'组件名称需要1–100个字符')
|
|
|
|
|
|
return name
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def versions(site_id,component_id):
|
|
|
|
|
|
_component(site_id,component_id)
|
|
|
|
|
|
rows=db.rows('''SELECT version,snapshot_hash,created_at FROM reusable_component_versions
|
|
|
|
|
|
WHERE component_id=? ORDER BY version DESC''',(component_id,))
|
|
|
|
|
|
return {'items':rows}
|
|
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
|
|
|
|
|
|
@router.get('/{site_id}/components')
|
|
|
|
|
|
def all_components(request:Request,site_id:str):
|
|
|
|
|
|
enterprise.require(request,site_id,'viewer')
|
2026-10-26 09:43:17 +08:00
|
|
|
|
return [{**r,'block':json.loads(r['block_json']), 'snapshot_hash':fingerprint(json.loads(r['block_json']))}
|
|
|
|
|
|
for r in db.rows('SELECT * FROM reusable_components WHERE site_id=? ORDER BY updated_at DESC',(site_id,))]
|
|
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
|
|
|
|
|
|
@router.post('/{site_id}/components')
|
|
|
|
|
|
def save_component(request:Request,site_id:str,payload:dict=Body(...)):
|
|
|
|
|
|
enterprise.require(request,site_id,'editor')
|
2026-10-26 09:43:17 +08:00
|
|
|
|
name=_name(payload.get('name'))
|
|
|
|
|
|
block=_validate_block(payload.get('block'))
|
|
|
|
|
|
# Saved library snapshots must not retain a reference to a different component.
|
|
|
|
|
|
block.pop('shared_ref',None)
|
|
|
|
|
|
cid=uid();now=db.now();encoded=canonical(block);sha=fingerprint(block)
|
|
|
|
|
|
with db.connect() as c:
|
|
|
|
|
|
c.execute('INSERT INTO reusable_components VALUES (?,?,?,?,?,?)',(cid,site_id,name,1,encoded,now))
|
|
|
|
|
|
c.execute('INSERT INTO reusable_component_versions VALUES (?,?,?,?,?)',(cid,1,encoded,sha,now))
|
|
|
|
|
|
return {'id':cid,'name':name,'version':1,'block':block,'snapshot_hash':sha}
|
|
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
|
|
|
|
|
|
@router.put('/{site_id}/components/{component_id}')
|
|
|
|
|
|
def update_component(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
|
|
|
|
|
|
enterprise.require(request,site_id,'editor')
|
2026-10-26 09:43:17 +08:00
|
|
|
|
block=_validate_block(payload.get('block'));block.pop('shared_ref',None)
|
|
|
|
|
|
with db.connect() as c:
|
|
|
|
|
|
row=_component(site_id,component_id,c)
|
|
|
|
|
|
rev=payload.get('expected_version')
|
|
|
|
|
|
if type(rev) is not int or rev!=row['version']:raise HTTPException(409,'组件版本已改变,请重新加载')
|
|
|
|
|
|
version=rev+1;name=_name(payload.get('name') if payload.get('name') is not None else row['name'])
|
|
|
|
|
|
encoded=canonical(block);now=db.now()
|
|
|
|
|
|
cur=c.execute('''UPDATE reusable_components SET name=?,block_json=?,version=?,updated_at=?
|
|
|
|
|
|
WHERE id=? AND site_id=? AND version=?''',(name,encoded,version,now,component_id,site_id,rev))
|
|
|
|
|
|
if cur.rowcount!=1:raise HTTPException(409,'组件已由其他用户修改')
|
|
|
|
|
|
c.execute('INSERT INTO reusable_component_versions VALUES (?,?,?,?,?)',
|
|
|
|
|
|
(component_id,version,encoded,fingerprint(block),now))
|
|
|
|
|
|
return {'id':component_id,'version':version,'snapshot_hash':fingerprint(block),'published_unchanged':True}
|
|
|
|
|
|
|
2026-10-22 16:49:25 +08:00
|
|
|
|
|
|
|
|
|
|
@router.delete('/{site_id}/components/{component_id}')
|
|
|
|
|
|
def delete_component(request:Request,site_id:str,component_id:str):
|
|
|
|
|
|
enterprise.require(request,site_id,'editor')
|
2026-10-26 09:43:17 +08:00
|
|
|
|
_component(site_id,component_id)
|
|
|
|
|
|
# Reject deletion while linked instances exist; preserve historical versions.
|
|
|
|
|
|
if _references(site_id,component_id):raise HTTPException(409,'组件仍被页面引用,请先解除引用')
|
|
|
|
|
|
db.execute('DELETE FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id))
|
2026-10-22 16:49:25 +08:00
|
|
|
|
return {'deleted':True}
|
2026-10-26 09:43:17 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get('/{site_id}/components/{component_id}/versions')
|
|
|
|
|
|
def api_versions(request:Request,site_id:str,component_id:str):
|
|
|
|
|
|
enterprise.require(request,site_id,'viewer')
|
|
|
|
|
|
return versions(site_id,component_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get('/{site_id}/components/{component_id}/versions/{version}')
|
|
|
|
|
|
def api_version(request:Request,site_id:str,component_id:str,version:int):
|
|
|
|
|
|
enterprise.require(request,site_id,'viewer')
|
|
|
|
|
|
_component(site_id,component_id)
|
|
|
|
|
|
row=db.one('SELECT * FROM reusable_component_versions WHERE component_id=? AND version=?',(component_id,version))
|
|
|
|
|
|
if not row:raise HTTPException(404,'版本不存在')
|
|
|
|
|
|
block=json.loads(row['block_json'])
|
|
|
|
|
|
if fingerprint(block)!=row['snapshot_hash']:raise HTTPException(409,'历史快照完整性校验失败')
|
|
|
|
|
|
return {'version':version,'block':block,'snapshot_hash':row['snapshot_hash']}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _walk(rows):
|
|
|
|
|
|
for block in rows:
|
|
|
|
|
|
yield block
|
|
|
|
|
|
if block.get('type')=='layout':
|
|
|
|
|
|
for col in block.get('columns',[]):yield from _walk(col.get('blocks',[]))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replace_in_tree(rows,block_id,replacement):
|
|
|
|
|
|
for i,block in enumerate(rows):
|
|
|
|
|
|
if block.get('id')==block_id:
|
|
|
|
|
|
rows[i]=replacement;return True
|
|
|
|
|
|
if block.get('type')=='layout':
|
|
|
|
|
|
for col in block.get('columns',[]):
|
|
|
|
|
|
if _replace_in_tree(col['blocks'],block_id,replacement):return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _references(site_id,component_id):
|
|
|
|
|
|
# Bounded scan; site ownership is enforced at query boundary.
|
|
|
|
|
|
all_pages=db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
|
|
|
|
|
|
drafts={r['entry_id']:r for r in db.rows('SELECT * FROM studio_drafts WHERE site_id=?',(site_id,))}
|
|
|
|
|
|
found=[]
|
|
|
|
|
|
for page in all_pages:
|
|
|
|
|
|
draft=drafts.get(page['id'])
|
|
|
|
|
|
plan=json.loads(draft['blueprint_json']) if draft else json.loads(page['meta_json'] or '{}')
|
|
|
|
|
|
for block in _walk(plan.get('blocks',[])):
|
|
|
|
|
|
meta=block.get('shared_ref')
|
|
|
|
|
|
if isinstance(meta,dict) and meta.get('id')==component_id:
|
|
|
|
|
|
found.append({'entry_id':page['id'],'route':page['route'],'title':page['title'],
|
|
|
|
|
|
'block_id':block['id'],'current_version':meta.get('version'),
|
|
|
|
|
|
'local_modified':fingerprint(block)!=meta.get('hash'),
|
|
|
|
|
|
'draft_revision':draft['revision'] if draft else 0,
|
|
|
|
|
|
'base_hash':draft['base_hash'] if draft else studio_v86._hash(page),
|
|
|
|
|
|
'has_draft':bool(draft),'conflict':bool(draft and draft['base_hash']!=studio_v86._hash(page))})
|
|
|
|
|
|
return found
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _preview(site_id,component_id):
|
|
|
|
|
|
component=_component(site_id,component_id)
|
|
|
|
|
|
refs=_references(site_id,component_id)
|
|
|
|
|
|
for item in refs:
|
|
|
|
|
|
item['up_to_date']=item['current_version']==component['version']
|
|
|
|
|
|
item['eligible']=not item['local_modified'] and not item['conflict'] and not item['up_to_date']
|
|
|
|
|
|
item['reason']=('存在页面发布冲突' if item['conflict'] else '页面有本地修改' if item['local_modified']
|
|
|
|
|
|
else '已是最新版' if item['up_to_date'] else '')
|
|
|
|
|
|
# Token includes all page revisions and instance fingerprints; replay of stale
|
|
|
|
|
|
# preview after any page or component changes is rejected.
|
|
|
|
|
|
proof={'component':component_id,'version':component['version'],'references':refs}
|
|
|
|
|
|
token=hashlib.sha256(canonical(proof).encode()).hexdigest()
|
|
|
|
|
|
return {'component':{'id':component_id,'name':component['name'],'version':component['version']},
|
|
|
|
|
|
'references':refs,'plan_token':token,'warning':'同步仅写入新草稿,不自动发布或部署'}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get('/{site_id}/components/{component_id}/usage')
|
|
|
|
|
|
def api_usage(request:Request,site_id:str,component_id:str):
|
|
|
|
|
|
enterprise.require(request,site_id,'viewer')
|
|
|
|
|
|
return _preview(site_id,component_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _copy_into(snapshot,original,component_id,version):
|
|
|
|
|
|
block=copy.deepcopy(snapshot)
|
|
|
|
|
|
def fresh(node,root=False):
|
|
|
|
|
|
node['id']=original['id'] if root else uuid.uuid4().hex[:10]
|
|
|
|
|
|
node.pop('shared_ref',None)
|
|
|
|
|
|
if node.get('type')=='layout':
|
|
|
|
|
|
for col in node.get('columns',[]):
|
|
|
|
|
|
for sub in col.get('blocks',[]):fresh(sub)
|
|
|
|
|
|
fresh(block,True)
|
|
|
|
|
|
block['shared_ref']={'id':component_id,'version':version,'hash':fingerprint(block)}
|
|
|
|
|
|
return block
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post('/{site_id}/components/{component_id}/sync')
|
|
|
|
|
|
def api_sync(request:Request,site_id:str,component_id:str,payload:dict=Body(...)):
|
|
|
|
|
|
enterprise.require(request,site_id,'editor')
|
|
|
|
|
|
selected=payload.get('entry_ids')
|
|
|
|
|
|
if not isinstance(selected,list) or not selected or len(selected)>100 or len(set(selected))!=len(selected) or any(not isinstance(s,str) for s in selected):
|
|
|
|
|
|
raise HTTPException(400,'选择1–100个不同页面')
|
|
|
|
|
|
preview=_preview(site_id,component_id)
|
|
|
|
|
|
if payload.get('plan_token')!=preview['plan_token']:raise HTTPException(409,'预检已过期,请重新查看引用页面')
|
|
|
|
|
|
chosen=set(selected)
|
|
|
|
|
|
available={r['entry_id'] for r in preview['references'] if r['eligible']}
|
|
|
|
|
|
if not chosen.issubset(available):raise HTTPException(409,'包含已修改、存在冲突或不可同步的页面')
|
|
|
|
|
|
component=_component(site_id,component_id)
|
|
|
|
|
|
current=blueprints.validate_blocks([json.loads(component['block_json'])])[0]
|
|
|
|
|
|
# Restrict to exactly one linked instance per page per request for now.
|
|
|
|
|
|
# When one page has multiple references, sync all eligible instances atomically.
|
|
|
|
|
|
updated=[]
|
|
|
|
|
|
expected_pages={r['entry_id']:(r['draft_revision'],r['base_hash']) for r in preview['references'] if r['entry_id'] in chosen}
|
|
|
|
|
|
with db.connect() as c:
|
|
|
|
|
|
# Lock the read/compare/write window. PostgreSQL uses an advisory
|
|
|
|
|
|
# transaction lock through the compatibility adapter.
|
|
|
|
|
|
c.execute('BEGIN IMMEDIATE')
|
|
|
|
|
|
newest=_component(site_id,component_id,c)
|
|
|
|
|
|
if newest['version']!=component['version']:
|
|
|
|
|
|
raise HTTPException(409,'组件源版本发生变化')
|
|
|
|
|
|
for entry_id in selected:
|
|
|
|
|
|
page=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id)).fetchone()
|
|
|
|
|
|
if not page:raise HTTPException(409,'页面已删除,请重新预检')
|
|
|
|
|
|
draft=c.execute('SELECT * FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id)).fetchone()
|
|
|
|
|
|
old_hash=studio_v86._hash(dict(page))
|
|
|
|
|
|
expected_page_revision,expected_base=expected_pages[entry_id]
|
|
|
|
|
|
if expected_base!=old_hash or (draft['revision'] if draft else 0)!=expected_page_revision:
|
|
|
|
|
|
raise HTTPException(409,'页面版本已变化,重新预检后同步')
|
|
|
|
|
|
if draft and draft['base_hash']!=old_hash:raise HTTPException(409,'页面内容已变化')
|
|
|
|
|
|
plan=json.loads(draft['blueprint_json']) if draft else {
|
|
|
|
|
|
'blocks':json.loads(page['meta_json'] or '{}').get('blocks',[]),
|
|
|
|
|
|
'page_template':json.loads(page['meta_json'] or '{}').get('page_template','home' if page['route']=='/' else 'standard'),
|
|
|
|
|
|
'layout_mode':json.loads(page['meta_json'] or '{}').get('layout_mode','append')}
|
|
|
|
|
|
did=0
|
|
|
|
|
|
for block in list(_walk(plan['blocks'])):
|
|
|
|
|
|
ref=block.get('shared_ref')
|
|
|
|
|
|
if isinstance(ref,dict) and ref.get('id')==component_id:
|
|
|
|
|
|
if fingerprint(block)!=ref.get('hash') or ref.get('version')==component['version']:
|
|
|
|
|
|
raise HTTPException(409,'引用发生变化,请重新预检')
|
|
|
|
|
|
if not _replace_in_tree(plan['blocks'],block['id'],_copy_into(current,block,component_id,component['version'])):
|
|
|
|
|
|
raise HTTPException(409,'组件位置发生变化')
|
|
|
|
|
|
did+=1
|
|
|
|
|
|
if not did:raise HTTPException(409,'页面已经没有组件引用')
|
|
|
|
|
|
plan=studio_v86._validate(plan)
|
|
|
|
|
|
expected=draft['revision'] if draft else 0
|
|
|
|
|
|
rev=expected+1
|
|
|
|
|
|
if draft:
|
|
|
|
|
|
cur=c.execute('''UPDATE studio_drafts SET revision=?,blueprint_json=?,updated_at=?
|
|
|
|
|
|
WHERE site_id=? AND entry_id=? AND revision=? AND base_hash=?''',
|
|
|
|
|
|
(rev,canonical(plan),db.now(),site_id,entry_id,expected,draft['base_hash']))
|
|
|
|
|
|
if cur.rowcount!=1:raise HTTPException(409,'草稿并发冲突')
|
|
|
|
|
|
else:
|
|
|
|
|
|
c.execute('INSERT INTO studio_drafts VALUES (?,?,?,?,?,?)',
|
|
|
|
|
|
(site_id,entry_id,rev,old_hash,canonical(plan),db.now()))
|
|
|
|
|
|
studio_v86._journal(c,site_id,entry_id,rev,'draft',plan,enterprise.actor(request))
|
|
|
|
|
|
updated.append({'entry_id':entry_id,'revision':rev,'instances':did})
|
|
|
|
|
|
return {'updated':updated,'published_unchanged':True,'build_required_after_publish':True}
|