Files
cms-SiteForge/app/v8_workflows.py
T
浅念 601451f22b upgrade: adopt V8.21.3 (V8.13→8.21.3: P5 SFTP/S3, P6 multilingual, editor overhaul, enterprise hardening)
Overlay ~150 upstream items onto our V8.12 base, preserving trade-pro
theme, registry tests, and design docs. Re-applied our patch set
(discovery/env_for/blocks.css/type-mapping/theme_color/frontend entries,
test_theme_pro_v3 mods) — all anchors survived upstream evolution.

New platform enhancement (fixes upgrade-introduced regression):
multilingual.site_locale now honors settings_json.locale, plus
PUT /api/i18n/sites/{id}/locale — standalone English sites get
<html lang='en'> under the V8.15 locale-rewrite pipeline.

compose: adopt V8.21.3 (image 8.21.3-dev.1, /api/ready probes, MySQL/S3
env) with project name pinned to siteforge-v8-10 (data volumes).

Tests: 356 passed / 11 failed — all 11 confirmed pre-existing in
pristine V8.21.3 on this host (10 upstream Windows env + our build test
now green after locale fix). Patch registry updated in plan doc.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-23 12:31:03 +08:00

299 lines
18 KiB
Python

"""V8 portable content import pipeline: deterministic preview, batch audit and guarded rollback.
This is a single-instance pipeline suitable for controlled pilots. It never runs
untrusted scripts, and keeps original import and business data separate.
"""
from __future__ import annotations
import hashlib
import io
import json
import re
import uuid
import csv
from urllib.parse import urlsplit
from fastapi import APIRouter, Body, HTTPException, Request, File, UploadFile, Form
from . import db, enterprise, assembly
from .core import save_entry, route_path
router = APIRouter(prefix='/api/factory/sites', tags=['V8 content workflow'])
MAX_ITEMS = 1000
MAX_UPLOAD = 12_000_000
def setup():
with db.connect() as c:
c.executescript('''
CREATE TABLE IF NOT EXISTS v8_import_batches(
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
origin TEXT NOT NULL, content_type TEXT NOT NULL, state TEXT NOT NULL,
mapping_json TEXT NOT NULL, warnings_json TEXT NOT NULL DEFAULT '[]',
input_count INTEGER NOT NULL, created_at TEXT NOT NULL, committed_at TEXT,
rolled_back_at TEXT);
CREATE TABLE IF NOT EXISTS v8_import_items(
id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES v8_import_batches(id) ON DELETE CASCADE,
source_key TEXT NOT NULL, row_number INTEGER NOT NULL,
route TEXT NOT NULL, content_json TEXT NOT NULL, state TEXT NOT NULL DEFAULT 'staged',
entry_id TEXT, before_json TEXT, after_fingerprint TEXT, error TEXT,
UNIQUE(batch_id, source_key));
CREATE INDEX IF NOT EXISTS v8_batches_by_site ON v8_import_batches(site_id,created_at);
CREATE TABLE IF NOT EXISTS v8_site_templates(
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
name TEXT NOT NULL, version TEXT NOT NULL, blueprint_json TEXT NOT NULL,
created_at TEXT NOT NULL);
''')
def _row_hash(row):
if row is None:return ''
data={k:row[k] for k in ('id','site_id','type','title','route','slug','summary','body','cover',
'seo_title','seo_description','canonical','robots','meta_json','status','source','created_at','updated_at')}
return hashlib.sha256(json.dumps(data,ensure_ascii=False,sort_keys=True).encode()).hexdigest()
def _require_site(site_id):
if not db.one('SELECT id FROM sites WHERE id=?',(site_id,)):
raise HTTPException(404,'网站不存在')
def _mapped_input(rows, mapping, kind):
if not isinstance(rows,list) or not rows or len(rows)>MAX_ITEMS:
raise ValueError('每批需要 1-1000 条内容')
if any(not isinstance(x,dict) for x in rows):raise ValueError('数据行必须是对象')
if kind not in ('product','vehicle','page','post','case','brand','service','other','mixed'):
raise ValueError('内容类型不受支持')
mapping = mapping or assembly.suggest(list(rows[0].keys()))
if not isinstance(mapping,dict):raise ValueError('映射规则不合法')
mapped,errors=assembly.transform(rows,mapping,'page' if kind=='mixed' else kind)
if kind=='mixed':
kinds={'post':'post','page':'page','product':'product','brand':'brand','vehicle':'vehicle',
'case':'case','service':'service'}
for i,item in enumerate(mapped):
src=str(rows[i].get('类型') or rows[i].get('分类') or 'page').lower()
item['type']=kinds.get(src,'other')
if errors:raise ValueError('数据预检失败:'+json.dumps(errors[:8],ensure_ascii=False))
return mapped,mapping
def stage(site_id, rows, mapping=None, kind='product', origin='manual-import', source_keys=None):
_require_site(site_id)
mapped,mapping=_mapped_input(rows,mapping,kind)
if len(json.dumps(mapped,ensure_ascii=False).encode('utf-8'))>MAX_UPLOAD:
raise ValueError('映射后的批次超过12MB上限,请拆分导入')
if source_keys is not None and len(source_keys)!=len(rows):raise ValueError('源记录主键数量不一致')
existing_routes={x['route'] for x in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
conflicts=[x['route'] for x in mapped if x['route'] in existing_routes]
batch_id=uuid.uuid4().hex
now=db.now()
keys=[]
for i,item in enumerate(mapped):
source_key=str(source_keys[i] if source_keys else item['route'])
if not source_key or len(source_key)>250:raise ValueError('来源主键缺失或过长')
keys.append(source_key)
if len(set(keys))!=len(keys):raise ValueError('导入文件的来源主键重复')
with db.connect() as c:
c.execute('''INSERT INTO v8_import_batches(id,site_id,origin,content_type,state,mapping_json,
input_count,created_at) VALUES(?,?,?,?,?,?,?,?)''',
(batch_id,site_id,origin[:160],kind,'staged',json.dumps(mapping,ensure_ascii=False),len(mapped),now))
for i,item in enumerate(mapped):
c.execute('''INSERT INTO v8_import_items(id,batch_id,source_key,row_number,route,content_json)
VALUES(?,?,?,?,?,?)''',(uuid.uuid4().hex,batch_id,keys[i],i+1,item['route'],
json.dumps(item,ensure_ascii=False)))
return {'batch_id':batch_id,'state':'staged','mapped':len(mapped),'conflicts':conflicts,
'sample':mapped[:5],'mapping':mapping,'requires_conflict_choice':bool(conflicts)}
def list_batches(site_id):
_require_site(site_id)
return db.rows('SELECT id,origin,content_type,state,input_count,created_at,committed_at,rolled_back_at FROM v8_import_batches WHERE site_id=? ORDER BY created_at DESC LIMIT 100',(site_id,))
def batch_detail(site_id,batch_id):
result=db.one('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id))
if not result:raise HTTPException(404,'导入批次不存在')
result['items']=db.rows('SELECT id,source_key,row_number,route,state,entry_id,error FROM v8_import_items WHERE batch_id=? ORDER BY row_number LIMIT 1100',(batch_id,))
return result
def commit(site_id,batch_id,on_conflict='skip'):
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
batch=db.one('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id))
if not batch:raise HTTPException(404,'导入批次不存在')
if batch['state']=='committed':return {'batch_id':batch_id,'state':'committed','existing':True}
if batch['state']!='staged':raise HTTPException(409,'批次已经开始处理,不能重复执行')
locked=db.execute("UPDATE v8_import_batches SET state='processing' WHERE id=? AND state='staged'",(batch_id,))
if not locked:raise HTTPException(409,'批次正在由其他请求处理')
items=db.rows('SELECT * FROM v8_import_items WHERE batch_id=? ORDER BY row_number',(batch_id,))
created=updated=skipped=0
failed=[]
for item in items:
original=db.one('SELECT * FROM entries WHERE site_id=? AND route=?',(site_id,item['route']))
if original and on_conflict=='skip':
db.execute("UPDATE v8_import_items SET state='skipped', entry_id=? WHERE id=?",(original['id'],item['id']))
skipped+=1
continue
try:
payload=json.loads(item['content_json'])
meta=payload.get('meta') or {}
meta.setdefault('provenance',{})
meta['provenance'].update({'import_batch':batch_id,'source_key':item['source_key'],'origin':batch['origin']})
payload['meta']=meta
before=json.dumps(original,ensure_ascii=False) if original else None
eid=save_entry(site_id,payload,existing_id=original['id'] if original else None,source='v8-import')
after=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,eid))
db.execute('''UPDATE v8_import_items SET state='applied',entry_id=?,before_json=?,after_fingerprint=? WHERE id=?''',
(eid,before,_row_hash(after),item['id']))
if original:updated+=1
else:created+=1
except Exception as exc:
msg=str(exc)[:250]
db.execute("UPDATE v8_import_items SET state='error',error=? WHERE id=?",(msg,item['id']))
failed.append({'row':item['row_number'],'message':msg})
db.execute('UPDATE v8_import_batches SET state=?,committed_at=? WHERE id=?',
('partial' if failed else 'committed',db.now(),batch_id))
# Keep unsupported Elementor/WooCommerce details in the existing release gate.
if batch['origin']=='wordpress-wxr':
for finding in json.loads(batch.get('warnings_json') or '[]')[:200]:
db.insert('''INSERT INTO cms_import_reviews(id,site_id,platform,row_number,issue,created_at)
VALUES(?,?,?,?,?,?)''',(uuid.uuid4().hex,site_id,'wordpress-wxr',int(finding.get('row',0)),
('v8:'+batch_id+': '+str(finding.get('issue','Requires review')))[:600],db.now()))
return {'batch_id':batch_id,'state':'partial' if failed else 'committed',
'created':created,'updated':updated,'skipped':skipped,'errors':failed}
def rollback(site_id,batch_id):
# Hold one write transaction across the conflict check and all reversions.
# A manual edit racing with rollback must not be accidentally overwritten.
with db.connect() as c:
c.execute('BEGIN IMMEDIATE')
batch=c.execute('SELECT * FROM v8_import_batches WHERE site_id=? AND id=?',(site_id,batch_id)).fetchone()
if not batch:raise HTTPException(404,'导入批次不存在')
if batch['state']=='rolled_back':return {'batch_id':batch_id,'state':'rolled_back','existing':True}
if batch['state'] not in ('committed','partial'):raise HTTPException(409,'批次还没有完成提交')
applied=c.execute("SELECT * FROM v8_import_items WHERE batch_id=? AND state='applied' ORDER BY row_number DESC",(batch_id,)).fetchall()
conflicts=[]
for item in applied:
current=c.execute('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,item['entry_id'])).fetchone()
if _row_hash(dict(current) if current else None)!=item['after_fingerprint']:
conflicts.append(item['route'])
if conflicts:raise HTTPException(409,{'message':'内容在导入后被修改,拒绝覆盖人工编辑','routes':conflicts})
cols=('type','title','route','slug','summary','body','cover','seo_title','seo_description',
'canonical','robots','meta_json','status','source','created_at','updated_at')
for item in applied:
if not item['before_json']:
c.execute('DELETE FROM entries WHERE site_id=? AND id=?',(site_id,item['entry_id']))
else:
before=json.loads(item['before_json'])
assignments=','.join(k+'=?' for k in cols)
c.execute(f'UPDATE entries SET {assignments} WHERE site_id=? AND id=?',
(*[before[k] for k in cols],site_id,item['entry_id']))
c.execute("UPDATE v8_import_items SET state='rolled_back' WHERE id=?",(item['id'],))
c.execute("UPDATE v8_import_batches SET state='rolled_back',rolled_back_at=? WHERE id=?",(db.now(),batch_id))
if batch['origin']=='wordpress-wxr':
c.execute("UPDATE cms_import_reviews SET state='resolved',reviewer='system',resolution='Source import batch rolled back',resolved_at=? WHERE site_id=? AND platform='wordpress-wxr' AND state='pending' AND issue LIKE ?",
(db.now(),site_id,'v8:'+batch_id+': %'))
return {'batch_id':batch_id,'state':'rolled_back','restored':len(applied)}
def parse_wxr(content):
"""Extract conservative WordPress records without writing into the site.
Unsupported builder configuration stays in source metadata for review.
"""
from defusedxml import ElementTree as ET
if len(content)>MAX_UPLOAD:raise ValueError('XML 文件超过限制')
root=ET.fromstring(content)
if root.tag!='rss':raise ValueError('这不是 WordPress WXR RSS 导出')
# Stage only unambiguous permanent paths; dynamic functions remain review items.
from .wp_complex_v820 import inspect_wxr
checks=inspect_wxr(content)
blockers=[x for x in checks['issues'] if x['code'] in ('invalid_permalink','duplicate_route')]
if blockers or (checks['findings_truncated'] and checks['blocking_issues']):
raise ValueError('存在不能安全映射的 WordPress URL,请先运行 V8.20 WXR 预检并修正:'+
', '.join(x['code']+':'+x['route'] for x in blockers[:5]))
rows=[];ids=[];warnings=[]
for it in root.findall('./channel/item'):
get=lambda name:next((str(node.text or '') for node in it if node.tag.endswith('}'+name) or node.tag==name),'')
raw_typ=get('post_type') or 'post'
status=get('status') or 'publish'
if raw_typ in ('attachment','revision','nav_menu_item') or status not in ('publish','draft','private'):continue
if len(rows)>=MAX_ITEMS:raise ValueError('单批 WXR 最多1000条,请拆分文件')
wid=get('post_id')
title=get('title').strip() or get('post_name').strip()
if not title:continue
url=get('link')
slug=get('post_name') or re.sub(r'[^a-z0-9-]','-',title.lower())[:65] or uuid.uuid4().hex[:8]
route=urlsplit(url).path if url.startswith(('http://','https://')) else ''
if not route:route=f'/{slug}/' if raw_typ=='page' else f'/{raw_typ}/{slug}/'
metas={}
for pm in it:
if not pm.tag.endswith('}postmeta'):continue
k=v=''
for n in pm:
if n.tag.endswith('}meta_key'):k=n.text or ''
if n.tag.endswith('}meta_value'):v=n.text or ''
if k:metas[k]=v[:10000]
if any(k in metas for k in ('_elementor_data','_product_attributes')):
warnings.append({'row':len(rows)+1,'issue':'含复杂 Elementor/WooCommerce 组件,导入后需人工核对'})
rows.append({'标题':title,'路径':route,'正文':get('encoded'),'类型':raw_typ,
'SEO标题':metas.get('_yoast_wpseo_title') or metas.get('rank_math_title',''),
'SEO描述':metas.get('_yoast_wpseo_metadesc') or metas.get('rank_math_description',''),
'分类':raw_typ})
ids.append(f'wordpress:{wid}' if wid else f'wordpress:path:{route}')
for finding in checks['issues']:
if finding['severity'] not in ('review','blocker'):continue
if finding['code'] in ('invalid_permalink','duplicate_route'):continue
route=finding['route']
index=next((i+1 for i,row in enumerate(rows) if row['路径']==route),0)
if index:
if finding['code'].startswith('elementor') and any(w.get('row')==index and 'Elementor' in w.get('issue','') for w in warnings):
continue
warnings.append({'row':index,'issue':finding['code']+': '+finding['message']})
# Bound warnings written into batch metadata and shown for human review.
return rows,ids,warnings[:200]
@router.post('/{site_id}/staging')
def api_stage(request:Request,site_id:str,payload:dict=Body(...)):
enterprise.require(request,site_id,'editor')
try:return stage(site_id,payload.get('rows'),payload.get('mapping'),payload.get('content_type','product'),payload.get('origin','manual-import'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/file')
async def api_table_file(request:Request,site_id:str,file:UploadFile=File(...),
content_type:str=Form('product')):
enterprise.require(request,site_id,'editor')
from .table_loader import parse_table
blob=await file.read(MAX_UPLOAD+1)
try:
parsed=parse_table(file.filename or '',blob)
result=stage(site_id,parsed['rows'],kind=content_type,origin='table:'+parsed['filename'][:120])
result['columns']=parsed['columns']
return result
except (ValueError,UnicodeError,json.JSONDecodeError) as e:
raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/wxr')
async def api_wxr(request:Request,site_id:str,file:UploadFile=File(...)):
enterprise.require(request,site_id,'editor')
if not (file.filename or '').lower().endswith('.xml'):raise HTTPException(400,'仅支持 .xml 导出')
blob=await file.read(MAX_UPLOAD+1)
try:
rows,keys,warnings=parse_wxr(blob)
if not rows:raise ValueError('没有可导入的 WordPress 页面或文章')
result=stage(site_id,rows,{'title':'标题','route':'路径','body':'正文',
'seo_title':'SEO标题','seo_description':'SEO描述'},'mixed','wordpress-wxr',keys)
db.execute('UPDATE v8_import_batches SET warnings_json=? WHERE id=?',
(json.dumps(warnings,ensure_ascii=False),result['batch_id']))
return {**result,'warnings':warnings[:30]}
except ValueError as e:raise HTTPException(400,str(e))
@router.get('/{site_id}/staging')
def api_list(request:Request,site_id:str):
enterprise.require(request,site_id,'viewer')
return {'items':list_batches(site_id)}
@router.get('/{site_id}/staging/{batch_id}')
def api_detail(request:Request,site_id:str,batch_id:str):
enterprise.require(request,site_id,'viewer')
return batch_detail(site_id,batch_id)
@router.post('/{site_id}/staging/{batch_id}/commit')
def api_commit(request:Request,site_id:str,batch_id:str,payload:dict=Body(default_factory=dict)):
enterprise.require(request,site_id,'editor')
try:return commit(site_id,batch_id,payload.get('on_conflict','skip'))
except ValueError as e:raise HTTPException(400,str(e))
@router.post('/{site_id}/staging/{batch_id}/rollback')
def api_rollback(request:Request,site_id:str,batch_id:str):
enterprise.require(request,site_id,'owner')
return rollback(site_id,batch_id)