Files
浅念 8cea7dee20 chore: baseline V8.10.0-dev.1 with v8_workflows type-mapping fix and trade-pro spec
- SiteForge Enterprise V8.10.0-dev.1 upstream source (210 tests)
- Fix: v8_workflows._mapped_input now reads both 类型/分类 columns so WXR
  imports keep post/product types instead of collapsing to page
- Docs: trade-pro theme port spec (R3) and implementation plan

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-22 16:49:25 +08:00

304 lines
18 KiB
Python

"""V8 declarative whole-site template snapshot and safe application.
A template contains page structure, not source-site content records, product data,
media bytes, domain, secrets or SEO values.
"""
from __future__ import annotations
import copy,json,uuid,hashlib
from fastapi import APIRouter,Body,HTTPException,Request
from . import db,enterprise,blueprints,theme_store
from .core import save_entry
router=APIRouter(prefix='/api/factory/sites',tags=['V8 whole-site templates'])
DESIGN_FIELDS=('columns','gap','align','image_side','limit','source','height','visible','width','background',
'padding','padding_mobile','padding_tablet','margin','color','columns_mobile','tablet_columns','mobile_columns')
PAGE_LIMIT=35
def _base_block(block):
kind=block['type']
fresh=blueprints.new_block(kind)
for key in DESIGN_FIELDS:
if key in block and key!='columns':fresh[key]=copy.deepcopy(block[key])
if kind=='layout':
columns=[]
for column in block.get('columns',[]):
if not isinstance(column,dict):raise ValueError('Invalid layout column')
columns.append({'width':column.get('width',1),**{k:column[k] for k in ('tablet_order','mobile_order','tablet_span','mobile_span','hide_tablet','hide_mobile') if k in column},'blocks':[_base_block(x) for x in column.get('blocks',[])]})
fresh['columns']=columns
return fresh
def export_template(site_id,name):
site=db.one('SELECT id,theme,accent,settings_json FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'网站不存在')
entries=db.rows("SELECT route,title,meta_json FROM entries WHERE site_id=? AND type='page' ORDER BY route LIMIT 36",(site_id,))
pages=[]
for entry in entries:
raw=json.loads(entry['meta_json'] or '{}')
blocks=raw.get('blocks')
if blocks is None:continue
shape=blueprints.validate_blocks(blocks)
pages.append({'route':entry['route'],'page_type':raw.get('page_template') or ('home' if entry['route']=='/' else 'standard'),
'blocks':[_base_block(b) for b in shape]})
if len(pages)>PAGE_LIMIT:raise ValueError('模板最多35个页面')
settings=json.loads(site['settings_json'] or '{}')
layout={'format':'siteforge-blueprint-v1','name':str(name)[:100],'theme':site['theme'],
'tokens':{'accent':site['accent']},'theme_version':(theme_store.pinned(site_id) or {}).get('version') or theme_store.latest(site['theme']),'pages':pages,
'navigation':[{'title':str(x.get('title',''))[:60],'route':str(x.get('route',''))[:250]} for x in settings.get('nav',[]) if isinstance(x,dict)]}
return validate_template(layout)
def validate_template(doc):
if not isinstance(doc,dict) or doc.get('format')!='siteforge-blueprint-v1':raise ValueError('模板格式不支持')
if doc.get('theme') not in theme_store.ids():raise ValueError('主题未安装或不可用')
from .core import route_path,clean_hex
tokens=doc.get('tokens',{})
if not isinstance(tokens,dict):raise ValueError('设计令牌不合法')
accent=clean_hex(tokens.get('accent','#2563eb'))
pages=doc.get('pages',[])
if not isinstance(pages,list) or not (1<=len(pages)<=PAGE_LIMIT):raise ValueError('页面数量必须为1到35')
routes=set();out=[]
for p in pages:
if not isinstance(p,dict):raise ValueError('页面配置不合法')
route=route_path(p.get('route',''))
if route in routes:raise ValueError('模板包含重复 URL')
routes.add(route)
kind=p.get('page_type','standard')
if kind not in blueprints.PAGE_TEMPLATES:raise ValueError('未知页面类型')
blocks=blueprints.validate_blocks(p.get('blocks',[]))
out.append({'route':route,'page_type':kind,'blocks':blocks})
navigation=doc.get('navigation',[])
if not isinstance(navigation,list) or len(navigation)>30:raise ValueError('导航不能超过30项')
links=[]
for nav in navigation:
if not isinstance(nav,dict):raise ValueError('导航项不合法')
link=route_path(nav.get('route',''))
links.append({'title':str(nav.get('title',''))[:60],'route':link})
return {'format':'siteforge-blueprint-v1','name':str(doc.get('name','Website template'))[:100],
'theme':doc['theme'],'theme_version':str(doc.get('theme_version',''))[:30],'tokens':{'accent':accent},'navigation':links,'pages':out}
def save_snapshot(site_id,name):
doc=export_template(site_id,name)
id=uuid.uuid4().hex
db.insert('INSERT INTO v8_site_templates VALUES (?,?,?,?,?,?)',
(id,site_id,doc['name'],'1.0.0',json.dumps(doc,ensure_ascii=False),db.now()))
return {'id':id,'name':doc['name'],'pages':len(doc['pages']),'format':doc['format']}
def preflight(target_site_id,doc):
"""Check applicability without changing the destination site or its content."""
normalized=validate_template(doc)
site=db.one('SELECT id,theme FROM sites WHERE id=?',(target_site_id,))
if not site:raise HTTPException(404,'网站不存在')
theme=normalized['theme']
verdict=theme_store.compatibility(theme, str(doc.get('theme_version') or '') or None)
errors=list(verdict['errors']);warnings=list(verdict['warnings'])
required=sorted({b['type'] for p in normalized['pages'] for b in blueprints.validate_blocks(p['blocks'])})
source_version=doc.get('theme_version')
if source_version:
try:theme_store.resolve(theme,str(source_version))
except (ValueError,FileNotFoundError) as e:errors.append('模板要求的主题版本不可用:'+str(e))
missing=[t for t in required if t not in blueprints.BLOCKS]
if missing:errors.append('区块组件不存在:'+','.join(missing))
installed=theme_store.ids()
if site['theme']!=theme:warnings.append('将切换现有主题:'+site['theme']+' → '+theme)
current={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(target_site_id,))}
conflicts=[p['route'] for p in normalized['pages'] if p['route'] in current]
if conflicts:warnings.append('将更新已有页面的布局,但不复制业务正文和 SEO:'+', '.join(conflicts[:10]))
return {'compatible':not errors,'errors':errors,'warnings':warnings,'theme':theme,
'page_count':len(normalized['pages']),'required_blocks':required,
'existing_routes':conflicts,'create_routes':[p['route'] for p in normalized['pages'] if p['route'] not in current],
'content_preserved':True,'no_changes_applied':True}
def preview_application(target_site_id,doc):
doc=validate_template(doc)
if not db.one('SELECT id FROM sites WHERE id=?',(target_site_id,)):raise HTTPException(404,'网站不存在')
existing={x['route'] for x in db.rows('SELECT route FROM entries WHERE site_id=?',(target_site_id,))}
return {'theme':doc['theme'],'new':[x['route'] for x in doc['pages'] if x['route'] not in existing],
'existing':[x['route'] for x in doc['pages'] if x['route'] in existing],
'pages':len(doc['pages']),'content_preserved':True,'requires_confirmation':True}
def setup():
with db.connect() as c:
c.execute('''CREATE TABLE IF NOT EXISTS v8_template_application_drafts (
site_id TEXT PRIMARY KEY REFERENCES sites(id) ON DELETE CASCADE,
revision INTEGER NOT NULL, base_hash TEXT NOT NULL,
template_json TEXT NOT NULL, overwrite_pages INTEGER NOT NULL,
created_at TEXT NOT NULL)''')
def _source_fingerprint(site_id,c=None):
"""Hash current page designs + site chrome before applying a multi-page plan."""
def read(conn):
site=conn.execute('SELECT theme,accent,settings_json,updated_at FROM sites WHERE id=?',(site_id,)).fetchone()
if not site:raise HTTPException(404,'网站不存在')
rows=conn.execute('SELECT route,meta_json,updated_at FROM entries WHERE site_id=? ORDER BY route',(site_id,)).fetchall()
return hashlib.sha256(json.dumps({'site':dict(site),'pages':[dict(x) for x in rows]},sort_keys=True,ensure_ascii=False).encode()).hexdigest()
if c is not None:return read(c)
with db.connect() as conn:return read(conn)
def stage_template(site_id,doc,overwrite_pages=False,expected_revision=0):
"""Persist a reviewable whole-site plan without touching published pages."""
setup()
plan=validate_template(doc)
report=preflight(site_id,plan)
if not report['compatible']:raise HTTPException(422,{'errors':report['errors']})
if report['existing_routes'] and not overwrite_pages:
raise HTTPException(409,'已有同路径页面,需明确允许替换布局')
if type(expected_revision) is not int or expected_revision<0:raise HTTPException(400,'草稿版本无效')
with db.connect() as c:
if not db.POSTGRES:c.execute('BEGIN IMMEDIATE')
old=c.execute('SELECT revision FROM v8_template_application_drafts WHERE site_id=?',(site_id,)).fetchone()
revision=old['revision'] if old else 0
if expected_revision!=revision:raise HTTPException(409,'模板草稿已在其他窗口修改')
fingerprint=_source_fingerprint(site_id,c)
c.execute('''INSERT INTO v8_template_application_drafts
(site_id,revision,base_hash,template_json,overwrite_pages,created_at)
VALUES(?,?,?,?,?,?)
ON CONFLICT(site_id) DO UPDATE SET revision=excluded.revision,base_hash=excluded.base_hash,
template_json=excluded.template_json,overwrite_pages=excluded.overwrite_pages,
created_at=excluded.created_at''',
(site_id,revision+1,fingerprint,json.dumps(plan,ensure_ascii=False),int(overwrite_pages),db.now()))
return {'staged':True,'revision':revision+1,'page_count':len(plan['pages']),
'content_preserved':True,'published_unchanged':True,'requires_owner_approval':True}
def read_stage(site_id):
setup()
draft=db.one('SELECT * FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
if not draft:return {'state':'none','revision':0}
doc=json.loads(draft['template_json'])
return {'state':'draft','revision':draft['revision'],'theme':doc['theme'],
'page_count':len(doc['pages']), 'conflict':_source_fingerprint(site_id)!=draft['base_hash']}
def publish_stage(site_id,revision):
setup()
draft=db.one('SELECT * FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
if not draft:raise HTTPException(409,'没有待应用的整站模板草稿')
if type(revision) is not int or revision!=draft['revision']:raise HTTPException(409,'模板草稿版本不匹配')
return apply_template(site_id,json.loads(draft['template_json']),bool(draft['overwrite_pages']),
expected_hash=draft['base_hash'],staged_revision=revision)
def apply_template(target_site_id,doc,overwrite_pages=False,expected_hash=None,staged_revision=None):
'''Apply a confirmed template as one SQLite/PostgreSQL transaction.
This administrative layout operation is blocked when designers have pending
drafts. A failed insert/update does not leave half the site changed.
Business content, robots, SEO, media and published build artifacts survive.
'''
from . import studio_v86,site_design_v87
from .core import slug_of
studio_v86.setup();site_design_v87.setup()
doc=validate_template(doc)
suitability=preflight(target_site_id,doc)
if not suitability['compatible']:raise HTTPException(422,{'errors':suitability['errors']})
dry=preview_application(target_site_id,doc)
if dry['existing'] and not overwrite_pages:
raise HTTPException(409,{'message':'现有页面需要显式确认才能应用布局','routes':dry['existing']})
with db.connect() as c:
if not db.POSTGRES:c.execute('BEGIN IMMEDIATE')
site=c.execute('SELECT * FROM sites WHERE id=?',(target_site_id,)).fetchone()
if not site:raise HTTPException(404,'网站不存在')
if expected_hash is not None and _source_fingerprint(target_site_id,c)!=expected_hash:
raise HTTPException(409,'模板预检后网站已变化;请重新暂存并审核')
if staged_revision is not None:
record=c.execute('SELECT revision,base_hash FROM v8_template_application_drafts WHERE site_id=?',(target_site_id,)).fetchone()
if not record or record['revision']!=staged_revision or record['base_hash']!=expected_hash:
raise HTTPException(409,'当前模板草稿已被更新')
if c.execute('SELECT 1 FROM studio_drafts WHERE site_id=? LIMIT 1',(target_site_id,)).fetchone():
raise HTTPException(409,'存在尚未发布的页面设计草稿;请先发布或放弃草稿')
if c.execute('SELECT 1 FROM site_design_drafts WHERE site_id=?',(target_site_id,)).fetchone():
raise HTTPException(409,'存在尚未发布的全站草稿;请先发布或放弃草稿')
created=0;updated=0
for page in doc['pages']:
row=c.execute('SELECT id,meta_json FROM entries WHERE site_id=? AND route=?',
(target_site_id,page['route'])).fetchone()
if row:
meta=json.loads(row['meta_json'] or '{}')
meta.update(blocks=page['blocks'],page_template=page['page_type'])
c.execute('UPDATE entries SET meta_json=?,updated_at=? WHERE site_id=? AND id=?',
(json.dumps(meta,ensure_ascii=False),db.now(),target_site_id,row['id']))
updated+=1
else:
route=page['route']
label='首页' if route=='/' else route.strip('/').replace('-',' ').title()
c.execute('''INSERT INTO entries
(id,site_id,type,title,route,slug,summary,body,cover,seo_title,
seo_description,canonical,robots,meta_json,status,source,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)''',
(uuid.uuid4().hex,target_site_id,'page',label,route,slug_of(label),'','','','','',
'','noindex,follow',json.dumps({'blocks':page['blocks'],'page_template':page['page_type']},ensure_ascii=False),
'draft','v8-template',db.now(),db.now()))
created+=1
settings=json.loads(site['settings_json'] or '{}')
settings['nav']=doc['navigation']
c.execute('UPDATE sites SET theme=?,accent=?,settings_json=?,updated_at=? WHERE id=?',
(doc['theme'],doc['tokens']['accent'],json.dumps(settings,ensure_ascii=False),db.now(),target_site_id))
if staged_revision is not None:
c.execute('DELETE FROM v8_template_application_drafts WHERE site_id=? AND revision=?',(target_site_id,staged_revision))
return {'site_id':target_site_id,'created':created,'updated_layouts':updated,
'theme':doc['theme'],'content_preserved':True,'transactional':True}
@router.post('/{site_id}/whole-site-templates')
def api_save(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
return save_snapshot(site_id,payload.get('name','New whole-site template'))
@router.get('/{site_id}/whole-site-templates')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT id,name,version,created_at FROM v8_site_templates WHERE site_id=? ORDER BY created_at DESC',(site_id,))
@router.get('/{site_id}/whole-site-templates/{template_id}')
def api_show(request:Request,site_id:str,template_id:str):
enterprise.require(request,site_id,'viewer')
row=db.one('SELECT blueprint_json FROM v8_site_templates WHERE id=? AND site_id=?',(template_id,site_id))
if not row:raise HTTPException(404,'模板不存在')
return json.loads(row['blueprint_json'])
@router.post('/{site_id}/whole-site-templates/preflight')
def api_preflight(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'viewer')
try:return preflight(site_id,payload.get('template'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/whole-site-templates/apply')
def api_apply(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
template=payload.get('template')
if template is None:
source=payload.get('source_site_id');snapshot=payload.get('template_id')
# Read requires membership in the source site, not only the destination.
enterprise.require(request,source,'viewer')
row=db.one('SELECT blueprint_json FROM v8_site_templates WHERE site_id=? AND id=?',(source,snapshot))
if not row:raise HTTPException(404,'源模板不存在')
template=json.loads(row['blueprint_json'])
try:
if not payload.get('confirm',False):return preview_application(site_id,template)
if payload.get('legacy_direct_apply'):
from .site_design_v87 import require_legacy_enabled
require_legacy_enabled()
enterprise.require(request,site_id,'owner')
return apply_template(site_id,template,bool(payload.get('overwrite_layouts',False)))
return stage_template(site_id,template,bool(payload.get('overwrite_layouts',False)),payload.get('expected_revision',0))
except ValueError as e:raise HTTPException(400,str(e))
@router.get('/{site_id}/whole-site-templates/staged/current')
def api_stage_detail(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return read_stage(site_id)
@router.post('/{site_id}/whole-site-templates/staged/publish')
def api_publish_staged(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'owner')
return publish_stage(site_id,payload.get('expected_revision'))
@router.delete('/{site_id}/whole-site-templates/staged/current')
def api_discard_staged(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
setup()
db.execute('DELETE FROM v8_template_application_drafts WHERE site_id=?',(site_id,))
return {'discarded':True}