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>
This commit is contained in:
+262
-23
@@ -1,11 +1,39 @@
|
||||
"""Site-scoped reusable editor component library with versioned snapshots."""
|
||||
"""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
|
||||
from fastapi import APIRouter,Body,HTTPException,Request
|
||||
from . import db,enterprise,blueprints
|
||||
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(
|
||||
@@ -13,41 +41,252 @@ def setup():
|
||||
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.pop('block_json'))} for r in db.rows(
|
||||
'SELECT * FROM reusable_components WHERE site_id=? ORDER BY updated_at DESC',(site_id,))]
|
||||
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=str(payload.get('name','')).strip()[:100]
|
||||
if not name:raise HTTPException(400,'组件名称不能为空')
|
||||
try:block=blueprints.validate_blocks([payload.get('block')])[0]
|
||||
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
|
||||
cid=uid()
|
||||
db.insert('INSERT INTO reusable_components VALUES (?,?,?,?,?,?)',
|
||||
(cid,site_id,name,1,json.dumps(block,ensure_ascii=False),db.now()))
|
||||
return {'id':cid,'name':name,'version':1,'block':block}
|
||||
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')
|
||||
current=db.one('SELECT * FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id))
|
||||
if not current:raise HTTPException(404)
|
||||
if payload.get('expected_version')!=current['version']:raise HTTPException(409,'组件已被修改,请重新加载')
|
||||
try:block=blueprints.validate_blocks([payload.get('block')])[0]
|
||||
except (ValueError,TypeError) as exc:raise HTTPException(400,str(exc))
|
||||
db.execute('UPDATE reusable_components SET name=?,block_json=?,version=?,updated_at=? WHERE id=? AND site_id=?',
|
||||
(str(payload.get('name') or current['name'])[:100],json.dumps(block,ensure_ascii=False),current['version']+1,db.now(),component_id,site_id))
|
||||
return {'id':component_id,'version':current['version']+1}
|
||||
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')
|
||||
if not db.execute('DELETE FROM reusable_components WHERE site_id=? AND id=?',(site_id,component_id)):
|
||||
raise HTTPException(404)
|
||||
_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}
|
||||
|
||||
Reference in New Issue
Block a user