Files
cms-SiteForge/app/multilingual.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

266 lines
15 KiB
Python

"""Explicit multilingual relationships. No automatic fact translation.
One site per locale. Only approved, published and indexed content receives
alternate links. Pending translation records cannot accidentally enter a build.
"""
import json
import re
from urllib.parse import urlparse
from html import escape as html_escape
from xml.sax.saxutils import escape as xml_escape
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import db, enterprise
from .core import uid
router=APIRouter(prefix='/api/i18n', tags=['Multilingual'])
LOCALE_PATTERN=re.compile(r'^[a-z]{2,3}(?:-[A-Z][a-z]{3})?(?:-(?:[A-Z]{2}|[0-9]{3}))?$')
KEY_PATTERN=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{0,119}$')
def locale_value(locale):
value=str(locale or '').strip()
if not LOCALE_PATTERN.fullmatch(value):
raise ValueError('语言标识必须是 BCP47 格式,例如 zh-CN、en、fr-FR')
return value
def _domain(site):
value=(site.get('domain') or '').strip().rstrip('/')
parsed=urlparse(value)
if parsed.scheme!='https' or not parsed.hostname or parsed.username or parsed.password or parsed.path not in ('','/') or parsed.query or parsed.fragment:
raise ValueError('多语言正式站点必须配置独立 HTTPS 域名(不允许路径、账号或查询参数)')
return value
def setup():
with db.connect() as c:
c.executescript('''CREATE TABLE IF NOT EXISTS language_groups(
id TEXT PRIMARY KEY, name TEXT NOT NULL, default_locale TEXT NOT NULL,
created_at TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS language_sites(
group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
locale TEXT NOT NULL, PRIMARY KEY(group_id,site_id),
UNIQUE(group_id,locale), UNIQUE(site_id));
CREATE TABLE IF NOT EXISTS translation_units(
id TEXT PRIMARY KEY, group_id TEXT NOT NULL REFERENCES language_groups(id) ON DELETE CASCADE,
reference_key TEXT NOT NULL, created_at TEXT NOT NULL,
UNIQUE(group_id,reference_key));
CREATE TABLE IF NOT EXISTS translation_members(
unit_id TEXT NOT NULL REFERENCES translation_units(id) ON DELETE CASCADE,
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
state TEXT NOT NULL CHECK(state IN ('pending','approved')),
PRIMARY KEY(unit_id,site_id),UNIQUE(site_id,entry_id));
CREATE INDEX IF NOT EXISTS idx_translations_site ON translation_members(site_id,state);
''')
def require_group(group_id):
group=db.one('SELECT * FROM language_groups WHERE id=?',(group_id,))
if not group:raise HTTPException(404,'语言组不存在')
return group
def _authorized(request,group_id,role='viewer'):
sites=db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(group_id,))
# At least one managed member is needed to read or edit the group.
if not getattr(request.state,'is_superadmin',False):
if not any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS[role] for s in sites):
raise HTTPException(403,'无权管理此语言组')
return sites
class GroupIn(BaseModel):
name:str=Field(min_length=2,max_length=120)
default_locale:str='en'
class GroupSiteIn(BaseModel):
site_id:str
locale:str
class UnitIn(BaseModel):
reference_key:str
class MemberIn(BaseModel):
site_id:str
entry_id:str
state:str='pending'
@router.post('/groups',status_code=201)
def create_group(data:GroupIn,request:Request):
if not request.state.is_superadmin:raise HTTPException(403,'仅管理员可创建语言组')
try:locale=locale_value(data.default_locale)
except ValueError as exc:raise HTTPException(400,str(exc))
gid=uid()
db.insert('INSERT INTO language_groups VALUES (?,?,?,?)',(gid,data.name,locale,db.now()))
return {'id':gid,'name':data.name,'default_locale':locale}
class LocaleIn(BaseModel):
locale:str
@router.put('/sites/{site_id}/locale')
def set_site_locale(site_id:str,data:LocaleIn,request:Request):
"""Declare this site's content language (BCP47). Used for <html lang>, defaults zh-CN."""
enterprise.require(request,site_id,'owner')
try:locale=locale_value(data.locale)
except ValueError as exc:raise HTTPException(400,str(exc))
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
if not site:raise HTTPException(404,'网站不存在')
settings=json.loads(site['settings_json'] or '{}')
settings['locale']=locale
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
return {'site_id':site_id,'locale':locale}
@router.get('/groups')
def groups(request:Request):
gs=db.rows('SELECT * FROM language_groups ORDER BY created_at DESC')
if not request.state.is_superadmin:
gs=[g for g in gs if any(enterprise.level(request,s['site_id'])>=enterprise.ACCESS['viewer'] for s in db.rows('SELECT site_id FROM language_sites WHERE group_id=?',(g['id'],)))]
return gs
@router.get('/sites/{site_id}')
def site_language(site_id:str,request:Request):
enterprise.require(request,site_id,'viewer')
membership=db.one('''SELECT s.locale,s.group_id,g.name,g.default_locale FROM language_sites s
JOIN language_groups g ON g.id=s.group_id WHERE s.site_id=?''',(site_id,))
if not membership:return {'group':None,'sites':[],'units':[]}
gid=membership['group_id']
_authorized(request,gid)
sites=db.rows('''SELECT s.locale,s.site_id,t.name,t.domain FROM language_sites s
JOIN sites t ON t.id=s.site_id WHERE s.group_id=? ORDER BY s.locale''',(gid,))
units=db.rows('''SELECT u.id,u.reference_key,m.site_id,m.entry_id,m.state,e.title,e.route,s.locale
FROM translation_units u JOIN translation_members m ON m.unit_id=u.id
JOIN entries e ON e.id=m.entry_id JOIN language_sites s ON s.site_id=m.site_id
WHERE u.group_id=? ORDER BY u.reference_key,s.locale''',(gid,))
return {'group':membership,'sites':sites,'units':units}
@router.post('/groups/{group_id}/sites',status_code=201)
def join_site(group_id:str,data:GroupSiteIn,request:Request):
require_group(group_id)
_authorized(request,group_id,'owner')
enterprise.require(request,data.site_id,'owner')
site=db.one('SELECT * FROM sites WHERE id=?',(data.site_id,))
if not site:raise HTTPException(404,'网站不存在')
try:
locale=locale_value(data.locale)
domain=_domain(site)
except ValueError as exc:raise HTTPException(400,str(exc))
existing=db.rows('''SELECT x.site_id,t.domain FROM language_sites x JOIN sites t ON t.id=x.site_id
WHERE x.group_id=?''',(group_id,))
if any(_domain(s)==domain and s['site_id']!=data.site_id for s in existing):
raise HTTPException(409,'语言组内两个网站不能使用相同域名')
if db.one('SELECT site_id FROM language_sites WHERE site_id=?',(data.site_id,)):
raise HTTPException(409,'该站点已经属于语言组')
try:db.insert('INSERT INTO language_sites VALUES (?,?,?)',(group_id,data.site_id,locale))
except Exception as exc:
if 'UNIQUE' in str(exc).upper():raise HTTPException(409,'组内语言标识已存在')
raise
return {'group_id':group_id,'site_id':data.site_id,'locale':locale}
@router.post('/groups/{group_id}/units',status_code=201)
def create_unit(group_id:str,data:UnitIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
key=data.reference_key.strip()
if not KEY_PATTERN.fullmatch(key):raise HTTPException(400,'翻译组标识只能包含字母、数字、短横线、下划线、点和冒号')
found=db.one('SELECT id FROM translation_units WHERE group_id=? AND reference_key=?',(group_id,key))
if found:return {'id':found['id'],'reference_key':key,'existing':True}
id=uid();db.insert('INSERT INTO translation_units VALUES (?,?,?,?)',(id,group_id,key,db.now()))
return {'id':id,'reference_key':key,'existing':False}
@router.post('/groups/{group_id}/units/{unit_id}/members')
def link_member(group_id:str,unit_id:str,data:MemberIn,request:Request):
require_group(group_id);_authorized(request,group_id,'editor')
enterprise.require(request,data.site_id,'editor')
unit=db.one('SELECT * FROM translation_units WHERE id=? AND group_id=?',(unit_id,group_id))
if not unit:raise HTTPException(404,'翻译内容组不存在')
if not db.one('SELECT site_id FROM language_sites WHERE site_id=? AND group_id=?',(data.site_id,group_id)):
raise HTTPException(400,'网站不属于该语言组')
entry=db.one('SELECT * FROM entries WHERE id=? AND site_id=?',(data.entry_id,data.site_id))
if not entry:raise HTTPException(404,'内容不存在或不属于该网站')
if data.state not in ('pending','approved'):raise HTTPException(400,'无效的翻译状态')
if data.state=='pending' and entry['status']=='published':
raise HTTPException(409,'待翻译内容必须先保存为草稿,避免泄露未完成翻译')
if data.state=='approved' and entry['status']!='published':
raise HTTPException(409,'只有已发布的正式内容才能审核通过翻译关系')
prior=db.one('SELECT unit_id FROM translation_members WHERE site_id=? AND entry_id=?',(data.site_id,data.entry_id))
if prior and prior['unit_id']!=unit_id:raise HTTPException(409,'内容已经属于另一翻译组')
# Atomic upsert: one locale / site has at most one entry in a semantic group.
with db.connect() as c:
c.execute('''INSERT INTO translation_members(unit_id,site_id,entry_id,state) VALUES (?,?,?,?)
ON CONFLICT(unit_id,site_id) DO UPDATE SET entry_id=excluded.entry_id,state=excluded.state''',
(unit_id,data.site_id,data.entry_id,data.state))
return {'unit_id':unit_id,'site_id':data.site_id,'entry_id':data.entry_id,'state':data.state}
@router.delete('/groups/{group_id}/units/{unit_id}/members/{site_id}')
def unlink_member(group_id:str,unit_id:str,site_id:str,request:Request):
require_group(group_id);_authorized(request,group_id,'editor');enterprise.require(request,site_id,'editor')
deleted=db.execute('DELETE FROM translation_members WHERE unit_id=? AND site_id=?',(unit_id,site_id))
if not deleted:raise HTTPException(404,'没有匹配的翻译关联')
return {'removed':True}
def site_locale(site_id):
row=db.one('SELECT locale FROM language_sites WHERE site_id=?',(site_id,))
if row:return row['locale']
# Per-site locale override (e.g. standalone English sites outside any language group)
site=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
try:override=json.loads(site['settings_json'] or '{}').get('locale') if site else None
except ValueError:override=None
if override:
try:return locale_value(override)
except ValueError:pass
return 'zh-CN'
def assert_build_safe(site_id):
invalid=db.rows('''SELECT e.route,e.title FROM translation_members m JOIN entries e ON e.id=m.entry_id
WHERE m.site_id=? AND m.state='pending' AND e.status='published' ''',(site_id,))
if invalid:
raise ValueError('有待翻译内容被设为已发布,禁止构建:'+', '.join(x['route'] for x in invalid[:8]))
def alternates(site_id):
"""Get {route: [(locale,absolute_url)]} with approved cross-site peers only.
Return zero for unlinked/draft/noindex/invalid domain pages, including for
pending translation records. All members use the same symmetric set.
"""
row=db.one('SELECT group_id FROM language_sites WHERE site_id=?',(site_id,))
if not row:return {}
gid=row['group_id'];group=db.one('SELECT * FROM language_groups WHERE id=?',(gid,))
members=db.rows('''SELECT m.unit_id,s.locale,e.route,e.status,e.robots,e.canonical,t.domain,t.id AS site_id
FROM translation_members m JOIN entries e ON e.id=m.entry_id AND e.site_id=m.site_id
JOIN language_sites s ON s.site_id=m.site_id JOIN sites t ON t.id=m.site_id
JOIN translation_units u ON u.id=m.unit_id
WHERE u.group_id=? AND m.state='approved' ''',(gid,))
by_unit={}
for item in members:
if item['status']!='published' or 'noindex' in item['robots'].lower():continue
try:domain=_domain(item)
except ValueError:continue
if item['canonical'] and item['canonical'].rstrip('/') != (domain+item['route']).rstrip('/'):
continue # a canonical elsewhere is not an indexable translation target
by_unit.setdefault(item['unit_id'],[]).append((item['site_id'],item['route'],item['locale'],domain+item['route']))
result={}
for linked in by_unit.values():
if len(linked)<2:continue
peers=sorted([(locale,url) for _,_,locale,url in linked],key=lambda x:x[0])
# x-default is explicit only if an approved default-language peer exists.
default=next((url for locale,url in peers if locale==group['default_locale']),None)
if default:peers.append(('x-default',default))
for sid,route,_,_ in linked:
if sid==site_id:result[route]=peers
return result
def inject_seo(html,locale,links):
"""Lightweight escape-safe HTML head augmentation without reparsing 5k pages."""
html=re.sub(r"(<html\b[^>]*?\blang=)[\"'\"][^\"']*[\"'\"]",lambda m:m.group(1)+'"'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if not re.search(r'<html\b[^>]*\blang=',html,flags=re.I):
html=re.sub(r'<html\b','<html lang="'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I)
if links and '</head>' in html.lower():
tags=''.join('<link rel="alternate" hreflang="'+html_escape(language,quote=True)+'" href="'+html_escape(url,quote=True)+'">' for language,url in links)
html=re.sub(r'</head>',tags+'</head>',html,count=1,flags=re.I)
# Visible accessible language switch; never fabricate links for missing translations.
labels={'zh-CN':'简体中文','en':'English','fr-FR':'Français','ar':'العربية',
'es':'Español','ru':'Русский','pt':'Português'}
choices=[(language,url) for language,url in links if language!='x-default']
nav='<nav class="sf-language-switch" aria-label="选择网站语言">'+''.join(
'<a lang="'+html_escape(lang,quote=True)+'" hreflang="'+html_escape(lang,quote=True)+
'" href="'+html_escape(url,quote=True)+'"'+(' aria-current="page"' if lang==locale else '')+
'>'+html_escape(labels.get(lang,lang))+'</a>' for lang,url in choices)+'</nav>'
html=re.sub(r'</header>',lambda _m:nav+'</header>',html,count=1,flags=re.I)
return html
def sitemap_alternates(links):
return ''.join('<xhtml:link rel="alternate" hreflang="'+xml_escape(locale)+'" href="'+xml_escape(url)+'"/>' for locale,url in links)