- 迁移 12:community_content(帖子/角色 Release、publication 六态、可见性)、comment(父级树)、reaction((content,actor,kind) 唯一防重复)、follow(双 FK 显式命名修复 convention 冲突)、report + moderation_case(举报即建司议案) - 发布:帖子直发;角色 Release 由服务端构造公开快照(name/道体/版本数)——私密记忆/关系/对话结构性缺席,测试以注入私密记忆后发布验证零泄露 - 互动:评论(父级校验同内容可见)、点赞/收藏(重复 409、取消后计数正确) - 关注/取关与计数;举报自动生成司议案(治理队列入口,status=pending) - 下架传播:removed 后列表/详情/评论统一 404 或过滤(require_content 单点);非作者下架 404 fail-closed - 测试基建修复:login 返回自包含 headers(显式 Cookie 优先),多用户交替不再串会话 - 测试 79 项全绿(+7)
195 lines
5.9 KiB
Python
195 lines
5.9 KiB
Python
"""社区域路由(WP-11)。"""
|
|
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, Depends, Request, status
|
|
|
|
from app.auth.dependencies import get_current_user, require_state_change
|
|
from app.community import service
|
|
from app.conversations.routes import _domain_of
|
|
from app.db.session import DbSession
|
|
from app.models import User
|
|
|
|
router = APIRouter(prefix="/v1/community", tags=["community"])
|
|
|
|
|
|
def _content_out(c: Any, counts: dict[str, int] | None = None) -> dict[str, Any]:
|
|
out: dict[str, Any] = {
|
|
"id": c.id,
|
|
"kind": c.kind,
|
|
"title": c.title,
|
|
"body": c.body,
|
|
"visibility": c.visibility,
|
|
"status": c.status,
|
|
"author_domain_id": c.author_domain_id,
|
|
"created_at": c.created_at.isoformat(),
|
|
}
|
|
if counts is not None:
|
|
out["reactions"] = counts
|
|
return out
|
|
|
|
|
|
def _comment_out(c: Any) -> dict[str, Any]:
|
|
return {
|
|
"id": c.id,
|
|
"content_id": c.content_id,
|
|
"parent_comment_id": c.parent_comment_id,
|
|
"body": c.body,
|
|
"author_domain_id": c.author_domain_id,
|
|
"created_at": c.created_at.isoformat(),
|
|
}
|
|
|
|
|
|
@router.post("/contents", status_code=201)
|
|
async def publish(
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
request: Request,
|
|
body: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
content = await service.create_content(
|
|
db, domain.id, body, getattr(request.state, "trace_id", None)
|
|
)
|
|
return _content_out(content)
|
|
|
|
|
|
@router.get("/contents")
|
|
async def list_contents(
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
limit: int = 20,
|
|
cursor: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if not 1 <= limit <= 100:
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "limit 取值范围 1-100")
|
|
page, next_cursor, has_more = await service.list_public(db, limit, cursor)
|
|
return {
|
|
"data": [_content_out(c) for c in page],
|
|
"next_cursor": next_cursor,
|
|
"has_more": has_more,
|
|
}
|
|
|
|
|
|
@router.get("/contents/{content_id}")
|
|
async def get_content(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict[str, Any]:
|
|
content = await service.require_content(db, content_id)
|
|
counts = await service.reaction_counts(db, content_id)
|
|
return _content_out(content, counts)
|
|
|
|
|
|
@router.post("/contents/{content_id}/comments", status_code=201)
|
|
async def comment(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
request: Request,
|
|
body: dict[str, str],
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
created = await service.add_comment(
|
|
db, domain.id, content_id, body.get("body", ""), body.get("parent_comment_id"),
|
|
getattr(request.state, "trace_id", None),
|
|
)
|
|
return _comment_out(created)
|
|
|
|
|
|
@router.get("/contents/{content_id}/comments")
|
|
async def list_comments(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
) -> dict[str, Any]:
|
|
rows = await service.list_comments(db, content_id)
|
|
return {"data": [_comment_out(c) for c in rows]}
|
|
|
|
|
|
@router.post("/contents/{content_id}/reactions", status_code=201)
|
|
async def react(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
body: dict[str, str],
|
|
) -> dict[str, str]:
|
|
domain = await _domain_of(db, user)
|
|
kind = body.get("kind", "")
|
|
if kind not in {"like", "save"}:
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
|
|
await service.react(db, domain.id, content_id, kind, None)
|
|
counts = await service.reaction_counts(db, content_id)
|
|
return {k: str(v) for k, v in counts.items()}
|
|
|
|
|
|
@router.delete("/contents/{content_id}/reactions", status_code=204)
|
|
async def unreact(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
body: dict[str, str],
|
|
) -> None:
|
|
domain = await _domain_of(db, user)
|
|
await service.unreact(db, domain.id, content_id, body.get("kind", ""))
|
|
|
|
|
|
@router.post("/follows", status_code=201)
|
|
async def follow(
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
body: dict[str, str],
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
from app.models import PersonalDomain
|
|
|
|
target = await db.get(PersonalDomain, body.get("followee_domain_id", ""))
|
|
if target is None:
|
|
from fastapi import HTTPException
|
|
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "用户不存在")
|
|
await service.follow(db, domain.id, target.id)
|
|
return await service.follow_counts(db, domain.id)
|
|
|
|
|
|
@router.get("/follows/counts")
|
|
async def follow_counts(
|
|
db: DbSession, user: Annotated[User, Depends(get_current_user)]
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
return await service.follow_counts(db, domain.id)
|
|
|
|
|
|
@router.post("/reports", status_code=201)
|
|
async def report(
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
request: Request,
|
|
body: dict[str, str],
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
report_row, case = await service.file_report(
|
|
db, domain.id, body.get("target_type", ""), body.get("target_id", ""),
|
|
body.get("reason", ""), getattr(request.state, "trace_id", None),
|
|
)
|
|
return {"report_id": report_row.id, "case_id": case.id, "status": case.status}
|
|
|
|
|
|
@router.post("/contents/{content_id}/withdrawals", status_code=200)
|
|
async def withdraw(
|
|
content_id: str,
|
|
db: DbSession,
|
|
user: Annotated[User, Depends(require_state_change)],
|
|
request: Request,
|
|
) -> dict[str, Any]:
|
|
domain = await _domain_of(db, user)
|
|
content = await service.withdraw_content(
|
|
db, domain.id, content_id, getattr(request.state, "trace_id", None)
|
|
)
|
|
return _content_out(content)
|