8884e100f9
Overlay 190 upstream items (V8.22→8.31) preserving trade-pro theme, registry tests, and design docs. Re-applied full patch set on identical anchors (discovery/env_for/blocks.css/type-mapping/locale-override/ theme_color/frontend entries/test_theme_pro_v3 mods) plus compose pins. Verified: theme suites 35/35; dual-baseline full run 415 passed vs pristine 386+29 ours, FAILED-set diff EMPTY (same 10 Windows env fails). Live: container 8.31.1-dev.1 healthy, hesupipe 192 entries intact, trade-pro build 203 pages 0 errors. Co-Authored-By: Claude Code <noreply@anthropic.com>
293 lines
15 KiB
Python
293 lines
15 KiB
Python
"""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
|
||
import json
|
||
import re
|
||
import uuid
|
||
from fastapi import APIRouter, Body, HTTPException, Request
|
||
from . import db, enterprise, blueprints, studio_v86
|
||
from .core import uid
|
||
|
||
router=APIRouter(prefix='/api/sites',tags=['Reusable Components'])
|
||
|
||
|
||
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()
|
||
|
||
|
||
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);
|
||
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));
|
||
''')
|
||
# 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}
|
||
|
||
|
||
@router.get('/{site_id}/components')
|
||
def all_components(request:Request,site_id:str):
|
||
enterprise.require(request,site_id,'viewer')
|
||
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,))]
|
||
|
||
|
||
@router.post('/{site_id}/components')
|
||
def save_component(request:Request,site_id:str,payload:dict=Body(...)):
|
||
enterprise.require(request,site_id,'editor')
|
||
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}
|
||
|
||
|
||
@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')
|
||
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}
|
||
|
||
|
||
@router.delete('/{site_id}/components/{component_id}')
|
||
def delete_component(request:Request,site_id:str,component_id:str):
|
||
enterprise.require(request,site_id,'editor')
|
||
_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))
|
||
return {'deleted':True}
|
||
|
||
|
||
@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}
|