"""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 , 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,theme 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,theme 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 if site and site['theme']=='trade-pro':return 'en' 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"(]*?\blang=)[\"'\"][^\"']*[\"'\"]",lambda m:m.group(1)+'"'+html_escape(locale,quote=True)+'"',html,count=1,flags=re.I) if not re.search(r']*\blang=',html,flags=re.I): html=re.sub(r'' in html.lower(): tags=''.join('' for language,url in links) html=re.sub(r'',tags+'',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='' html=re.sub(r'',lambda _m:nav+'',html,count=1,flags=re.I) return html def sitemap_alternates(links): return ''.join('' for locale,url in links)