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