Files
cms-SiteForge/app/factory_api.py
T
浅念 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

335 lines
21 KiB
Python

"""Factory V2 endpoints: themes, mappings, build comparisons, team release readiness."""
import json,csv,io
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,str]=Field(default_factory=dict)
content_type:str='product'
prefix:str|None=None
on_conflict:str='skip'
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)
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.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}/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)
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','/ui/blocks.css').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)