feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化

Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+112 -3
View File
@@ -1,19 +1,23 @@
from fastapi import APIRouter, HTTPException, Query, Depends
from sqlalchemy.orm import Session
from sqlalchemy import or_
from pathlib import Path
import os
from datetime import datetime, date
from ..database import get_db
from ..models import User, Article
from .auth import get_current_user
from ..models import User, Article, Topic
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/articles", tags=["articles"])
@router.get("/drafts")
def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""从 articles 表列出草稿"""
query = db.query(Article)
query = db.query(Article).join(Topic, Article.topic_id == Topic.id)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if topic_id:
query = query.filter(Article.topic_id == topic_id)
articles = query.order_by(Article.created_at.desc()).all()
@@ -22,9 +26,114 @@ def list_drafts(topic_id: str = None, current_user: User = Depends(get_current_u
result.setdefault(a.platform, []).append({"id": a.id, "topic_id": a.topic_id, "status": a.status})
return {"articles": result}
@router.get("/list")
def list_articles(
platform: str = None,
status: str = None,
search: str = None,
topic_id: str = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""列出所有文章(含选题标题)"""
query = db.query(Article, Topic.title.label("topic_title")).join(Topic, Article.topic_id == Topic.id)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if platform:
query = query.filter(Article.platform == platform)
if status:
query = query.filter(Article.status == status)
if topic_id:
query = query.filter(Article.topic_id == topic_id)
if search:
query = query.filter(
or_(Article.id.ilike(f"%{search}%"), Topic.title.ilike(f"%{search}%"))
)
rows = query.order_by(Article.created_at.desc()).all()
return {
"articles": [
{
"id": r.Article.id,
"topic_id": r.Article.topic_id,
"topic_title": r.topic_title,
"platform": r.Article.platform,
"status": r.Article.status,
"compliance_score": r.Article.compliance_score,
"word_count": r.Article.word_count,
"created_at": r.Article.created_at.isoformat() if r.Article.created_at else None,
"images": r.Article.images or {},
}
for r in rows
]
}
@router.get("/detail/{article_id}")
def get_article_detail(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取文章详情"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Article not found")
return {
"id": article.id,
"topic_id": article.topic_id,
"topic_title": topic.title if topic else None,
"platform": article.platform,
"file_path": article.file_path,
"status": article.status,
"compliance_score": article.compliance_score,
"word_count": article.word_count,
"outline": article.outline,
"html_content": article.html_content,
"images": article.images or {},
"created_at": article.created_at.isoformat() if article.created_at else None,
}
@router.delete("/{article_id}")
def delete_article(article_id: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""删除文章"""
article = db.query(Article).filter(Article.id == article_id).first()
if not article:
raise HTTPException(status_code=404, detail="Article not found")
topic = db.query(Topic).filter(Topic.id == article.topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Article not found")
file_path = Path(article.file_path) if article.file_path else None
db.delete(article)
db.commit()
if file_path and file_path.exists():
try:
file_path.unlink()
except OSError:
pass
return {"detail": "deleted"}
@router.get("/{topic_id}/images")
def get_article_images(topic_id: str, platform: str = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""获取某选题在各平台的配图路径"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
query = db.query(Article).filter(Article.topic_id == topic_id)
if platform:
query = query.filter(Article.platform == platform)
articles = query.all()
return {
topic_id: {
a.platform: (a.images or {})
for a in articles
}
}
@router.get("/{topic_id}/preview")
def preview_article(topic_id: str, platform: str = "zhihu", current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
"""从 articles 表预览某选题的 HTML 内容"""
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if topic and current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
article_id = f"{platform}_{topic_id}"
article = db.query(Article).filter(Article.id == article_id).first()
if not article or not article.html_content: