"""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}