3c57c167f2
导入 SiteForge Factory V5 基线源码: - FastAPI + SQLite + Docker Compose(Web + Worker)架构 - 四套商业主题 × 14 页面模板(1.1.0,含历史版本快照) - 四种建站模式骨架 + 迁移审计 + 发布门禁 - 52 项 pytest 测试 - 补全计划主文档 docs/superpowers/plans/ Co-Authored-By: Claude Code <noreply@anthropic.com>
339 lines
18 KiB
Python
339 lines
18 KiB
Python
"""SiteForge team accounts, RBAC, content history, audit, jobs, migration baseline and release governance.
|
||
|
||
This module is deliberately database-backed and independent of the frontend. It
|
||
extends the existing site factory without allowing editors to bypass permissions.
|
||
"""
|
||
import csv
|
||
import hashlib
|
||
import hmac
|
||
import io
|
||
import json
|
||
import os
|
||
import secrets
|
||
import sqlite3
|
||
import uuid
|
||
from pathlib import Path
|
||
from urllib.parse import urlparse
|
||
from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Body
|
||
from fastapi.responses import Response
|
||
from pydantic import BaseModel, Field
|
||
from . import db
|
||
from .core import route_path
|
||
|
||
router = APIRouter(prefix='/api/enterprise', tags=['Enterprise'])
|
||
ROLES = ('owner','editor','reviewer','viewer')
|
||
ACCESS = {'viewer':0, 'reviewer':1, 'editor':2, 'owner':3, 'superadmin':4}
|
||
|
||
def setup():
|
||
with db.connect() as c:
|
||
c.executescript('''
|
||
CREATE TABLE IF NOT EXISTS team_users (
|
||
id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
|
||
display_name TEXT NOT NULL, disabled INTEGER NOT NULL DEFAULT 0,
|
||
created_at TEXT NOT NULL);
|
||
CREATE TABLE IF NOT EXISTS team_memberships (
|
||
user_id TEXT NOT NULL REFERENCES team_users(id) ON DELETE CASCADE,
|
||
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||
role TEXT NOT NULL, PRIMARY KEY(user_id,site_id));
|
||
CREATE TABLE IF NOT EXISTS audit_events (
|
||
id TEXT PRIMARY KEY, actor TEXT NOT NULL, site_id TEXT, operation TEXT NOT NULL,
|
||
path TEXT NOT NULL, status INTEGER NOT NULL, created_at TEXT NOT NULL);
|
||
CREATE INDEX IF NOT EXISTS idx_audit_time ON audit_events(created_at);
|
||
CREATE TABLE IF NOT EXISTS content_revisions (
|
||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL, entry_id TEXT NOT NULL,
|
||
actor TEXT NOT NULL, action TEXT NOT NULL, snapshot_json TEXT NOT NULL,
|
||
created_at TEXT NOT NULL);
|
||
CREATE INDEX IF NOT EXISTS idx_revision_entry ON content_revisions(site_id,entry_id,created_at);
|
||
CREATE TABLE IF NOT EXISTS build_jobs (
|
||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||
kind TEXT NOT NULL, state TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0,
|
||
error TEXT NOT NULL DEFAULT '', result_json TEXT NOT NULL DEFAULT '{}',
|
||
created_by TEXT NOT NULL, created_at TEXT NOT NULL, started_at TEXT, finished_at TEXT,
|
||
retry_at TEXT);
|
||
CREATE INDEX IF NOT EXISTS idx_build_jobs_state ON build_jobs(state,created_at);
|
||
CREATE TABLE IF NOT EXISTS migration_baseline (
|
||
site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||
route TEXT NOT NULL, seo_title TEXT NOT NULL DEFAULT '',
|
||
description TEXT NOT NULL DEFAULT '', canonical TEXT NOT NULL DEFAULT '',
|
||
status INTEGER NOT NULL DEFAULT 200, PRIMARY KEY(site_id,route));
|
||
CREATE TABLE IF NOT EXISTS release_history (
|
||
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||
build_id TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL,
|
||
created_at TEXT NOT NULL);
|
||
''')
|
||
# Lightweight forward migration for V2-V5 databases created before
|
||
# delayed job retries were introduced.
|
||
columns={row['name'] for row in c.execute('PRAGMA table_info(build_jobs)').fetchall()}
|
||
if 'retry_at' not in columns:
|
||
c.execute('ALTER TABLE build_jobs ADD COLUMN retry_at TEXT')
|
||
|
||
def uid(): return uuid.uuid4().hex[:16]
|
||
|
||
def hash_password(password, salt=None):
|
||
if len(password)<12: raise ValueError('密码至少12位')
|
||
salt=salt or secrets.token_bytes(16)
|
||
digest=hashlib.scrypt(password.encode(),salt=salt,n=2**14,r=8,p=1,dklen=32)
|
||
return f'scrypt$16384$8$1${salt.hex()}${digest.hex()}'
|
||
|
||
def check_password(password, encoded):
|
||
try:
|
||
algorithm,n,r,p,salt,expected=encoded.split('$')
|
||
if algorithm!='scrypt':return False
|
||
result=hashlib.scrypt(password.encode(),salt=bytes.fromhex(salt),n=int(n),r=int(r),p=int(p),dklen=32)
|
||
return hmac.compare_digest(result,bytes.fromhex(expected))
|
||
except (ValueError,TypeError,MemoryError):return False
|
||
|
||
def actor(request):
|
||
return request.state.actor if hasattr(request.state,'actor') else 'system'
|
||
|
||
def level(request, site_id=None):
|
||
user=getattr(request.state,'account_id',None)
|
||
if not user: return ACCESS['superadmin'] if getattr(request.state,'is_superadmin',False) else -1
|
||
if not site_id:return ACCESS['viewer']
|
||
item=db.one('SELECT role FROM team_memberships WHERE site_id=? AND user_id=?',(site_id,user))
|
||
return ACCESS.get(item['role'],-1) if item else -1
|
||
|
||
def require(request, site_id=None, role='viewer'):
|
||
if level(request,site_id)<ACCESS[role]:raise HTTPException(403,'没有访问权限')
|
||
|
||
def user_by_name(username):
|
||
return db.one('SELECT * FROM team_users WHERE username=?',(username,))
|
||
|
||
def account_from_session(session):
|
||
if session.get('sub')=='admin':return {'actor':'admin','id':None,'superadmin':True}
|
||
if session.get('sub')!='user':return None
|
||
account=db.one('SELECT id,username,disabled FROM team_users WHERE id=?',(session.get('user_id'),))
|
||
if not account or account['disabled']:return None
|
||
return {'actor':account['username'],'id':account['id'],'superadmin':False}
|
||
|
||
def log_event(actor_name,path,status,method,site_id=None):
|
||
try:
|
||
db.insert('INSERT INTO audit_events VALUES (?,?,?,?,?,?,?)',(uid(),actor_name,site_id,method,path,status,db.now()))
|
||
except sqlite3.Error:pass
|
||
|
||
def save_revision(site_id,entry_id,actor_name,action):
|
||
row=db.one('SELECT * FROM entries WHERE site_id=? AND id=?',(site_id,entry_id))
|
||
if row:
|
||
db.insert('INSERT INTO content_revisions VALUES (?,?,?,?,?,?,?)',
|
||
(uid(),site_id,entry_id,actor_name,action,json.dumps(row,ensure_ascii=False),db.now()))
|
||
|
||
class UserCreate(BaseModel):
|
||
username:str=Field(min_length=3,max_length=70,pattern=r'^[a-zA-Z0-9._-]+$')
|
||
display_name:str=Field(min_length=1,max_length=90)
|
||
password:str=Field(min_length=12,max_length=200)
|
||
|
||
class AssignRole(BaseModel):
|
||
user_id:str
|
||
role:str
|
||
|
||
@router.get('/team/users')
|
||
def list_users(request:Request):
|
||
require(request,role='owner')
|
||
if not request.state.is_superadmin:raise HTTPException(403)
|
||
return db.rows('SELECT id,username,display_name,disabled,created_at FROM team_users ORDER BY created_at DESC')
|
||
|
||
@router.post('/team/users',status_code=201)
|
||
def create_user(request:Request,payload:UserCreate):
|
||
if not request.state.is_superadmin:raise HTTPException(403)
|
||
try:
|
||
person=uid()
|
||
db.insert('INSERT INTO team_users(id,username,password_hash,display_name,created_at) VALUES (?,?,?,?,?)',
|
||
(person,payload.username,hash_password(payload.password),payload.display_name,db.now()))
|
||
except sqlite3.IntegrityError:raise HTTPException(409,'用户名已存在')
|
||
return {'id':person,'username':payload.username,'display_name':payload.display_name}
|
||
|
||
@router.patch('/team/users/{user_id}/disable')
|
||
def disable_user(request:Request,user_id:str,payload:dict=Body(default_factory=dict)):
|
||
if not request.state.is_superadmin:raise HTTPException(403)
|
||
if not db.one('SELECT id FROM team_users WHERE id=?',(user_id,)):raise HTTPException(404)
|
||
db.execute('UPDATE team_users SET disabled=? WHERE id=?',(int(bool(payload.get('disabled',True))),user_id))
|
||
return {'ok':True}
|
||
|
||
@router.put('/sites/{site_id}/members')
|
||
def grant_site(request:Request,site_id:str,payload:AssignRole):
|
||
require(request,site_id,'owner')
|
||
if payload.role not in ROLES:raise HTTPException(400,'不支持该角色')
|
||
if not db.one('SELECT id FROM team_users WHERE id=?',(payload.user_id,)):raise HTTPException(404,'用户不存在')
|
||
db.insert('''INSERT INTO team_memberships(user_id,site_id,role) VALUES (?,?,?)
|
||
ON CONFLICT(user_id,site_id) DO UPDATE SET role=excluded.role''',(payload.user_id,site_id,payload.role))
|
||
return {'assigned':True}
|
||
|
||
@router.get('/sites/{site_id}/members')
|
||
def site_members(request:Request,site_id:str):
|
||
require(request,site_id,'owner')
|
||
return db.rows('''SELECT u.id,u.username,u.display_name,m.role FROM team_memberships m
|
||
JOIN team_users u ON u.id=m.user_id WHERE m.site_id=?''',(site_id,))
|
||
|
||
@router.get('/audit')
|
||
def audits(request:Request,site_id:str='',limit:int=100):
|
||
if site_id:require(request,site_id,'owner')
|
||
elif not request.state.is_superadmin:raise HTTPException(403)
|
||
limit=min(max(limit,1),250)
|
||
if site_id:return db.rows('SELECT * FROM audit_events WHERE site_id=? ORDER BY created_at DESC,id DESC LIMIT ?',(site_id,limit))
|
||
return db.rows('SELECT * FROM audit_events ORDER BY created_at DESC,id DESC LIMIT ?',(limit,))
|
||
|
||
@router.get('/sites/{site_id}/entries/{entry_id}/revisions')
|
||
def revisions(request:Request,site_id:str,entry_id:str):
|
||
require(request,site_id,'viewer')
|
||
return db.rows('SELECT id,actor,action,created_at FROM content_revisions WHERE site_id=? AND entry_id=? ORDER BY created_at DESC,id DESC LIMIT 100',(site_id,entry_id))
|
||
|
||
@router.get('/sites/{site_id}/revisions/{revision_id}')
|
||
def revision_detail(request:Request,site_id:str,revision_id:str):
|
||
require(request,site_id,'viewer')
|
||
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
|
||
if not row:raise HTTPException(404)
|
||
return {'id':revision_id,'entry':json.loads(row['snapshot_json']),'actor':row['actor']}
|
||
|
||
@router.post('/sites/{site_id}/revisions/{revision_id}/restore')
|
||
def revision_restore(request:Request,site_id:str,revision_id:str):
|
||
require(request,site_id,'editor')
|
||
row=db.one('SELECT * FROM content_revisions WHERE site_id=? AND id=?',(site_id,revision_id))
|
||
if not row:raise HTTPException(404)
|
||
payload=json.loads(row['snapshot_json'])
|
||
entry_id=payload['id']
|
||
save_revision(site_id,entry_id,actor(request),'pre_restore')
|
||
columns=['type','title','route','slug','summary','body','cover','seo_title','seo_description','canonical','robots','meta_json','status']
|
||
with db.connect() as c:
|
||
c.execute(f'''UPDATE entries SET {','.join(f'{col}=?' for col in columns)},updated_at=? WHERE site_id=? AND id=?''',
|
||
[payload[k] for k in columns]+[db.now(),site_id,entry_id])
|
||
if c.total_changes==0:raise HTTPException(404,'原页面已删除,请先创建')
|
||
return {'restored':True,'entry_id':entry_id}
|
||
|
||
@router.post('/sites/{site_id}/jobs',status_code=202)
|
||
def enqueue(request:Request,site_id:str,payload:dict=Body(default_factory=dict)):
|
||
require(request,site_id,'editor')
|
||
kind=payload.get('kind','build')
|
||
if kind!='build':raise HTTPException(400,'当前任务队列支持构建任务')
|
||
row_id=uid()
|
||
db.insert('INSERT INTO build_jobs(id,site_id,kind,state,created_by,created_at) VALUES (?,?,?,?,?,?)',
|
||
(row_id,site_id,kind,'queued',actor(request),db.now()))
|
||
return {'id':row_id,'state':'queued'}
|
||
|
||
@router.get('/sites/{site_id}/jobs')
|
||
def jobs(request:Request,site_id:str):
|
||
require(request,site_id,'viewer')
|
||
return db.rows('SELECT * FROM build_jobs WHERE site_id=? ORDER BY created_at DESC LIMIT 50',(site_id,))
|
||
|
||
class BaselineItem(BaseModel):
|
||
route:str
|
||
seo_title:str=''
|
||
description:str=''
|
||
canonical:str=''
|
||
status:int=200
|
||
|
||
@router.put('/sites/{site_id}/baseline')
|
||
def set_baseline(request:Request,site_id:str,payload:list[BaselineItem]):
|
||
require(request,site_id,'editor')
|
||
if len(payload)>20000:raise HTTPException(413,'URL 基线最多20000条')
|
||
routes=set();rows=[]
|
||
for item in payload:
|
||
route=route_path(item.route)
|
||
if route in routes:raise HTTPException(400,'存在重复 URL:'+route)
|
||
routes.add(route)
|
||
rows.append((site_id,route,item.seo_title,item.description,item.canonical,item.status))
|
||
with db.connect() as c:
|
||
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
|
||
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',rows)
|
||
return {'saved':len(rows)}
|
||
|
||
@router.post('/sites/{site_id}/baseline/snapshot')
|
||
def baseline_snapshot(request:Request,site_id:str):
|
||
require(request,site_id,'editor')
|
||
entries=db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))
|
||
with db.connect() as c:
|
||
c.execute('DELETE FROM migration_baseline WHERE site_id=?',(site_id,))
|
||
c.executemany('INSERT INTO migration_baseline VALUES (?,?,?,?,?,?)',
|
||
[(site_id,x['route'],x['seo_title'],x['seo_description'],x['canonical'],200) for x in entries])
|
||
return {'captured':len(entries)}
|
||
|
||
def diff_baseline(site_id):
|
||
baseline=db.rows('SELECT * FROM migration_baseline WHERE site_id=?',(site_id,))
|
||
entries={x['route']:x for x in db.rows('SELECT route,seo_title,seo_description,canonical FROM entries WHERE site_id=?',(site_id,))}
|
||
redirects={x['old_path']:x['new_path'] for x in db.rows('SELECT old_path,new_path FROM redirects WHERE site_id=?',(site_id,))}
|
||
problems=[]
|
||
for old in baseline:
|
||
new=entries.get(old['route'])
|
||
if not new:
|
||
problems.append({'route':old['route'],'field':'url','severity':'error','from':'200','to':f'redirect→{redirects[old["route"]]}' if old['route'] in redirects else 'missing'})
|
||
continue
|
||
for oldcol,newcol in [('seo_title','seo_title'),('description','seo_description'),('canonical','canonical')]:
|
||
if old[oldcol] and old[oldcol]!=new[newcol]:
|
||
problems.append({'route':old['route'],'field':oldcol,'severity':'warning','from':old[oldcol],'to':new[newcol]})
|
||
return {'baseline_count':len(baseline),'current_count':len(entries),'issues':problems,
|
||
'errors':sum(x['severity']=='error' for x in problems),'warnings':sum(x['severity']=='warning' for x in problems),
|
||
'passed':not any(x['severity']=='error' for x in problems)}
|
||
|
||
@router.get('/sites/{site_id}/baseline/report')
|
||
def baseline_report(request:Request,site_id:str):
|
||
require(request,site_id,'viewer')
|
||
return diff_baseline(site_id)
|
||
|
||
@router.get('/sites/{site_id}/baseline/export.csv')
|
||
def baseline_export(request:Request,site_id:str):
|
||
require(request,site_id,'viewer')
|
||
records=db.rows('SELECT route,seo_title,description,canonical,status FROM migration_baseline WHERE site_id=? ORDER BY route',(site_id,))
|
||
dest=io.StringIO(); writer=csv.DictWriter(dest,fieldnames=['route','seo_title','description','canonical','status']);writer.writeheader();writer.writerows(records)
|
||
return Response('\ufeff'+dest.getvalue(),media_type='text/csv',headers={'Content-Disposition':'attachment; filename=siteforge-seo-baseline.csv'})
|
||
|
||
@router.get('/sites/{site_id}/releases')
|
||
def release_list(request:Request,site_id:str):
|
||
require(request,site_id,'viewer')
|
||
return db.rows('SELECT * FROM release_history WHERE site_id=? ORDER BY created_at DESC',(site_id,))
|
||
|
||
@router.post('/sites/{site_id}/releases/{build_id}/rollback')
|
||
def release_rollback(request:Request,site_id:str,build_id:str):
|
||
require(request,site_id,'owner')
|
||
from .main import publish_local
|
||
response=publish_local(site_id,build_id)
|
||
db.insert('INSERT INTO release_history VALUES (?,?,?,?,?,?)',(uid(),site_id,build_id,actor(request),'rollback',db.now()))
|
||
return response
|
||
|
||
# Design tokens are site-specific and independent of the reusable theme package.
|
||
DESIGN_DEFAULTS = {'font':'system','heading_scale':1.0,'radius':12,'section_spacing':72,'container_width':1200,'button_style':'rounded','header_style':'standard'}
|
||
|
||
@router.get('/sites/{site_id}/design-tokens')
|
||
def get_tokens(request:Request,site_id:str):
|
||
require(request,site_id,'viewer')
|
||
row=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
|
||
if not row:raise HTTPException(404)
|
||
settings=json.loads(row['settings_json'])
|
||
return {**DESIGN_DEFAULTS,**settings.get('design_tokens',{})}
|
||
|
||
@router.put('/sites/{site_id}/design-tokens')
|
||
def save_tokens(request:Request,site_id:str,payload:dict=Body(...)):
|
||
require(request,site_id,'editor')
|
||
tokens={**DESIGN_DEFAULTS}
|
||
if set(payload)-set(tokens):raise HTTPException(400,'存在不支持的设计变量')
|
||
tokens.update(payload)
|
||
if tokens['font'] not in ('system','serif','modern'):raise HTTPException(400,'不支持字体')
|
||
if tokens['button_style'] not in ('rounded','square','pill'):raise HTTPException(400,'不支持按钮形状')
|
||
if tokens['header_style'] not in ('standard','compact'):raise HTTPException(400,'不支持导航布局')
|
||
for field,lo,hi in [('heading_scale',0.75,1.5),('radius',0,30),('section_spacing',32,140),('container_width',900,1600)]:
|
||
if not isinstance(tokens[field],(float,int)) or not lo<=tokens[field]<=hi:raise HTTPException(400,'设计变量超出范围: '+field)
|
||
record=db.one('SELECT settings_json FROM sites WHERE id=?',(site_id,))
|
||
if not record:raise HTTPException(404)
|
||
settings=json.loads(record['settings_json']);settings['design_tokens']=tokens
|
||
db.execute('UPDATE sites SET settings_json=?,updated_at=? WHERE id=?',(json.dumps(settings,ensure_ascii=False),db.now(),site_id))
|
||
return tokens
|
||
|
||
@router.get('/sites/{site_id}/builds/{build_id}/quality')
|
||
def quality_report(request:Request,site_id:str,build_id:str):
|
||
require(request,site_id,'viewer')
|
||
if not db.one('SELECT id FROM builds WHERE site_id=? AND id=?',(site_id,build_id)):raise HTTPException(404)
|
||
from .quality import report
|
||
return report(site_id,build_id)
|
||
|
||
@router.post('/sites/{site_id}/analyze')
|
||
def scan_source(request:Request,site_id:str,payload:dict=Body(...)):
|
||
require(request,site_id,'editor')
|
||
from .adapters import analyze
|
||
try:return analyze(str(payload.get('url','')))
|
||
except Exception as exc:raise HTTPException(400,str(exc)[:300])
|
||
|
||
@router.post('/sites/{site_id}/wordpress-rest')
|
||
def wordpress_rest(request:Request,site_id:str,payload:dict=Body(...)):
|
||
require(request,site_id,'editor')
|
||
from .adapters import import_wordpress_rest
|
||
try:return import_wordpress_rest(site_id,str(payload.get('origin','')),int(payload.get('max_items',150)),str(payload.get('on_conflict','skip')))
|
||
except Exception as exc:raise HTTPException(400,str(exc)[:300])
|