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:
+70
-2
@@ -6,6 +6,7 @@ studio uses only /studio-draft endpoints; a build never consumes these drafts.
|
||||
from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import uuid
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from . import db, blueprints, enterprise
|
||||
from .core import save_entry
|
||||
@@ -24,6 +25,14 @@ def setup():
|
||||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL, actor TEXT NOT NULL, published_at TEXT NOT NULL,
|
||||
blueprint_hash TEXT NOT NULL)''')
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS studio_history (
|
||||
id TEXT PRIMARY KEY,site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||
entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,kind TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,snapshot_hash TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,created_at TEXT NOT NULL)''')
|
||||
c.execute('''CREATE INDEX IF NOT EXISTS idx_studio_history_site_page
|
||||
ON studio_history(site_id,entry_id,created_at,revision)''')
|
||||
|
||||
|
||||
def _entry(site_id, entry_id):
|
||||
@@ -59,7 +68,7 @@ def read(site_id,entry_id):
|
||||
'layout_mode':meta.get('layout_mode','append'),'revision':0,'conflict':False,'state':'published', 'entry':{'id':row['id'],'title':row['title'],'route':row['route']}}
|
||||
|
||||
|
||||
def write(site_id,entry_id,payload):
|
||||
def write(site_id,entry_id,payload,actor="system"):
|
||||
row=_entry(site_id,entry_id)
|
||||
plan=_validate(payload)
|
||||
# A draft is detached from the public entry. Only the final publish
|
||||
@@ -76,6 +85,7 @@ def write(site_id,entry_id,payload):
|
||||
ON CONFLICT(site_id,entry_id) DO UPDATE SET revision=excluded.revision,
|
||||
blueprint_json=excluded.blueprint_json,updated_at=excluded.updated_at''',
|
||||
(site_id,entry_id,next_rev,base,json.dumps(plan,ensure_ascii=False),db.now()))
|
||||
_journal(c,site_id,entry_id,next_rev,'draft',plan,actor)
|
||||
return {'saved':True,'state':'draft','revision':next_rev,'published_unchanged':True}
|
||||
|
||||
|
||||
@@ -101,11 +111,69 @@ def publish(site_id,entry_id,expected_revision,actor):
|
||||
pid=uuid.uuid4().hex
|
||||
c.execute('INSERT INTO studio_publications VALUES (?,?,?,?,?,?,?)',
|
||||
(pid,site_id,entry_id,draft['revision'],actor,db.now(),hashlib.sha256(json.dumps(plan,sort_keys=True).encode()).hexdigest()))
|
||||
_journal(c,site_id,entry_id,draft['revision'],'published',plan,actor)
|
||||
c.execute('DELETE FROM studio_drafts WHERE site_id=? AND entry_id=?',(site_id,entry_id))
|
||||
return {'published':True,'publication_id':pid,'revision':draft['revision'],
|
||||
'build_required':True,'message':'页面设计已发布到内容库;需要重新构建静态站点才会上线'}
|
||||
|
||||
|
||||
def _journal(connection, site_id, entry_id, revision, kind, plan, actor):
|
||||
# Store a complete, immutable validated snapshot; never executable user code.
|
||||
canonical=json.dumps(plan,ensure_ascii=False,sort_keys=True,separators=(',',':'))
|
||||
connection.execute('''INSERT INTO studio_history VALUES (?,?,?,?,?,?,?,?,?)''',
|
||||
(uuid.uuid4().hex,site_id,entry_id,revision,kind,canonical,
|
||||
hashlib.sha256(canonical.encode('utf-8')).hexdigest(),actor,db.now()))
|
||||
|
||||
|
||||
def history(site_id,entry_id,limit=30):
|
||||
_entry(site_id,entry_id)
|
||||
if type(limit) is not int or not 1<=limit<=100:raise HTTPException(400,'历史条数必须为1-100')
|
||||
rows=db.rows('''SELECT id,revision,kind,snapshot_hash,actor,created_at
|
||||
FROM studio_history WHERE site_id=? AND entry_id=?
|
||||
ORDER BY created_at DESC, revision DESC, id DESC LIMIT ?''',(site_id,entry_id,limit))
|
||||
return {'items':rows,'note':'历史记录不会直接修改已发布页面;恢复时只生成新的草稿修订'}
|
||||
|
||||
|
||||
def history_snapshot(site_id,entry_id,history_id):
|
||||
_entry(site_id,entry_id)
|
||||
row=db.one('SELECT * FROM studio_history WHERE site_id=? AND entry_id=? AND id=?',
|
||||
(site_id,entry_id,history_id))
|
||||
if not row:raise HTTPException(404,'历史记录不存在')
|
||||
if hashlib.sha256(row['snapshot_json'].encode('utf-8')).hexdigest()!=row['snapshot_hash']:
|
||||
raise HTTPException(409,'历史快照完整性校验失败,不允许恢复')
|
||||
plan=_validate(json.loads(row['snapshot_json']))
|
||||
return {'id':row['id'],'revision':row['revision'],'kind':row['kind'],
|
||||
'snapshot_hash':row['snapshot_hash'],'plan':plan}
|
||||
|
||||
|
||||
def restore_history(site_id,entry_id,history_id,expected_revision,actor):
|
||||
if type(expected_revision) is not int:raise HTTPException(400,'必须指定当前草稿修订号')
|
||||
previous=history_snapshot(site_id,entry_id,history_id)
|
||||
# Optimistic revision is enforced by write(). Restore never directly alters
|
||||
# published content and cannot bypass a conflicting published page.
|
||||
plan={**previous['plan'],'expected_revision':expected_revision}
|
||||
result=write(site_id,entry_id,plan,actor)
|
||||
return {**result,'restored_from':history_id,'requires_publish':True}
|
||||
|
||||
|
||||
@router.get('/{site_id}/pages/{entry_id}/studio-history')
|
||||
def api_history(request:Request,site_id:str,entry_id:str,limit:int=30):
|
||||
enterprise.require(request,site_id,'viewer')
|
||||
return history(site_id,entry_id,limit)
|
||||
|
||||
|
||||
@router.get('/{site_id}/pages/{entry_id}/studio-history/{history_id}')
|
||||
def api_history_snapshot(request:Request,site_id:str,entry_id:str,history_id:str):
|
||||
enterprise.require(request,site_id,'viewer')
|
||||
return history_snapshot(site_id,entry_id,history_id)
|
||||
|
||||
|
||||
@router.post('/{site_id}/pages/{entry_id}/studio-history/{history_id}/restore')
|
||||
def api_history_restore(request:Request,site_id:str,entry_id:str,history_id:str,payload:dict=Body(...)):
|
||||
enterprise.require(request,site_id,'editor')
|
||||
return restore_history(site_id,entry_id,history_id,payload.get('expected_revision'),enterprise.actor(request))
|
||||
|
||||
|
||||
@router.get('/{site_id}/pages/{entry_id}/studio-draft')
|
||||
def api_read(request:Request,site_id:str,entry_id:str):
|
||||
enterprise.require(request,site_id,'viewer')
|
||||
@@ -115,7 +183,7 @@ def api_read(request:Request,site_id:str,entry_id:str):
|
||||
@router.put('/{site_id}/pages/{entry_id}/studio-draft')
|
||||
def api_write(request:Request,site_id:str,entry_id:str,payload:dict=Body(...)):
|
||||
enterprise.require(request,site_id,'editor')
|
||||
return write(site_id,entry_id,payload)
|
||||
return write(site_id,entry_id,payload,enterprise.actor(request))
|
||||
|
||||
|
||||
@router.post('/{site_id}/pages/{entry_id}/studio-draft/publish')
|
||||
|
||||
Reference in New Issue
Block a user