Files
浅念 e21dbb6518 upgrade: adopt V8.33.1 — final merge, our patches upstreamed natively
V8.33.0 absorbed our entire platform patch set as native features
(discovery/env_for macro override/blocks.css priority/type-mapping fix/
frontend entries/locale override) and synced trade-pro from our remote;
V8.33.1 adds HESU PIPE industrial-theme home fixes (1.3.1).

Zero patch re-application this round — all verified native. Theme
suites 35/35; full run 428 passed + same 10 known Windows env fails
(FAILED set unchanged). Live: container 8.33.1-dev.1 healthy, hesupipe
192 entries intact, trade-pro build 203 pages 0 errors.

Patch registry retired to archive — future upgrades are plain overlay.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-26 14:27:17 +08:00

383 lines
24 KiB
Python

"""Factory V2 endpoints: themes, mappings, build comparisons, team release readiness."""
import json,csv,io
from typing import Any
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel,Field
from . import db,theme_store,assembly,enterprise,builder
router=APIRouter(prefix='/api/factory',tags=['Website Factory v2'])
def setup():
theme_store.setup();assembly.setup()
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS release_requests (
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
build_id TEXT NOT NULL, requested_by TEXT NOT NULL,
reviewed_by TEXT NOT NULL DEFAULT '', state TEXT NOT NULL DEFAULT 'pending',
review_note TEXT NOT NULL DEFAULT '', requested_at TEXT NOT NULL,reviewed_at TEXT);
CREATE INDEX IF NOT EXISTS idx_release_request_site ON release_requests(site_id,build_id);
''')
@router.get('/themes')
def all_themes(request:Request):
return theme_store.catalog()
@router.post('/themes/install')
async def install_theme(request:Request,file:UploadFile=File(...)):
if not request.state.is_superadmin:raise HTTPException(403,'仅系统管理员可安装主题')
try:return theme_store.install(await file.read(8_000_001))
except (ValueError,UnicodeError,KeyError,json.JSONDecodeError) as exc:raise HTTPException(400,str(exc))
@router.get('/themes/{theme_id}/compatibility')
def theme_compatibility(request:Request,theme_id:str,version:str=''):
return theme_store.compatibility(theme_id,version or None)
@router.get('/themes/{theme_id}/versions')
def versions(request:Request,theme_id:str):
if theme_id in theme_store.BUILTIN:
archive=theme_store.BASE/theme_id/'versions'
historical=[x.name for x in archive.iterdir() if x.is_dir()] if archive.exists() else []
return [{'theme_id':theme_id,'version':v,'builtin':True} for v in [theme_store.latest(theme_id)]+sorted(historical,reverse=True)]
return db.rows('SELECT * FROM theme_versions WHERE theme_id=? ORDER BY installed_at DESC',(theme_id,))
@router.put('/sites/{site_id}/theme')
def choose_theme(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return theme_store.pin(site_id,str(payload.get('theme','')),payload.get('version') or None)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/theme')
def site_theme(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT theme FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pin=theme_store.pinned(site_id)
return {'theme':site['theme'],'version':pin['version'] if pin else theme_store.latest(site['theme'])}
@router.get('/sites/{site_id}/relationships')
def preview_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
from . import relations
return relations.preview(site_id)
@router.post('/sites/{site_id}/relationships/assemble')
def assemble_relationships(request:Request,site_id:str):
enterprise.require(request,site_id,'editor')
from . import relations
try:return relations.commit(site_id)
except ValueError as exc:raise HTTPException(409,str(exc))
@router.get('/sites/{site_id}/mapping-profiles')
def profiles(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM mapping_profiles WHERE site_id=? ORDER BY updated_at DESC',(site_id,))
@router.post('/sites/{site_id}/mapping-profiles')
def save_profile(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
from .core import uid
name=str(payload.get('name','')).strip()[:100]
if not name:raise HTTPException(400,'请输入映射方案名称')
rules=payload.get('rules',{})
if not isinstance(rules,dict) or 'title' not in rules:raise HTTPException(400,'请设置标题字段映射')
kind=str(payload.get('content_type','product'))
id=uid()
db.insert('INSERT INTO mapping_profiles VALUES (?,?,?,?,?,?,?)',(id,site_id,name,kind,json.dumps(rules,ensure_ascii=False),db.now(),db.now()))
return {'id':id,'name':name,'rules':rules}
class MapRows(BaseModel):
rows:list[dict]=Field(max_length=1000)
rules:dict[str,Any]=Field(default_factory=dict)
content_type:str='product'
prefix:str|None=None
on_conflict:str='skip'
expected_plan_hash:str|None=None
dry_run:bool=True
@router.post('/sites/{site_id}/mapping/parse-file')
async def parse_mapping_file(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'viewer')
from .table_loader import parse_table
try:
decoded=parse_table(file.filename or '',await file.read(12_000_001))
return {**decoded,'suggested':assembly.suggest(decoded['columns'])}
except (ValueError,UnicodeDecodeError,json.JSONDecodeError) as exc:
raise HTTPException(400,str(exc))
@router.post('/sites/{site_id}/mapping/suggest')
def suggested_map(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'viewer')
columns=payload.get('columns',[])
if not isinstance(columns,list) or len(columns)>150:raise HTTPException(400,'字段数量过多')
return {'rules':assembly.suggest(columns)}
@router.post('/sites/{site_id}/mapping/run')
def map_rows(request:Request,site_id:str,payload:MapRows):
enterprise.require(request,site_id,'viewer' if payload.dry_run else 'editor')
try:
return assembly.dry_run(site_id,payload.rows,payload.rules,payload.content_type,payload.prefix) if payload.dry_run else assembly.commit(site_id,payload.rows,payload.rules,payload.content_type,payload.on_conflict,payload.prefix,payload.expected_plan_hash)
except ValueError as exc:raise HTTPException(400,str(exc))
@router.get('/sites/{site_id}/migration/readiness')
def readiness(request:Request,site_id:str,build_id:str=''):
enterprise.require(request,site_id,'viewer')
site=db.one('SELECT id,mode FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
base=enterprise.diff_baseline(site_id)
builds=db.rows('SELECT * FROM builds WHERE site_id=? ORDER BY created_at DESC LIMIT 1',(site_id,))
latest=db.one('SELECT * FROM builds WHERE site_id=? AND id=?',(site_id,build_id)) if build_id else (builds[0] if builds else None)
report=json.loads(latest['report_json']) if latest else None
gates=[]
if not latest:gates.append({'level':'block','message':'还没有完成静态网站构建'})
if site['mode']=='migration' and base['baseline_count']==0:gates.append({'level':'block','message':'迁移项目尚未导入原站 URL 基线'})
if site['mode']=='migration' and base['errors']:gates.append({'level':'block','message':f'仍有 {base["errors"]} 个旧站 URL 没有原路径页面'})
if base['warnings']:gates.append({'level':'review','message':f'{base["warnings"]} 项 SEO 差异待核查'})
if report:
for key in ('missing_local_images','broken_links'):
if report.get(key):gates.append({'level':'block','message':f'{key} 有 {len(report[key])} 项需处理'})
if report.get('external_assets'):gates.append({'level':'review','message':'仍存在未本地化的远程资源'})
if latest and site['mode']=='migration' and base['baseline_count']:
from . import migration_audit
detail=migration_audit.audit(site_id,latest['id'])
if detail['errors']:
gates.append({'level':'block','message':f'构建文件与原站基线存在 {detail["errors"]} 个阻断问题'})
if detail['warnings']:
gates.append({'level':'review','message':f'生成 HTML 存在 {detail["warnings"]} 项源站内容/SEO 差异'})
if detail.get('unreviewed_warnings'):
gates.append({'level':'block','message':f'还有 {detail["unreviewed_warnings"]} 项差异未经审核,暂不允许发布'})
return {'site_id':site_id,'build_id':latest['id'] if latest else None,'baseline':base,
'report':report,'gates':gates,'can_publish':not any(x['level']=='block' for x in gates),
'must_review':any(x['level']=='review' for x in gates)}
@router.get('/sites/{site_id}/assets/inventory')
def inventory(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
records=db.rows('SELECT route,cover,body,meta_json FROM entries WHERE site_id=?',(site_id,))
from bs4 import BeautifulSoup
assets={}
for row in records:
for url in [row['cover']]+[x.get('src') for x in BeautifulSoup(row['body'] or '','html.parser').select('img[src]')]:
if url:assets.setdefault(url,[]).append(row['route'])
legacy=db.DATA/'legacy'/site_id
media=db.DATA/'media'/site_id
existing=[str(p.relative_to(d)) for d in (legacy,media) if d.exists() for p in d.rglob('*') if p.is_file()]
return {'references':len(assets),'files':len(existing),'assets':[{'url':a,'used_by':routes} for a,routes in sorted(assets.items())][:5000]}
@router.post('/sites/{site_id}/bootstrap')
def bootstrap_site(request:Request,site_id:str):
"""Create full navigation/landing-page structure while preserving existing URLs.
Does not fabricate business descriptions or products; placeholder blocks stay
visibly editable and static empty collections clearly identify missing data.
"""
enterprise.require(request,site_id,'editor')
from . import blueprints
from .core import save_entry
site=db.one('SELECT theme,name FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404)
pages=[('about','关于我们','/about/','企业介绍与经营理念'),
('contact','联系我们','/contact/','业务咨询与联系方式'),
('listing','产品中心','/products/','按分类浏览产品', 'product'),
('listing','品牌中心','/brands/','了解我们的合作品牌','brand'),
('listing','新闻资讯','/news/','企业资讯与行业动态','post'),
('listing','项目案例','/cases/','精选客户项目','case'),
('standard','服务与解决方案','/services/','业务流程与服务能力'),
('standard','常见问题','/faq/','了解合作流程')]
created=[];skipped=[]
for page in pages:
kind,title,route,description=page[:4]
if db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,route)):
skipped.append(route);continue
blocks=blueprints.theme_blocks(site['theme'],kind)
if kind=='listing' and len(page)>4:
for b in blocks:
if b['type']=='collection':b['source']=page[4];b['title']=title;b['limit']=36
if route=='/faq/':blocks=[blueprints.new_block('faq')]
save_entry(site_id,{'type':'page','title':title,'route':route,'summary':description,
'meta':{'page_template':kind,'blocks':blocks}},source='site-bootstrap')
created.append(route)
nav=[{'title':v,'route':r} for v,r in [('首页','/'),('产品中心','/products/'),('品牌','/brands/'),('新闻','/news/'),('案例','/cases/'),('关于我们','/about/'),('联系我们','/contact/')]]
raw=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
settings=json.loads(raw['settings_json'] or '{}')
if not settings.get('nav'):
settings['nav']=nav
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'created':created,'skipped':skipped,'navigation':settings.get('nav'),
'message':'页面已建立;企业介绍、联系方式、产品等实际业务内容仍需补充。'}
@router.get('/sites/{site_id}/release-requests')
def requests_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return db.rows('SELECT * FROM release_requests WHERE site_id=? ORDER BY requested_at DESC',(site_id,))
@router.post('/sites/{site_id}/release-requests')
def request_release(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
build_id=str(payload.get('build_id',''))
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):
raise HTTPException(404,'构建版本不存在')
status=readiness(request,site_id,build_id)
if not status['can_publish']:
raise HTTPException(409,'迁移/质量门禁尚未通过,不允许提交发布审批')
from . import quality
qa=quality.report(site_id,build_id)
if not qa['passed']:raise HTTPException(409,f'构建质量检测包含 {qa["errors"]} 个错误')
from .core import uid
rid=uid();db.insert('INSERT INTO release_requests(id,site_id,build_id,requested_by,requested_at) VALUES (?,?,?,?,?)',
(rid,site_id,build_id,request.state.actor,db.now()))
return {'id':rid,'state':'pending','build_id':build_id,'review_required':True}
@router.post('/sites/{site_id}/release-requests/{request_id}/review')
def review_release(request:Request,site_id:str,request_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'reviewer')
item=db.one('SELECT * FROM release_requests WHERE id=? AND site_id=?',(request_id,site_id))
if not item:raise HTTPException(404)
if item['state']!='pending':raise HTTPException(409,'该请求已处理')
if item['requested_by']==request.state.actor:raise HTTPException(403,'审批人不能审核自己提交的发布申请')
state=payload.get('state')
if state not in ('approved','rejected'):raise HTTPException(400,'必须选择 approved 或 rejected')
note=str(payload.get('note') or '')[:500]
db.execute('UPDATE release_requests SET state=?,reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND site_id=?',
(state,request.state.actor,note,db.now(),request_id,site_id))
return {'id':request_id,'state':state,'reviewed_by':request.state.actor}
@router.get('/themes/{theme_id}/package')
def download_theme_package(request:Request,theme_id:str,version:str=''):
if not request.state.is_superadmin:raise HTTPException(403,'只有系统管理员可以导出主题包')
import io,zipfile
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
buffer=io.BytesIO()
with zipfile.ZipFile(buffer,'w',compression=zipfile.ZIP_DEFLATED) as archive:
manifest=json.loads((folder/'manifest.json').read_text(encoding='utf-8'))
manifest['layout_family']=theme_id if theme_id in theme_store.BUILTIN else manifest.get('layout_family','corporate')
archive.writestr('manifest.json',json.dumps(manifest,ensure_ascii=False,indent=2))
for part in ('theme.css','page-presets.json'):
archive.write(folder/part,arcname=part)
for image_name in ('preview.png','preview.jpg','preview.webp'):
if (folder/image_name).exists():archive.write(folder/image_name,arcname=image_name)
return Response(buffer.getvalue(),media_type='application/zip',headers={'Content-Disposition':f'attachment; filename="{theme_id}-{version or theme_store.latest(theme_id)}.zip"'})
@router.get('/themes/{theme_id}/preview.css')
def theme_preview_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:dir=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
return Response((dir/'theme.css').read_text(encoding='utf-8'),media_type='text/css')
@router.get('/themes/{theme_id}/preview.blocks.css')
def theme_preview_blocks_css(theme_id:str,version:str=''):
"""Serve the identical block stylesheet used by a static theme build."""
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
chosen=folder/'blocks.css'
if not chosen.is_file():chosen=theme_store.BASE.parent/'app'/'static'/'blocks.css'
return Response(chosen.read_text(encoding='utf-8'),media_type='text/css',headers={'Cache-Control':'no-store'})
@router.get('/themes/{theme_id}/preview.pro.css')
def theme_preview_pro_css(theme_id:str,version:str=''):
from fastapi.responses import Response
try:folder=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
asset=folder/'pro.css'
return Response(asset.read_text(encoding='utf-8') if asset.exists() else '',media_type='text/css')
@router.get('/themes/{theme_id}/preview.bundle.css')
def preview_automotive_bundle_css(theme_id:str,version:str=''):
"""Read-only editor preview: same cascade as the static automotive build."""
from fastapi.responses import Response
from . import static_site
folder=theme_store.resolve(theme_id,version or None)
if theme_id != 'automotive' or not (folder/'auto-icons.svg').is_file():
raise HTTPException(404,'此主题没有优化资源包')
names=[folder/'theme.css',static_site.ASSETS/'blocks.css',static_site.ASSETS/'factory.css',
static_site.ASSETS/'pro-base.css',folder/'pro.css',static_site.ASSETS/'commercial.css']
return Response('\n'.join(p.read_text(encoding='utf-8') for p in names), media_type='text/css')
@router.get('/themes/{theme_id}/preview.bundle.js')
def preview_automotive_bundle_js(theme_id:str,version:str=''):
from fastapi.responses import Response
from . import static_site
folder=theme_store.resolve(theme_id,version or None)
if theme_id != 'automotive' or not (folder/'auto-icons.svg').is_file():
raise HTTPException(404,'此主题没有优化资源包')
names=[static_site.ASSETS/name for name in ('site-runtime.js','pro.js','auto31.js')]
return Response('\n;\n'.join(p.read_text(encoding='utf-8') for p in names),media_type='application/javascript')
@router.get('/themes/{theme_id}/preview.icons.svg')
def preview_automotive_icons(theme_id:str,version:str=''):
from fastapi.responses import Response
folder=theme_store.resolve(theme_id,version or None)
if theme_id != 'automotive' or not (folder/'auto-icons.svg').is_file():
raise HTTPException(404,'此主题没有此图标集')
return Response((folder/'auto-icons.svg').read_bytes(),media_type='image/svg+xml',headers={'Cache-Control':'public,max-age=300'})
@router.get('/themes/{theme_id}/visual/hero.svg')
def theme_preview_hero(theme_id:str,version:str=''):
from fastapi.responses import Response
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
art=directory/'visual'/'hero.svg'
if not art.is_file():raise HTTPException(404,'该主题版本没有内置视觉素材')
return Response(art.read_bytes(),media_type='image/svg+xml',headers={'Cache-Control':'public,max-age=300'})
@router.get('/themes/{theme_id}/preview',response_class=HTMLResponse)
def theme_preview(theme_id:str,version:str='',page_type:str='home'):
from jinja2 import Environment,FileSystemLoader,select_autoescape
from markupsafe import Markup
from html import escape
from . import blueprints,static_site
try:directory=theme_store.resolve(theme_id,version or None)
except ValueError as exc:raise HTTPException(404,str(exc))
if page_type not in blueprints.PAGE_TEMPLATES:
raise HTTPException(400, '不支持的预览页面类型')
manifest=json.loads((directory/'manifest.json').read_text(encoding='utf-8'))
site={'id':'preview','theme':theme_id,'name':manifest.get('name','品牌企业'),
'tagline':'Independent design · Integrated content · Global delivery',
'domain':'','email':'hello@example.com','logo':'','accent':'#346aca'}
page={'title':{'home':'创造有影响力的网站','about':'关于我们','contact':'联系我们','listing':'产品中心','product':'旗舰产品 A','article':'最新企业新闻','brand':'合作品牌','standard':'我们的服务','notfound':'404 · 页面不存在','case':'项目案例','service':'专业服务','faq':'常见问题','category':'演示分类'}[page_type],
'route':'/' if page_type=='home' else ('/404.html' if page_type=='notfound' else ('/categories/demo/' if page_type=='category' else '/preview-example/')),'type':{'product':'product','article':'post','brand':'brand','case':'case','service':'service'}.get(page_type,'page'),
'summary':'从完整主题出发,连接产品、品牌和客户。',
'cover':'','body':'','robots':'index,follow','meta_json':json.dumps({'page_template':page_type})}
records=[{'title':t,'type':'product','route':'/product/example-'+str(i)+'/','summary':'展示产品与解决方案',
'cover':'','meta_json':'{}','status':'published'} for i,t in enumerate(('旗舰产品 A','核心产品 B','定制解决方案 C'))]
site['schemas']={}
blocks=blueprints.theme_blocks(theme_id,page_type,version or None)
block_html=blueprints.render_blocks(blocks,site,page,records)
from . import theme_pro
if (directory/'pro.html').exists():
# Online previews and published pages use the identical professional renderer.
if theme_id in theme_pro.THEMES and directory.resolve()==(theme_store.BASE/theme_id).resolve():
html=theme_pro.render(theme_id,site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[],'')
else:
env=Environment(loader=FileSystemLoader([str(directory),str(theme_store.BASE)]),autoescape=select_autoescape(['html']))
context=theme_pro.make_context(site,page,records,'home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),[])
context.update(custom_blocks=Markup(''),has_custom=False,noindex=page_type=='notfound',theme_overrides=True)
html=env.get_template('pro.html').render(**context)
if theme_id=='automotive' and (directory/'auto-icons.svg').is_file():
ver=version or theme_store.latest(theme_id)
root=f'/api/factory/themes/automotive/preview'
html=html.replace('assets/auto-compiled.css',f'{root}.bundle.css?version={ver}')
html=html.replace('assets/auto-compiled.js',f'{root}.bundle.js?version={ver}')
html=html.replace('assets/auto-icons.svg#',f'{root}.icons.svg?version={ver}#')
html=html.replace('assets/pro-base.css','/ui/pro-base.css').replace('assets/pro.js','/ui/pro.js').replace('assets/commercial.css','/ui/commercial.css')
html=html.replace('assets/pro.css',f'/api/factory/themes/{theme_id}/preview.pro.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/theme-overrides.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
else:
env=Environment(loader=FileSystemLoader(str(directory)),autoescape=select_autoescape(['html']))
html=env.get_template('site.html').render(site=site,page=page,mode='home' if page_type=='home' else ('notfound' if page_type=='notfound' else 'detail'),content=Markup(''),
blocks=Markup(block_html),blueprint_mode=True,entries=records,listing_items=[],
type_names={'product':'产品','page':'页面'},nav=[],link=lambda x:x.lstrip('/') or './',
asset=lambda x:x,canonical='',seo_title='主题预览 · '+manifest.get('name',''),
seo_description='SiteForge 主题模板预览')
html=static_site.shell(html,site,page,records,[])
html=html.replace('assets/theme.css',f'/api/factory/themes/{theme_id}/preview.css?version={version or theme_store.latest(theme_id)}')
html=html.replace('assets/blocks.css',f'/api/factory/themes/{theme_id}/preview.blocks.css?version={version or theme_store.latest(theme_id)}').replace('assets/factory.css','/ui/factory.css')
html=html.replace('assets/custom.css','/ui/factory.css').replace('assets/site-runtime.js','/ui/site-runtime.js')
return HTMLResponse(html)