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>
This commit is contained in:
+68
-11
@@ -1,7 +1,7 @@
|
||||
"""Declarative mapping from arbitrary tabular source data to SiteForge content.
|
||||
Never eval mappings, no AI hallucinated fields, preview shows all errors.
|
||||
"""
|
||||
import json,re
|
||||
import json,re,hashlib
|
||||
from . import db
|
||||
from .core import route_path,slug_of,save_entry
|
||||
|
||||
@@ -53,14 +53,41 @@ def suggest(columns):
|
||||
return mapping
|
||||
|
||||
|
||||
def source_column(spec):
|
||||
return spec if isinstance(spec,str) else spec['source']
|
||||
|
||||
|
||||
def validate_rules(rules,columns):
|
||||
if not isinstance(rules,dict) or len(rules)>100:raise ValueError('字段映射规则不合法')
|
||||
for dest,src in rules.items():
|
||||
if not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):raise ValueError('目标字段名称不合法')
|
||||
for dest,spec in rules.items():
|
||||
if not isinstance(dest,str) or not re.fullmatch(r'[\w\-]{1,60}',dest,re.UNICODE):
|
||||
raise ValueError('目标字段名称不合法')
|
||||
if isinstance(spec,dict):
|
||||
allowed={'source','trim','case','default','prefix','suffix'}
|
||||
if set(spec)-allowed or 'source' not in spec:raise ValueError('包含不支持的转换配置')
|
||||
if type(spec.get('trim',True)) is not bool:raise ValueError('trim 必须为布尔值')
|
||||
if spec.get('case','none') not in ('none','upper','lower'):raise ValueError('不支持的大小写处理')
|
||||
for key in ('default','prefix','suffix'):
|
||||
if key in spec and (not isinstance(spec[key],str) or len(spec[key])>500):
|
||||
raise ValueError('转换字符串过长或格式非法')
|
||||
elif not isinstance(spec,str):raise ValueError('映射字段格式非法')
|
||||
src=source_column(spec)
|
||||
if not isinstance(src,str) or src not in columns:raise ValueError(f'来源字段不存在: {src}')
|
||||
if 'title' not in rules:raise ValueError('必须映射标题字段')
|
||||
|
||||
|
||||
def mapped_value(row,spec):
|
||||
raw=row.get(source_column(spec))
|
||||
value=json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')
|
||||
if isinstance(spec,str):return value.strip()
|
||||
if spec.get('trim',True):value=value.strip()
|
||||
if not value:value=spec.get('default','')
|
||||
case=spec.get('case','none')
|
||||
if case=='upper':value=value.upper()
|
||||
elif case=='lower':value=value.lower()
|
||||
return spec.get('prefix','')+value+spec.get('suffix','')
|
||||
|
||||
|
||||
def transform(rows,rules,kind='product',prefix=None):
|
||||
if kind not in ('product','post','page','brand','vehicle','service','case','other'):raise ValueError('不支持的内容类型')
|
||||
columns=set().union(*(r.keys() for r in rows)) if rows else set()
|
||||
@@ -68,9 +95,8 @@ def transform(rows,rules,kind='product',prefix=None):
|
||||
seen=set();out=[];errors=[]
|
||||
for index,row in enumerate(rows):
|
||||
vals={}
|
||||
for target,src in rules.items():
|
||||
raw=row.get(src)
|
||||
vals[target]=(json.dumps(raw,ensure_ascii=False) if isinstance(raw,(dict,list)) else str(raw if raw is not None else '')).strip()
|
||||
for target,spec in rules.items():
|
||||
vals[target]=mapped_value(row,spec)
|
||||
title=vals.get('title','')
|
||||
if not title:
|
||||
errors.append({'row':index+1,'error':'标题为空'});continue
|
||||
@@ -97,30 +123,61 @@ def transform(rows,rules,kind='product',prefix=None):
|
||||
return out,errors
|
||||
|
||||
|
||||
def _state_token(site_id,items):
|
||||
"""Preview token binds mapped payload and existing rows' revision metadata.
|
||||
|
||||
This is advisory optimistic checking, not a global distributed lock.
|
||||
"""
|
||||
routes=[x['route'] for x in items]
|
||||
existing={r['route']:r for r in
|
||||
db.rows('SELECT * FROM entries WHERE site_id=?',(site_id,))
|
||||
if r['route'] in routes}
|
||||
state={'site':site_id,'items':items,'existing':existing}
|
||||
return hashlib.sha256(json.dumps(state,ensure_ascii=False,sort_keys=True,
|
||||
separators=(',',':')).encode('utf-8')).hexdigest()
|
||||
|
||||
|
||||
def dry_run(site_id,rows,rules,kind='product',prefix=None):
|
||||
if len(rows)>1000:raise ValueError('单批最多1000条内容')
|
||||
items,errors=transform(rows,rules,kind,prefix)
|
||||
existing={r['route'] for r in db.rows('SELECT route FROM entries WHERE site_id=?',(site_id,))}
|
||||
collisions=[r['route'] for r in items if r['route'] in existing]
|
||||
return {'input':len(rows),'mapped':len(items),'errors':errors,'conflicts':collisions,
|
||||
'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) - set(rules.values())) if rows else [],
|
||||
'sample':items[:10], 'items':items}
|
||||
'unmapped_columns':sorted(set().union(*(x.keys() for x in rows)) -
|
||||
{source_column(v) for v in rules.values()}) if rows else [],
|
||||
'sample':items[:10], 'items':items,
|
||||
'plan_hash':_state_token(site_id,items),
|
||||
'supported_transforms':['trim','default','upper','lower','prefix','suffix'],
|
||||
'note':'提交时携带 expected_plan_hash 可阻止预检后发生变化的数据覆盖'}
|
||||
|
||||
|
||||
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None):
|
||||
if on_conflict not in ('skip','replace'):raise ValueError('冲突策略只能为 skip 或 replace')
|
||||
def commit(site_id,rows,rules,kind='product',on_conflict='skip',prefix=None,expected_plan_hash=None):
|
||||
if on_conflict not in ('skip','replace','abort'):
|
||||
raise ValueError('冲突策略只能为 skip、replace 或 abort')
|
||||
report=dry_run(site_id,rows,rules,kind,prefix)
|
||||
if expected_plan_hash is not None:
|
||||
if not isinstance(expected_plan_hash,str) or not re.fullmatch(r'[a-f0-9]{64}',expected_plan_hash):
|
||||
raise ValueError('预检令牌格式无效')
|
||||
if expected_plan_hash!=report['plan_hash']:
|
||||
raise ValueError('导入预检已过期:来源数据或已有内容发生变化,请重新预览')
|
||||
if on_conflict=='abort' and (report['conflicts'] or report['errors']):
|
||||
raise ValueError('严格导入被阻断:存在 URL 冲突或无效数据,请处理后重新预览')
|
||||
created=0;updated=0;skipped=0;errors=list(report['errors'])
|
||||
for idx,item in enumerate(report['items'],1):
|
||||
try:
|
||||
existing=db.one('SELECT id FROM entries WHERE site_id=? AND route=?',(site_id,item['route']))
|
||||
if existing and on_conflict=='skip':skipped+=1;continue
|
||||
# abort semantics require a preflight without errors; do not turn
|
||||
# this into an automatic 'replace' policy on a colliding route.
|
||||
if existing and on_conflict=='abort':raise ValueError('提交期间出现 URL 冲突')
|
||||
save_entry(site_id,item,existing_id=existing['id'] if existing else None,source='mapped-import')
|
||||
if existing:updated+=1
|
||||
else:created+=1
|
||||
except Exception as exc:
|
||||
errors.append({'row':idx,'error':str(exc)[:250]})
|
||||
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped']}
|
||||
if on_conflict=='abort':break
|
||||
summary={'created':created,'updated':updated,'skipped':skipped,'errors':errors,'mapped':report['mapped'],
|
||||
'plan_hash':report['plan_hash']}
|
||||
from .core import uid
|
||||
db.insert('INSERT INTO import_batches VALUES (?,?,?,?,?,?)',(uid(),site_id,'',f'content-map:{kind}',json.dumps(summary,ensure_ascii=False),db.now()))
|
||||
return summary
|
||||
|
||||
Reference in New Issue
Block a user