433 lines
17 KiB
Markdown
433 lines
17 KiB
Markdown
# Plan B: TopFans-activity-admin 后台 FastAPI + Vue3
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**父计划:** `docs/superpowers/plans/2026-06-17-moderation-report-feedback-system.md`
|
||
|
||
**Goal:** 在 TopFans-activity-admin 仓库完成审核员后台的 FastAPI 后端 + Vue3 前端
|
||
|
||
**Architecture:**
|
||
- FastAPI 路由层 `/api/admin/moderation/*`(**不依赖** topfans moderationService RPC)
|
||
- 通过共享 PostgreSQL `top-fans` 库直读写所有 moderation_* 表
|
||
- 复用现有 `verify_token` 中间件
|
||
- 业务下架/封禁:事务内双重写入(业务表软删除 + moderation_target_status UPSERT)
|
||
- 错误码 50008 vs 50009 vs 50014 在 handler 层区分
|
||
|
||
**Tech Stack:** Python 3.11+ / FastAPI / SQLAlchemy / Pydantic / pytest / Vue3 + Element Plus
|
||
|
||
**仓库:** `/Users/liulujian/Documents/code/TopFans-activity-admin`
|
||
|
||
**前置:** Plan A 阶段 1 的 9 张表已迁移到 `top-fans` 库
|
||
|
||
---
|
||
|
||
## 阶段 B.1:模型 + Schema + CRUD
|
||
|
||
### Task B.1.1: 创建 ORM 模型
|
||
|
||
**Files:**
|
||
- Create: `backend/models/moderation.py`
|
||
|
||
按 spec §3 表结构写 9 个 SQLAlchemy ORM(ReportCategory / FeedbackCategory / Report / ReportEvidence / Feedback / FeedbackEvidence / ModerationAction / ModerationTargetStatus / AdminAuditLog)。
|
||
|
||
```python
|
||
from sqlalchemy import Column, BigInteger, String, Integer, Boolean, DateTime, Text, ForeignKey, JSON
|
||
from sqlalchemy.dialects.postgresql import JSONB
|
||
from .base import Base
|
||
|
||
class Report(Base):
|
||
__tablename__ = "reports"
|
||
id = Column(BigInteger, primary_key=True)
|
||
reporter_id = Column(BigInteger, nullable=False)
|
||
star_id = Column(BigInteger)
|
||
target_type = Column(String(30), nullable=False)
|
||
target_id = Column(BigInteger, nullable=False)
|
||
target_snapshot = Column(JSONB, nullable=False)
|
||
triggered_auto_hide = Column(Boolean, default=False, nullable=False)
|
||
category_code = Column(String(50), ForeignKey("report_categories.code", ondelete="RESTRICT", onupdate="CASCADE"), nullable=False)
|
||
description = Column(String(500))
|
||
is_anonymous = Column(Boolean, default=False, nullable=False)
|
||
status = Column(String(20), default="pending", nullable=False)
|
||
is_auto_hidden = Column(Boolean, default=False, nullable=False)
|
||
claimed_by = Column(BigInteger)
|
||
claimed_at = Column(BigInteger)
|
||
resolved_action = Column(String(20))
|
||
resolved_by = Column(BigInteger)
|
||
resolved_at = Column(BigInteger)
|
||
resolution_note = Column(String(500))
|
||
created_at = Column(BigInteger, nullable=False)
|
||
updated_at = Column(BigInteger, nullable=False)
|
||
```
|
||
|
||
**同模式**:ReportCategory / FeedbackCategory / ReportEvidence / Feedback / FeedbackEvidence / ModerationAction / ModerationTargetStatus / AdminAuditLog
|
||
|
||
### Task B.1.2: 创建 Pydantic Schema
|
||
|
||
**Files:**
|
||
- Create: `backend/schemas/moderation.py`
|
||
|
||
```python
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional, List
|
||
|
||
class ReportListItem(BaseModel):
|
||
id: int
|
||
target_type: str
|
||
target_id: int
|
||
category_code: str
|
||
status: str
|
||
reporter_id: int
|
||
created_at: int
|
||
resolved_at: Optional[int] = None
|
||
resolved_action: Optional[str] = None
|
||
|
||
class ReportDetail(BaseModel):
|
||
report: ReportListItem
|
||
description: Optional[str]
|
||
target_snapshot: dict
|
||
evidence: List[dict]
|
||
actions: List[dict]
|
||
claimed_by: Optional[int]
|
||
claimed_at: Optional[int]
|
||
|
||
class ClaimRequest(BaseModel):
|
||
pass
|
||
|
||
class ActionRequest(BaseModel):
|
||
action: str = Field(..., regex="^(takedown|ban|warn|dismiss|restore)$")
|
||
note: str = Field(..., max_length=500)
|
||
send_notification: bool = True
|
||
restore: Optional[bool] = None # 仅 dismiss path C 用
|
||
|
||
class BulkDismissRequest(BaseModel):
|
||
report_ids: List[int] = Field(..., min_items=1, max_items=500)
|
||
reason: str = Field(..., max_length=200)
|
||
|
||
class ForceReleaseRequest(BaseModel):
|
||
reason: str = Field(..., max_length=200)
|
||
```
|
||
|
||
### Task B.1.3: 创建 CRUD 函数
|
||
|
||
**Files:**
|
||
- Create: `backend/crud/moderation_crud.py`
|
||
|
||
**关键函数**(按 spec §5.2 端点):
|
||
```python
|
||
async def list_reports(db: AsyncSession, *, status: Optional[str], category: Optional[str],
|
||
target_type: Optional[str], keyword: Optional[str],
|
||
page: int = 1, page_size: int = 20) -> Tuple[List[Report], int]:
|
||
q = select(Report)
|
||
if status: q = q.where(Report.status == status)
|
||
if category: q = q.where(Report.category_code == category)
|
||
if target_type: q = q.where(Report.target_type == target_type)
|
||
if keyword: q = q.where(or_(
|
||
Report.description.ilike(f"%{keyword}%"),
|
||
Report.target_id == int(keyword) if keyword.isdigit() else False,
|
||
))
|
||
total = await db.scalar(select(func.count()).select_from(q.subquery()))
|
||
rows = (await db.execute(q.order_by(Report.created_at.desc()).offset((page-1)*page_size).limit(page_size))).scalars().all()
|
||
return rows, total
|
||
|
||
async def claim_report(db: AsyncSession, *, report_id: int, admin_id: int, now: int) -> int:
|
||
"""返回 affected rows(0/1)"""
|
||
res = await db.execute(
|
||
update(Report)
|
||
.where(Report.id == report_id, Report.status.in_(["pending", "auto_hidden"]))
|
||
.values(status="reviewing", claimed_by=admin_id, claimed_at=now, updated_at=now)
|
||
)
|
||
await db.commit()
|
||
return res.rowcount
|
||
|
||
async def execute_action(db: AsyncSession, *, report_id: int, action: str, admin_id: int,
|
||
note: str, now: int, send_notification: bool, restore: Optional[bool] = None) -> Dict:
|
||
"""事务内三重写入:业务表软删除/恢复 + mts UPSERT + reports UPDATE
|
||
返回:{affected: int, target_type, target_id, action_log_id}"""
|
||
# 1. SELECT report 取 target_type/target_id/origin_status
|
||
report = await db.get(Report, report_id)
|
||
if report.status != "reviewing" or report.claimed_by != admin_id:
|
||
return {"affected": 0, "error": "not_claimed"} # handler 转 50008/50009
|
||
|
||
target_type = report.target_type
|
||
target_id = report.target_id
|
||
origin_is_auto_hidden = report.is_auto_hidden
|
||
|
||
# 2. 业务表软删除/恢复
|
||
affected = 0
|
||
if action == "takedown":
|
||
if target_type == "asset":
|
||
res = await db.execute(update(Asset).where(Asset.id == target_id, Asset.is_active == True)
|
||
.values(is_active=False, deleted_at=now))
|
||
else:
|
||
res = await db.execute(update(User).where(User.id == target_id, User.is_active == True)
|
||
.values(is_active=False, deleted_at=now))
|
||
affected = res.rowcount
|
||
elif action == "ban":
|
||
# 仅 user_profile
|
||
res = await db.execute(update(User).where(User.id == target_id, User.is_active == True)
|
||
.values(is_active=False, deleted_at=now))
|
||
affected = res.rowcount
|
||
elif action == "warn":
|
||
# 不软删除 user,仅更新 mts
|
||
affected = 0 # warn 不影响业务表
|
||
elif action == "dismiss":
|
||
# path C: dismiss 且曾 auto_hidden 且 restore=True → 恢复业务表
|
||
if origin_is_auto_hidden and restore:
|
||
if target_type == "asset":
|
||
res = await db.execute(update(Asset).where(Asset.id == target_id, Asset.is_active == False)
|
||
.values(is_active=True, deleted_at=None))
|
||
else:
|
||
res = await db.execute(update(User).where(User.id == target_id, User.is_active == False)
|
||
.values(is_active=True, deleted_at=None))
|
||
affected = res.rowcount
|
||
elif action == "restore":
|
||
# v2.2 B6 + v2.5 W6: ban/takedown 误判恢复;用 CTE 条件写 mts
|
||
# 仅当 user_unbanned=1 时写 mts last_action_type='restore'
|
||
if target_type == "user_profile":
|
||
res = await db.execute(update(User).where(User.id == target_id, User.is_active == False)
|
||
.values(is_active=True, deleted_at=None).returning(User.id))
|
||
unbanned = res.fetchone()
|
||
if unbanned:
|
||
# UPSERT mts with last_action_type='restore'
|
||
stmt = pg_insert(ModerationTargetStatus).values(
|
||
target_type=target_type, target_id=target_id, last_action_type="restore",
|
||
source="admin", operator_admin_id=admin_id, reason=f"unban: {note}",
|
||
created_at=now, updated_at=now,
|
||
).on_conflict_do_update(
|
||
index_elements=["target_type", "target_id"],
|
||
set_=dict(last_action_type="restore", source="admin",
|
||
operator_admin_id=admin_id, reason=f"unban: {note}", updated_at=now)
|
||
)
|
||
await db.execute(stmt)
|
||
# 同步更新 reports.resolved_action='restore'
|
||
await db.execute(update(Report).where(Report.id == report_id)
|
||
.values(resolved_action="restore", resolution_note=f"unban: {note}",
|
||
resolved_by=admin_id, resolved_at=now, updated_at=now))
|
||
|
||
# 3. UPSERT mts
|
||
last_action_type = action if action != "dismiss" else ("restore" if (origin_is_auto_hidden and restore) else "dismiss")
|
||
mts = ModerationTargetStatus(
|
||
target_type=target_type, target_id=target_id,
|
||
last_action_type=last_action_type, source="admin",
|
||
operator_admin_id=admin_id, reason=note,
|
||
created_at=now, updated_at=now,
|
||
)
|
||
stmt = pg_insert(ModerationTargetStatus).values(**mts.__dict__).on_conflict_do_update(
|
||
index_elements=["target_type", "target_id"],
|
||
set_=dict(last_action_type=last_action_type, source="admin",
|
||
operator_admin_id=admin_id, reason=note, updated_at=now,
|
||
is_warned=case(... if action == "warn" else ...),
|
||
warn_count=case(... if action == "warn" else ModerationTargetStatus.warn_count + 0),
|
||
last_warned_at=case(... if action == "warn" else ...))
|
||
)
|
||
await db.execute(stmt)
|
||
|
||
# 4. UPDATE reports.status = 'resolved' or 'dismissed'
|
||
new_status = "resolved" if action in ["takedown", "ban", "warn", "restore"] else "dismissed"
|
||
await db.execute(update(Report).where(Report.id == report_id)
|
||
.values(status=new_status, resolved_action=action, resolved_by=admin_id,
|
||
resolved_at=now, claimed_by=None, claimed_at=None, resolution_note=note, updated_at=now))
|
||
|
||
# 5. 写 moderation_actions
|
||
await db.execute(insert(ModerationAction).values(
|
||
report_id=report_id, admin_id=admin_id, action_type=action,
|
||
target_type=target_type, target_id=target_id, note=note,
|
||
success=True, created_at=now,
|
||
))
|
||
|
||
await db.commit()
|
||
|
||
# 6. 通知(异步,失败不影响主流程)
|
||
if send_notification:
|
||
try:
|
||
await send_notification_internal(target_type, target_id, action, note)
|
||
except Exception as e:
|
||
logger.warning(f"notification failed: {e}")
|
||
|
||
return {"affected": affected, "target_type": target_type, "target_id": target_id}
|
||
```
|
||
|
||
**注意:**
|
||
- **warn** 流程:UPSERT mts SET is_warned=true, warn_count=warn_count+1, last_warned_at=now
|
||
- **dismiss path D**:不恢复业务表,mts.last_action_type='dismiss' 覆盖原 autohide
|
||
- **dismiss path C**:恢复业务表 + mts.last_action_type='restore' + 批量清理同 target 老 auto_hidden 工单
|
||
|
||
### Task B.1.4: 写其余 CRUD 函数
|
||
|
||
- `release_report` / `dismiss_path_a` (pending → dismissed) / `bulk_dismiss` / `force_release` / `withdraw_report` / `clear_warn` / `list_categories` / `upsert_category` / `delete_category` / `get_stats`
|
||
|
||
---
|
||
|
||
## 阶段 B.2:FastAPI Handler
|
||
|
||
### Task B.2.1: 写 handlers
|
||
|
||
**Files:**
|
||
- Create: `backend/handlers/moderation_admin.py`
|
||
|
||
```python
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from .verify_token import verify_token
|
||
from ..crud import moderation_crud
|
||
from ..schemas.moderation import *
|
||
|
||
router = APIRouter(prefix="/api/admin/moderation", tags=["moderation"])
|
||
|
||
@router.get("/reports")
|
||
async def list_reports(
|
||
status: Optional[str] = None,
|
||
category: Optional[str] = None,
|
||
target_type: Optional[str] = None,
|
||
keyword: Optional[str] = None,
|
||
page: int = Query(1, ge=1),
|
||
page_size: int = Query(20, ge=1, le=100),
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: dict = Depends(verify_token),
|
||
):
|
||
rows, total = await moderation_crud.list_reports(db, status=status, category=category,
|
||
target_type=target_type, keyword=keyword,
|
||
page=page, page_size=page_size)
|
||
return {"code": 200, "data": {"reports": [ReportListItem.from_orm(r).dict() for r in rows], "total": total}}
|
||
|
||
@router.post("/reports/{report_id}/claim")
|
||
async def claim_report(
|
||
report_id: int,
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: dict = Depends(verify_token),
|
||
):
|
||
affected = await moderation_crud.claim_report(db, report_id=report_id, admin_id=admin["admin_id"], now=now_ms())
|
||
if affected == 0:
|
||
# handler 区分 50007/50008/50009
|
||
report = await db.get(Report, report_id)
|
||
if report is None:
|
||
raise HTTPException(404, detail={"code": 50007, "message": "工单不存在"})
|
||
if report.status == "reviewing" and report.claimed_by != admin["admin_id"]:
|
||
raise HTTPException(409, detail={
|
||
"code": 50008, "message": "工单已被他人认领",
|
||
"claimed_by": report.claimed_by, "claimed_at": report.claimed_at,
|
||
})
|
||
if report.status in ["resolved", "dismissed"]:
|
||
raise HTTPException(400, detail={"code": 50009, "message": "工单已结案"})
|
||
# pending 状态但 claim 失败(异常情况)
|
||
raise HTTPException(409, detail={"code": 50014, "message": "工单已被释放或结案,请刷新"})
|
||
return {"code": 200, "data": {"claimed": True}}
|
||
|
||
@router.post("/reports/{report_id}/actions")
|
||
async def execute_action(
|
||
report_id: int,
|
||
payload: ActionRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
admin: dict = Depends(verify_token),
|
||
):
|
||
result = await moderation_crud.execute_action(
|
||
db, report_id=report_id, action=payload.action,
|
||
admin_id=admin["admin_id"], note=payload.note,
|
||
now=now_ms(), send_notification=payload.send_notification,
|
||
restore=payload.restore,
|
||
)
|
||
if "error" in result:
|
||
raise HTTPException(409, detail={"code": 50008, "message": "工单已被他人认领或结案"})
|
||
return {"code": 200, "data": result}
|
||
```
|
||
|
||
### Task B.2.2: 注册路由
|
||
|
||
**Files:**
|
||
- Modify: `backend/router/__init__.py`
|
||
|
||
```python
|
||
from .moderation_admin import router as moderation_admin_router
|
||
admin_router.include_router(moderation_admin_router)
|
||
```
|
||
|
||
### Task B.2.3: 启动 + curl 验证
|
||
|
||
```bash
|
||
cd /Users/liulujian/Documents/code/TopFans-activity-admin
|
||
uvicorn backend.main:app --reload
|
||
curl http://localhost:8000/api/admin/moderation/reports -H "Authorization: Bearer $ADMIN_TOKEN"
|
||
```
|
||
|
||
---
|
||
|
||
## 阶段 B.3:单元测试
|
||
|
||
### Task B.3.1: 写测试
|
||
|
||
**Files:**
|
||
- Create: `backend/handlers/test_moderation_admin.py`
|
||
|
||
**关键测试**:
|
||
- 列表/详情/认领 happy path
|
||
- dismiss 4 路径分支
|
||
- 50008 vs 50009 vs 50014 区分
|
||
- 鉴权(无 token → 401)
|
||
- bulk_dismiss / force_release / clear_warn
|
||
|
||
---
|
||
|
||
## 阶段 B.4:Vue3 前端
|
||
|
||
### Task B.4.1: API 封装
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/api/moderation.js`
|
||
|
||
按 spec §5.2 端点封装。
|
||
|
||
### Task B.4.2: 举报工单列表
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/moderation/ReportList.vue`
|
||
|
||
Element Plus 组件:
|
||
- el-form 筛选栏(status select / category select / target_type select / keyword input)
|
||
- el-table 列表(id/target_type/target_id/category/status[el-tag]/created_at)
|
||
- el-pagination 分页
|
||
- 详情按钮 → 跳转 `/moderation/reports/:id`
|
||
|
||
### Task B.4.3: 举报详情
|
||
|
||
**Files:**
|
||
- Create: `frontend/src/views/moderation/ReportDetail.vue`
|
||
|
||
- el-descriptions 信息卡片(target_snapshot JSON 格式化展示)
|
||
- el-image preview 证据图
|
||
- el-timeline 流水
|
||
- 动作按钮组(按 status 显隐):
|
||
- pending / auto_hidden:认领
|
||
- reviewing(本人):下架 / 封禁 / 警告 / 驳回(path B/C/D 选项)/ 释放
|
||
- el-dialog 输入 action/note/send_notification/restore
|
||
|
||
### Task B.4.4: 反馈列表/详情
|
||
|
||
按举报同模式。
|
||
|
||
### Task B.4.5: 分类 CRUD 页面
|
||
|
||
- `ReportCategoryConfig.vue` / `FeedbackCategoryConfig.vue`
|
||
- el-table 列出 + el-dialog 新增/编辑 + 删除确认
|
||
|
||
### Task B.4.6: 路由 + 菜单
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/router/index.js`
|
||
- Modify: `frontend/src/layout/Layout.vue`
|
||
|
||
注册 6 个路由 + 菜单项。
|
||
|
||
---
|
||
|
||
## 自审检查清单(Plan B)
|
||
|
||
- [ ] FastAPI `/api/admin/moderation/*` 路由全部注册
|
||
- [ ] 错误码 50007/50008/50009/50014 在 handler 区分正确
|
||
- [ ] 事务内三重写入(业务表 + mts + reports)原子性
|
||
- [ ] dismiss 4 路径全覆盖
|
||
- [ ] warn 流程不软删除 user,仅更新 mts
|
||
- [ ] restore 流程用 CTE 条件写 mts(v2.5 W6)
|
||
- [ ] Vue3 路由 + 菜单注册
|
||
- [ ] pytest 全部 PASS
|