Files
浅念 8884e100f9 upgrade: adopt V8.31.1 (theme 1.3/1.4 redesigns, editor deep-canvas, guided launch, component library)
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>
2026-10-26 09:43:17 +08:00

293 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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}