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
+124 -4
View File
@@ -3,16 +3,19 @@ from sqlalchemy.orm import Session
from typing import List
import bcrypt
from typing import Optional
from pydantic import BaseModel
from ..database import get_db
from ..models import User
from ..models import User, Topic, SystemConfig
from ..schemas import UserCreate, UserUpdate, UserResponse
from ..core.audit_logger import audit_log
import json
router = APIRouter(prefix="/api/admin", tags=["admin"])
def get_current_admin(request: Request, db: Session = Depends(get_db)):
"""依赖项:验证管理员权限"""
from .auth import verify_token
from .auth import verify_token, org_filter
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未提供认证令牌")
@@ -29,7 +32,11 @@ def list_users(
admin_user: User = Depends(get_current_admin)
):
"""获取用户列表(管理员)"""
users = db.query(User).all()
q = db.query(User)
of = org_filter(admin_user, User)
if of is not True:
q = q.filter(of)
users = q.all()
return [UserResponse.from_orm(u) for u in users]
@router.post("/users", response_model=UserResponse)
@@ -51,7 +58,8 @@ def create_user(
user = User(
username=user_data.username,
password_hash=hashed_password,
role=user_data.role or "user"
role=user_data.role or "user",
org_id=user_data.org_id or admin_user.org_id or "default"
)
db.add(user)
db.commit()
@@ -144,3 +152,115 @@ def delete_user(
db=db
)
return {"message": "删除成功"}
# ---------- Org Management ----------
class OrgCreate(BaseModel):
org_id: str
name: str
description: Optional[str] = None
class OrgUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@router.get("/orgs")
def list_orgs(
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
distinct_orgs = db.query(User.org_id).distinct().all()
result = []
for (org_id,) in distinct_orgs:
if not org_id:
continue
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
meta = json.loads(cfg.value) if cfg and cfg.value else {}
user_count = db.query(User).filter(User.org_id == org_id).count()
topic_count = db.query(Topic).filter(Topic.org_id == org_id).count()
result.append({
"org_id": org_id,
"name": meta.get("name", org_id),
"description": meta.get("description", ""),
"user_count": user_count,
"topic_count": topic_count,
"is_default": org_id == "default"
})
return result
@router.post("/orgs")
def create_org(
data: OrgCreate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
existing = db.query(SystemConfig).filter(SystemConfig.key == f"org:{data.org_id}").first()
if existing:
raise HTTPException(status_code=400, detail="组织已存在")
cfg = SystemConfig(
key=f"org:{data.org_id}",
value=json.dumps({"name": data.name, "description": data.description or ""}, ensure_ascii=False),
description=f"Organization: {data.org_id}"
)
db.add(cfg)
db.commit()
audit_log(
action="create_org", user=admin_user,
resource_type="org", resource_id=data.org_id,
details=data.model_dump(),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"org_id": data.org_id, "name": data.name, "description": data.description or ""}
@router.put("/orgs/{org_id}")
def update_org(
org_id: str,
data: OrgUpdate,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
if not cfg:
raise HTTPException(status_code=404, detail="组织不存在")
meta = json.loads(cfg.value) if cfg.value else {}
if data.name is not None:
meta["name"] = data.name
if data.description is not None:
meta["description"] = data.description
cfg.value = json.dumps(meta, ensure_ascii=False)
db.commit()
audit_log(
action="update_org", user=admin_user,
resource_type="org", resource_id=org_id,
details=data.model_dump(exclude_unset=True),
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"org_id": org_id, **meta}
@router.delete("/orgs/{org_id}")
def delete_org(
org_id: str,
request: Request,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin)
):
if org_id == "default":
raise HTTPException(status_code=400, detail="不能删除默认组织")
cfg = db.query(SystemConfig).filter(SystemConfig.key == f"org:{org_id}").first()
if cfg:
db.delete(cfg)
db.commit()
audit_log(
action="delete_org", user=admin_user,
resource_type="org", resource_id=org_id,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return {"message": "删除成功"}
+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:
+9 -1
View File
@@ -32,6 +32,7 @@ def create_token(user: User) -> str:
"sub": str(user.id),
"username": user.username,
"role": user.role,
"org_id": user.org_id,
"exp": expire
}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
@@ -66,7 +67,8 @@ def login(login_data: LoginRequest, request: Request, db: Session = Depends(get_
user = User(
username=DEFAULT_ADMIN_USERNAME,
password_hash=hashed.decode('utf-8'),
role="admin"
role="admin",
org_id="default"
)
db.add(user)
db.commit()
@@ -147,6 +149,12 @@ def get_current_user(request: Request, db: Session = Depends(get_db)) -> User:
user = verify_token(token, db)
return user
def org_filter(current_user: User, model):
"""返回 org_id 过滤条件。管理员看到全部数据,普通用户仅限本组织。"""
if current_user.role == "admin":
return True # no filter
return model.org_id == current_user.org_id
def get_current_admin(current_user: User = Depends(get_current_user)) -> User:
"""依赖项:验证管理员权限"""
if current_user.role != "admin":
+45 -5
View File
@@ -10,11 +10,27 @@ from ..schemas import (
ContentCalendarCreate, ContentCalendarUpdate, ContentCalendarResponse,
ContentCalendarBase
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/calendar", tags=["calendar"])
PLATFORM_ICONS = {
"zhihu": "",
"wechat": "",
"xiaohongshu": ""
}
def enrich_entry(entry, db):
d = entry.to_dict()
d["platform_icon"] = PLATFORM_ICONS.get(entry.platform, "📝")
d["topic_status"] = None
if entry.topic_id:
topic = db.query(Topic).filter(Topic.id == entry.topic_id).first()
if topic:
d["topic_status"] = topic.status
return d
@router.get("", response_model=List[ContentCalendarResponse])
def get_calendar(
year: Optional[int] = Query(None),
@@ -28,10 +44,17 @@ def get_calendar(
start = date(year, month, 1)
last_day = monthrange(year, month)[1]
end = date(year, month, last_day)
return db.query(ContentCalendar).filter(
base = db.query(ContentCalendar).filter(
ContentCalendar.planned_date >= start,
ContentCalendar.planned_date <= end
).order_by(ContentCalendar.planned_date).all()
)
of = org_filter(current_user, Topic)
if of is not True:
base = base.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
entries = base.order_by(ContentCalendar.planned_date).all()
return [enrich_entry(e, db) for e in entries]
@router.get("/entries", response_model=List[ContentCalendarResponse])
@@ -45,6 +68,11 @@ def list_entries(
current_user=Depends(get_current_user)
):
query = db.query(ContentCalendar)
of = org_filter(current_user, Topic)
if of is not True:
query = query.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
if start_date:
query = query.filter(ContentCalendar.planned_date >= start_date)
if end_date:
@@ -66,6 +94,8 @@ def create_entry(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry = ContentCalendar(**data.model_dump())
db.add(entry)
@@ -135,6 +165,8 @@ def bind_topic_to_entry(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry.topic_id = topic_id
entry.title = topic.title
if topic.field_name:
@@ -154,10 +186,16 @@ def calendar_stats(
start = date(year, month, 1)
last_day = monthrange(year, month)[1]
end = date(year, month, last_day)
entries = db.query(ContentCalendar).filter(
q = db.query(ContentCalendar).filter(
ContentCalendar.planned_date >= start,
ContentCalendar.planned_date <= end
).all()
)
of = org_filter(current_user, Topic)
if of is not True:
q = q.outerjoin(Topic, ContentCalendar.topic_id == Topic.id).filter(
(ContentCalendar.topic_id.is_(None)) | (Topic.org_id == current_user.org_id)
)
entries = q.all()
stats = {"total": len(entries), "planned": 0, "published": 0, "delayed": 0, "cancelled": 0}
for e in entries:
@@ -177,6 +215,8 @@ def create_from_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
entry = ContentCalendar(
topic_id=topic_id,
field_id=topic.field_id,
+127
View File
@@ -0,0 +1,127 @@
"""采集管理 API:类别与信息源的 CRUD"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from typing import List
from pydantic import BaseModel
from typing import Optional
from ..database import get_db
from ..models import CollectorCategory, CollectorSource
from .auth import get_current_admin
router = APIRouter(prefix="/api/admin/collector", tags=["collector_mgmt"], dependencies=[Depends(get_current_admin)])
# ---------- Schemas ----------
class CategoryCreate(BaseModel):
name: str
description: Optional[str] = None
search_query: Optional[str] = None
sort_order: int = 0
is_active: bool = True
class CategoryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
search_query: Optional[str] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class SourceCreate(BaseModel):
category_id: Optional[int] = None
name: str
source_type: str
url: Optional[str] = None
query: Optional[str] = None
credibility: str = "medium"
focus: Optional[str] = None
is_active: bool = True
sort_order: int = 0
class SourceUpdate(BaseModel):
category_id: Optional[int] = None
name: Optional[str] = None
source_type: Optional[str] = None
url: Optional[str] = None
query: Optional[str] = None
credibility: Optional[str] = None
focus: Optional[str] = None
is_active: Optional[bool] = None
sort_order: Optional[int] = None
# ---------- Categories ----------
@router.get("/categories")
def list_categories(db: Session = Depends(get_db)):
cats = db.query(CollectorCategory).order_by(CollectorCategory.sort_order).all()
result = []
for c in cats:
d = c.to_dict()
d["source_count"] = db.query(CollectorSource).filter(CollectorSource.category_id == c.id).count()
result.append(d)
return result
@router.post("/categories", status_code=201)
def create_category(data: CategoryCreate, db: Session = Depends(get_db)):
existing = db.query(CollectorCategory).filter(CollectorCategory.name == data.name).first()
if existing:
raise HTTPException(400, "类别名已存在")
cat = CollectorCategory(**data.model_dump())
db.add(cat)
db.commit()
db.refresh(cat)
return cat.to_dict()
@router.put("/categories/{cat_id}")
def update_category(cat_id: int, data: CategoryUpdate, db: Session = Depends(get_db)):
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
if not cat:
raise HTTPException(404, "类别不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(cat, k, v)
db.commit()
db.refresh(cat)
return cat.to_dict()
@router.delete("/categories/{cat_id}")
def delete_category(cat_id: int, db: Session = Depends(get_db)):
cat = db.query(CollectorCategory).filter(CollectorCategory.id == cat_id).first()
if not cat:
raise HTTPException(404, "类别不存在")
db.delete(cat)
db.commit()
return {"ok": True}
# ---------- Sources ----------
@router.get("/sources")
def list_sources(category_id: Optional[int] = None, db: Session = Depends(get_db)):
q = db.query(CollectorSource).order_by(CollectorSource.sort_order)
if category_id is not None:
q = q.filter(CollectorSource.category_id == category_id)
return [s.to_dict() for s in q.all()]
@router.post("/sources", status_code=201)
def create_source(data: SourceCreate, db: Session = Depends(get_db)):
src = CollectorSource(**data.model_dump())
db.add(src)
db.commit()
db.refresh(src)
return src.to_dict()
@router.put("/sources/{src_id}")
def update_source(src_id: int, data: SourceUpdate, db: Session = Depends(get_db)):
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
if not src:
raise HTTPException(404, "信息源不存在")
for k, v in data.model_dump(exclude_unset=True).items():
setattr(src, k, v)
db.commit()
db.refresh(src)
return src.to_dict()
@router.delete("/sources/{src_id}")
def delete_source(src_id: int, db: Session = Depends(get_db)):
src = db.query(CollectorSource).filter(CollectorSource.id == src_id).first()
if not src:
raise HTTPException(404, "信息源不存在")
db.delete(src)
db.commit()
return {"ok": True}
+48 -17
View File
@@ -11,7 +11,7 @@ from ..schemas import (
ContentMetricsCreate, ContentMetricsUpdate, ContentMetricsResponse,
MetricsDashboard
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/metrics", tags=["metrics"])
@@ -24,18 +24,24 @@ def get_dashboard(
):
since = datetime.now() - timedelta(days=days)
total_topics = db.query(Topic).count()
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
raw_status = db.query(Topic.status, func.count()).group_by(Topic.status).all()
total_topics = topic_base.count()
raw_status = topic_base.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
topics_by_status = {}
for s, cnt in raw_status:
topics_by_status[s] = cnt
total_published = db.query(ContentMetrics).filter(
ContentMetrics.views > 0
).count()
metrics_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
metrics_q = metrics_q.filter(of)
total_published = metrics_q.filter(ContentMetrics.views > 0).count()
all_metrics = db.query(ContentMetrics).filter(
all_metrics = metrics_q.filter(
ContentMetrics.created_at >= since
).all()
@@ -45,11 +51,14 @@ def get_dashboard(
engagement_rates = [m.engagement_rate for m in all_metrics if m.views > 0]
avg_engagement = sum(engagement_rates) / len(engagement_rates) if engagement_rates else 0
top_topics_data = db.query(
top_q = db.query(
ContentMetrics.topic_id,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes")
).join(Topic).filter(
).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
top_q = top_q.filter(of)
top_topics_data = top_q.filter(
ContentMetrics.created_at >= since
).group_by(ContentMetrics.topic_id).order_by(desc("total_views")).limit(10).all()
@@ -63,7 +72,10 @@ def get_dashboard(
"total_likes": row.total_likes or 0,
})
recent_metrics = db.query(ContentMetrics).order_by(
recent_q = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
if of is not True:
recent_q = recent_q.filter(of)
recent_metrics = recent_q.order_by(
ContentMetrics.created_at.desc()
).limit(10).all()
@@ -93,13 +105,17 @@ def get_trend(
else:
date_format = func.date_trunc(group_by, ContentMetrics.created_at)
rows = db.query(
q = db.query(
date_format.label("period"),
func.sum(ContentMetrics.views).label("views"),
func.sum(ContentMetrics.likes).label("likes"),
func.sum(ContentMetrics.comments).label("comments"),
func.count(ContentMetrics.id).label("count")
).filter(
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.filter(
ContentMetrics.created_at >= since
).group_by(date_format).order_by(date_format).all()
@@ -123,7 +139,10 @@ def list_metrics(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
query = db.query(ContentMetrics)
query = db.query(ContentMetrics).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
query = query.filter(of_m)
if topic_id:
query = query.filter(ContentMetrics.topic_id == topic_id)
if platform:
@@ -140,6 +159,8 @@ def create_metric(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
existing = db.query(ContentMetrics).filter(
ContentMetrics.topic_id == data.topic_id,
@@ -204,6 +225,8 @@ def get_topic_metrics(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
return db.query(ContentMetrics).filter(
ContentMetrics.topic_id == topic_id
).order_by(ContentMetrics.created_at.desc()).all()
@@ -214,13 +237,17 @@ def get_metrics_by_platform(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
rows = db.query(
q = db.query(
ContentMetrics.platform,
func.sum(ContentMetrics.views).label("total_views"),
func.sum(ContentMetrics.likes).label("total_likes"),
func.sum(ContentMetrics.comments).label("total_comments"),
func.count(ContentMetrics.id).label("count")
).group_by(ContentMetrics.platform).all()
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
q = q.filter(of_m)
rows = q.group_by(ContentMetrics.platform).all()
return [
{
@@ -243,11 +270,15 @@ def recommend_topics_from_metrics(
current_user=Depends(get_current_user)
):
try:
high_performing = db.query(
hp_q = db.query(
ContentMetrics.topic_id,
func.avg(ContentMetrics.engagement_rate).label("avg_engagement"),
func.max(ContentMetrics.views).label("max_views")
).group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
).join(Topic, ContentMetrics.topic_id == Topic.id)
of_m = org_filter(current_user, Topic)
if of_m is not True:
hp_q = hp_q.filter(of_m)
high_performing = hp_q.group_by(ContentMetrics.topic_id).order_by(desc("avg_engagement")).limit(20).all()
recommendations = []
for row in high_performing:
+64 -30
View File
@@ -3,78 +3,112 @@
from fastapi import APIRouter, HTTPException, Depends, Request
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
from typing import Optional, List
from ..database import get_db
from ..models import Topic, PublishRecord, User
from sqlalchemy.orm import Session
from .auth import get_current_admin
from .auth import get_current_admin, org_filter
from ..core.audit_logger import audit_log
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
class PublishRequest(BaseModel):
topic_id: str
PLATFORM_LABELS = {
"zhihu": "知乎",
"wechat": "微信公众号",
"xiaohongshu": "小红书"
}
class PublishResponse(BaseModel):
class MultiPublishRequest(BaseModel):
topic_id: str
platforms: List[str] = ["zhihu", "wechat", "xiaohongshu"]
class PublishResult(BaseModel):
platform: str
platform_label: str
status: str
error_msg: Optional[str] = None
class MultiPublishResponse(BaseModel):
ok: bool
topic_id: str
results: List[PublishResult]
message: str
@router.post("/create", response_model=PublishResponse)
@router.post("/create", response_model=MultiPublishResponse)
async def create_publish_record(
req: PublishRequest,
req: MultiPublishRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin)
):
"""标记选题为已发布,并创建发布记录(管理员)"""
"""多平台发布选题(管理员)"""
try:
# 查找选题
topic = db.query(Topic).filter(Topic.id == req.topic_id).first()
q = db.query(Topic).filter(Topic.id == req.topic_id)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if not topic:
raise HTTPException(status_code=404, detail=f"选题 {req.topic_id} 不存在")
if topic.status not in ('ready', '待发布'):
raise HTTPException(status_code=400, detail=f"选题 {req.topic_id} 状态不是待发布(当前: {topic.status}")
# 更新选题状态
if not req.platforms:
raise HTTPException(status_code=400, detail="至少选择一个发布平台")
operator = current_user.username
results = []
for platform in req.platforms:
try:
record = PublishRecord(
topic_id=req.topic_id,
platform=platform,
action='publish',
status='success',
operator=operator,
description=f"选题 {req.topic_id} 发布到 {PLATFORM_LABELS.get(platform, platform)}"
)
db.add(record)
results.append(PublishResult(
platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform),
status='success'
))
except Exception as e:
results.append(PublishResult(
platform=platform,
platform_label=PLATFORM_LABELS.get(platform, platform),
status='failed',
error_msg=str(e)
))
topic.status = '已发布'
topic.updated_at = datetime.now()
topic.published_at = datetime.now().date() # 设置发布时间为今天
# 创建发布记录
operator = current_user.username
record = PublishRecord(
topic_id=req.topic_id,
platform='all',
action='publish',
status='success',
operator=operator,
description=f"选题 {req.topic_id} 已发布"
)
db.add(record)
topic.published_at = datetime.now().date()
db.commit()
# 强制刷新会话缓存,确保后续读取最新数据
db.expire_all()
db.refresh(topic)
# 审计日志
audit_log(
action="publish",
user=current_user,
resource_type="topic",
resource_id=req.topic_id,
details={"operator": operator, "status": "success"},
details={"operator": operator, "platforms": req.platforms, "results": [r.model_dump() for r in results]},
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("user-agent", ""),
db=db
)
return PublishResponse(
ok=True,
success_count = sum(1 for r in results if r.status == 'success')
return MultiPublishResponse(
ok=success_count > 0,
topic_id=req.topic_id,
message=f"选题 {req.topic_id} 已成功发布"
results=results,
message=f"选题 {req.topic_id} 发布完成({success_count}/{len(results)} 平台成功)"
)
except HTTPException:
raise
+24 -12
View File
@@ -13,7 +13,7 @@ from ..core.generator import run_creator
from ..core.optimizer import run_optimizer
from ..core.sync import sync_all_topics
from ..core.scheduler import scheduler
from .auth import get_current_user
from .auth import get_current_user, org_filter
PROJECT_ROOT = Path(__file__).resolve().parents[4]
if os.getenv('PROJECT_ROOT'):
@@ -23,9 +23,9 @@ LOGS_DIR = PROJECT_ROOT / "automation" / "logs"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/system", tags=["system"])
def _aggregate_status_counts(db: Session):
def _aggregate_status_counts(q):
"""聚合状态计数,兼容中英文状态值"""
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
raw = q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
mapping = {
'pending': ['pending', '待处理'],
'review': ['review', '待审查'],
@@ -43,7 +43,7 @@ def _aggregate_status_counts(db: Session):
@router.get("/status")
def get_status(db: Session = Depends(get_db)):
total = db.query(Topic).count()
counts = _aggregate_status_counts(db)
counts = _aggregate_status_counts(db.query(Topic))
today = date.today()
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
return {
@@ -58,7 +58,7 @@ def get_status(db: Session = Depends(get_db)):
}
@router.post("/generate/run", dependencies=[Depends(get_current_user)])
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db)):
def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
logger.info(f"Received topic_id={topic_id}")
try:
result = run_creator(topic_id)
@@ -70,7 +70,7 @@ def trigger_generation(topic_id: str = Body(None, embed=True), db: Session = Dep
raise HTTPException(status_code=500, detail=str(e))
@router.post("/review/run", dependencies=[Depends(get_current_user)])
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db)):
def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
result = run_optimizer(topic_ids)
if not result["ok"]:
@@ -86,7 +86,11 @@ def trigger_review(topic_ids: List[str] = Body(None, embed=True), db: Session =
if topic_ids:
updated = 0
for tid in topic_ids:
topic = db.query(Topic).filter(Topic.id == tid).first()
q = db.query(Topic).filter(Topic.id == tid)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
topic = q.first()
if topic and topic.status in ('review', '待审查'):
topic.status = 'ready'
if not topic.generated_at:
@@ -109,9 +113,13 @@ def get_logs(log_date: str, log_type: str = "creator"):
return {"log_date": log_date, "log_type": log_type, "content": lines}
@router.get("/pipeline/status", dependencies=[Depends(get_current_user)])
def get_pipeline_status(db: Session = Depends(get_db)):
total = db.query(Topic).count()
counts = _aggregate_status_counts(db)
def get_pipeline_status(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
total = topic_base.count()
counts = _aggregate_status_counts(topic_base)
log_files = {
"collector": LOGS_DIR / f"collector_{date.today().isoformat()}.log",
"creator": LOGS_DIR / f"creator_{date.today().isoformat()}.log",
@@ -135,9 +143,13 @@ def run_sync():
raise HTTPException(status_code=500, detail=str(e))
@router.get("/automation/topics")
def list_automation_topics(db: Session = Depends(get_db)):
def list_automation_topics(db: Session = Depends(get_db), current_user=Depends(get_current_user)):
try:
topics = db.query(Topic).order_by(Topic.created_at.desc()).limit(100).all()
topic_base = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
topic_base = topic_base.filter(of)
topics = topic_base.order_by(Topic.created_at.desc()).limit(100).all()
result = []
for t in topics:
result.append({
+12 -3
View File
@@ -6,7 +6,7 @@ from typing import List, Optional
from ..database import get_db
from ..models import ContentTask, Topic
from ..schemas import ContentTaskCreate, ContentTaskResponse
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@@ -20,7 +20,10 @@ def list_tasks(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
query = db.query(ContentTask)
query = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
if status:
query = query.filter(ContentTask.status == status)
if topic_id:
@@ -35,7 +38,11 @@ def get_active_tasks(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
return db.query(ContentTask).filter(
q = db.query(ContentTask).join(Topic, ContentTask.topic_id == Topic.id, isouter=True)
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter((ContentTask.topic_id.is_(None)) | (Topic.org_id == current_user.org_id))
return q.filter(
ContentTask.status == "running"
).order_by(ContentTask.started_at.desc()).all()
@@ -50,6 +57,8 @@ def create_task(
topic = db.query(Topic).filter(Topic.id == data.topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="选题不存在")
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="选题不存在")
task_id = f"task_{uuid.uuid4().hex[:16]}"
+52 -15
View File
@@ -11,32 +11,46 @@ from ..schemas import (
TopicCreate, TopicUpdate, TopicResponse, TopicScoreRequest,
PublishRequest, PublishActionRequest, PublishRecordResponse
)
from .auth import get_current_user
from .auth import get_current_user, org_filter
router = APIRouter(prefix="/api/topics", tags=["topics"], dependencies=[Depends(get_current_user)])
PROJECT_ROOT = Path(__file__).parent.parent.parent
def _check_org(topic: Topic, current_user, db: Session):
"""Verify topic belongs to user's org (unless admin)."""
if current_user.role != "admin" and topic.org_id != current_user.org_id:
raise HTTPException(status_code=404, detail="Topic not found")
return topic
@router.get("", response_model=List[TopicResponse])
def list_topics(
field_id: Optional[int] = None,
status: Optional[str] = None,
today: Optional[bool] = None,
tag: Optional[str] = None,
search: Optional[str] = None,
sort_by: str = Query("priority_score", enum=["priority_score", "created_at", "title", "updated_at"]),
order: str = Query("desc", enum=["asc", "desc"]),
limit: int = Query(50, le=200),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db)
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
db.expire_all()
query = db.query(Topic).options(joinedload(Topic.field))
of = org_filter(current_user, Topic)
if of is not True:
query = query.filter(of)
if field_id:
query = query.filter(Topic.field_id == field_id)
if status:
query = query.filter(Topic.status == status)
if today:
query = query.filter(func.date(Topic.created_at) == date.today())
if tag:
query = query.filter(Topic.tags.contains([tag]))
if search:
@@ -56,14 +70,19 @@ def topic_stats(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
total = db.query(Topic).count()
raw = db.query(Topic.status, func.count()).group_by(Topic.status).all()
base_q = db.query(Topic)
of = org_filter(current_user, Topic)
if of is not True:
base_q = base_q.filter(of)
total = base_q.count()
raw = base_q.with_entities(Topic.status, func.count()).group_by(Topic.status).all()
by_status = {s: c for s, c in raw}
today = date.today()
today_count = db.query(Topic).filter(func.date(Topic.created_at) == today).count()
today_count = base_q.filter(func.date(Topic.created_at) == today).count()
published = db.query(Topic).filter(Topic.status == "published").count()
published = base_q.filter(Topic.status.in_(["published", "已发布"])).count()
metrics_count = db.query(ContentMetrics).count()
return {
@@ -103,6 +122,7 @@ def create_topic(
id=topic_id,
field_id=data.field_id,
field_name=field_name,
org_id=current_user.org_id or "default",
title=data.title,
format=data.format,
core_concept=data.core_concept,
@@ -121,10 +141,11 @@ def create_topic(
@router.get("/{topic_id}", response_model=TopicResponse)
def get_topic(topic_id: str, db: Session = Depends(get_db)):
def get_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).options(joinedload(Topic.field)).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
return topic
@@ -138,6 +159,7 @@ def update_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
if data.field_id is not None:
topic.field_id = data.field_id
@@ -167,6 +189,7 @@ def delete_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
try:
db.delete(topic)
db.commit()
@@ -186,6 +209,7 @@ def score_topic(
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.scoring_data = data.scoring_data
@@ -218,10 +242,11 @@ def score_topic(
@router.post("/{topic_id}/publish")
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db)):
def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
if topic.status not in ("pending", "ready", "draft"):
raise HTTPException(status_code=400, detail=f"选题状态({topic.status})不允许发布")
@@ -243,10 +268,11 @@ def publish_topic(topic_id: str, req: PublishRequest, db: Session = Depends(get_
@router.get("/{topic_id}/articles", response_model=List[Dict[str, Any]])
def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
def get_topic_articles(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
articles = db.query(Article).filter(Article.topic_id == topic_id).all()
return [a.to_dict() if hasattr(a, 'to_dict') else {
"id": a.id, "topic_id": a.topic_id, "platform": a.platform,
@@ -255,7 +281,11 @@ def get_topic_articles(topic_id: str, db: Session = Depends(get_db)):
@router.get("/{topic_id}/metrics", response_model=List[Dict[str, Any]])
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db)):
def get_topic_metrics(topic_id: str, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
metrics = db.query(ContentMetrics).filter(ContentMetrics.topic_id == topic_id).all()
return [m.to_dict() for m in metrics]
@@ -265,6 +295,7 @@ def lock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depend
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = current_user.username
topic.lock_at = datetime.now()
db.commit()
@@ -276,6 +307,7 @@ def unlock_topic(topic_id: str, db: Session = Depends(get_db), current_user=Depe
topic = db.query(Topic).filter(Topic.id == topic_id).first()
if not topic:
raise HTTPException(status_code=404, detail="Topic not found")
_check_org(topic, current_user, db)
topic.lock_by = None
topic.lock_at = None
db.commit()
@@ -289,7 +321,11 @@ def batch_update_status(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
updated = db.query(Topic).filter(Topic.id.in_(topic_ids)).update(
q = db.query(Topic).filter(Topic.id.in_(topic_ids))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
updated = q.update(
{Topic.status: status, Topic.updated_at: datetime.now()},
synchronize_session=False
)
@@ -302,8 +338,9 @@ def field_distribution(
db: Session = Depends(get_db),
current_user=Depends(get_current_user)
):
rows = db.query(
Topic.field_name,
func.count(Topic.id).label("count")
).group_by(Topic.field_name).all()
q = db.query(Topic.field_name, func.count(Topic.id).label("count"))
of = org_filter(current_user, Topic)
if of is not True:
q = q.filter(of)
rows = q.group_by(Topic.field_name).all()
return [{"field": r.field_name or "未分类", "count": r.count} for r in rows]