7e953afbd2
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式) - 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级) - 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包) - 发布闭环:publishing 放宽至 editor + records/mark-published 接口 - 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底) - 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立) - 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
210 lines
7.2 KiB
Python
210 lines
7.2 KiB
Python
#!/usr/bin/env python3
|
||
"""发布管理 API"""
|
||
from fastapi import APIRouter, HTTPException, Depends, Request
|
||
from pydantic import BaseModel
|
||
from datetime import datetime, timezone, timedelta, date
|
||
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_user, org_filter
|
||
from ..core.audit_logger import audit_log
|
||
|
||
router = APIRouter(prefix="/api/publishing", tags=["publishing"])
|
||
|
||
PLATFORM_LABELS = {
|
||
"zhihu": "知乎",
|
||
"wechat": "微信公众号",
|
||
"xiaohongshu": "小红书"
|
||
}
|
||
|
||
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=MultiPublishResponse)
|
||
async def create_publish_record(
|
||
req: MultiPublishRequest,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""多平台发布选题(运营/管理员,按组织隔离)"""
|
||
try:
|
||
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='stage',
|
||
status='pending_manual',
|
||
operator=operator,
|
||
description=f"选题 {req.topic_id} 已生成 {PLATFORM_LABELS.get(platform, platform)} 发布稿,待人工校验后发布",
|
||
org_id=topic.org_id
|
||
)
|
||
db.add(record)
|
||
results.append(PublishResult(
|
||
platform=platform,
|
||
platform_label=PLATFORM_LABELS.get(platform, platform),
|
||
status='pending_manual'
|
||
))
|
||
except Exception as e:
|
||
results.append(PublishResult(
|
||
platform=platform,
|
||
platform_label=PLATFORM_LABELS.get(platform, platform),
|
||
status='failed',
|
||
error_msg=str(e)
|
||
))
|
||
|
||
# 诚实状态流转:本项目无真实自动发布器(且按《AI生成内容标识办法》AI 稿需真人把关),
|
||
# 故仅将选题置为「待人工发布」,待运营在对应平台校验声明后手动发布。
|
||
topic.status = 'pending_publish'
|
||
_now = datetime.now(timezone(timedelta(hours=8)))
|
||
topic.updated_at = _now
|
||
db.commit()
|
||
db.expire_all()
|
||
db.refresh(topic)
|
||
|
||
audit_log(
|
||
action="publish_stage",
|
||
user=current_user,
|
||
resource_type="topic",
|
||
resource_id=req.topic_id,
|
||
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
|
||
)
|
||
|
||
staged = sum(1 for r in results if r.status == 'pending_manual')
|
||
return MultiPublishResponse(
|
||
ok=staged == len(results),
|
||
topic_id=req.topic_id,
|
||
results=results,
|
||
message=(
|
||
f"选题 {req.topic_id} 已生成 {staged}/{len(results)} 个平台的发布稿并置为「待人工发布」。"
|
||
f"AI 生成内容须由真人校验声明后手动发布,系统不代发。"
|
||
)
|
||
)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
db.rollback()
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/records")
|
||
def list_publish_records(
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""列出发布记录(按组织隔离),用于运营查看「待人工发布 / 已发布」状态"""
|
||
q = db.query(PublishRecord)
|
||
of = org_filter(current_user, PublishRecord)
|
||
if of is not True:
|
||
q = q.filter(of)
|
||
recs = q.order_by(PublishRecord.created_at.desc()).all()
|
||
result = []
|
||
for r in recs:
|
||
topic = db.query(Topic).filter(Topic.id == r.topic_id).first()
|
||
result.append({
|
||
"id": r.id,
|
||
"topic_id": r.topic_id,
|
||
"topic_title": topic.title if topic else None,
|
||
"platform": r.platform,
|
||
"action": r.action,
|
||
"status": r.status,
|
||
"operator": r.operator,
|
||
"description": r.description,
|
||
"url": r.url,
|
||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
||
})
|
||
return {"records": result}
|
||
|
||
|
||
class MarkPublishedResponse(BaseModel):
|
||
ok: bool
|
||
topic_id: str
|
||
message: str
|
||
|
||
|
||
@router.post("/{topic_id}/mark-published", response_model=MarkPublishedResponse)
|
||
def mark_published(
|
||
topic_id: str,
|
||
request: Request,
|
||
db: Session = Depends(get_db),
|
||
current_user: User = Depends(get_current_user)
|
||
):
|
||
"""运营在对应平台手动发布后,回填「已发布」闭环状态"""
|
||
q = db.query(Topic).filter(Topic.id == 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"选题 {topic_id} 不存在")
|
||
if topic.status != 'pending_publish':
|
||
raise HTTPException(status_code=400, detail=f"选题 {topic_id} 当前状态为「{topic.status}」,无需标记发布")
|
||
|
||
_now = datetime.now(timezone(timedelta(hours=8)))
|
||
topic.status = 'published'
|
||
topic.published_at = date.today()
|
||
topic.updated_at = _now
|
||
|
||
recs = db.query(PublishRecord).filter(
|
||
PublishRecord.topic_id == topic_id,
|
||
PublishRecord.status == 'pending_manual'
|
||
)
|
||
for r in recs:
|
||
r.status = 'published'
|
||
r.updated_at = _now
|
||
|
||
db.commit()
|
||
audit_log(
|
||
action="publish_confirm",
|
||
user=current_user,
|
||
resource_type="topic",
|
||
resource_id=topic_id,
|
||
details={"operator": current_user.username},
|
||
ip_address=request.client.host if request.client else None,
|
||
user_agent=request.headers.get("user-agent", ""),
|
||
db=db
|
||
)
|
||
return MarkPublishedResponse(
|
||
ok=True,
|
||
topic_id=topic_id,
|
||
message=f"选题 {topic_id} 已标记为「已发布」,发布记录同步更新。"
|
||
)
|