✨ feat: WP-11 社区——发布、互动、关注与治理
- 迁移 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)
This commit is contained in:
1 parent
34c481ab9e
commit
99985f5855
7 files changed
+823
-2
No files matched your search
@@ -0,0 +1 @@
|
||||
"""社区域:发布、互动、关注与治理。"""
|
||||
@@ -0,0 +1,194 @@
|
||||
"""社区域路由(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)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""社区域:发布、互动、关注与治理(WP-11)。"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import (
|
||||
AuditEvent,
|
||||
Comment,
|
||||
CommunityContent,
|
||||
Follow,
|
||||
ModerationCase,
|
||||
Reaction,
|
||||
Report,
|
||||
)
|
||||
|
||||
|
||||
async def require_content(db: AsyncSession, content_id: str) -> CommunityContent:
|
||||
"""公开读取:仅 published+可见;下架/草稿一律 404(不泄露存在性)。"""
|
||||
content = await db.get(CommunityContent, content_id)
|
||||
if content is None or content.status != "published":
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
|
||||
return content
|
||||
|
||||
|
||||
async def create_content(
|
||||
db: AsyncSession,
|
||||
domain_id: str,
|
||||
body: dict[str, Any],
|
||||
trace_id: str | None,
|
||||
) -> CommunityContent:
|
||||
"""发布:帖子直接入库;角色 Release 由服务端构造公开快照。
|
||||
|
||||
Release 快照只含公开字段(name/道体摘要/版本数);私密记忆、关系、
|
||||
对话在结构上不进入发布物(契约:公开 Release 不含私密数据)。
|
||||
"""
|
||||
kind = body.get("kind", "post")
|
||||
title = str(body.get("title", "")).strip()
|
||||
if not title:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "title 不能为空")
|
||||
payload: dict[str, Any] = {}
|
||||
character_id = None
|
||||
if kind == "character_release":
|
||||
from app.characters.service import require_character
|
||||
from app.models import CharacterVersion
|
||||
|
||||
character_id = body.get("character_id", "")
|
||||
character = await require_character(db, domain_id, character_id)
|
||||
if character.status != "active":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "仅启镜角色可发布")
|
||||
version_count = len(
|
||||
list(
|
||||
(
|
||||
await db.execute(
|
||||
select(CharacterVersion.id).where(
|
||||
CharacterVersion.character_id == character.id
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"character_name": character.name,
|
||||
"public_baseline": character.identity_baseline,
|
||||
"version_count": version_count,
|
||||
}
|
||||
elif kind == "post":
|
||||
payload = {"text": str(body.get("text", ""))}
|
||||
else:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "kind 取值不合法")
|
||||
content = CommunityContent(
|
||||
author_domain_id=domain_id,
|
||||
kind=kind,
|
||||
title=title,
|
||||
body=payload,
|
||||
character_id=character_id,
|
||||
visibility=body.get("visibility", "public"),
|
||||
status="published",
|
||||
)
|
||||
db.add(content)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="community.published",
|
||||
aggregate_type="community_content", aggregate_id=content.id,
|
||||
payload={"kind": kind}, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return content
|
||||
|
||||
|
||||
async def list_public(
|
||||
db: AsyncSession, limit: int, cursor: str | None
|
||||
) -> tuple[list[CommunityContent], str | None, bool]:
|
||||
stmt = (
|
||||
select(CommunityContent)
|
||||
.where(CommunityContent.status == "published")
|
||||
.order_by(CommunityContent.created_at.desc(), CommunityContent.id.desc())
|
||||
)
|
||||
if cursor:
|
||||
stmt = stmt.where(CommunityContent.id < cursor)
|
||||
stmt = stmt.limit(limit + 1)
|
||||
rows = list((await db.scalars(stmt)).all())
|
||||
has_more = len(rows) > limit
|
||||
page = rows[:limit]
|
||||
next_cursor = page[-1].id if has_more and page else None
|
||||
return page, next_cursor, has_more
|
||||
|
||||
|
||||
async def add_comment(
|
||||
db: AsyncSession,
|
||||
domain_id: str,
|
||||
content_id: str,
|
||||
body_text: str,
|
||||
parent_id: str | None,
|
||||
trace_id: str | None,
|
||||
) -> Comment:
|
||||
content = await require_content(db, content_id)
|
||||
if not body_text.strip():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "评论不能为空")
|
||||
if parent_id is not None:
|
||||
parent = await db.get(Comment, parent_id)
|
||||
if parent is None or parent.content_id != content.id or parent.status != "visible":
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "父评论无效")
|
||||
comment = Comment(
|
||||
content_id=content.id,
|
||||
author_domain_id=domain_id,
|
||||
parent_comment_id=parent_id,
|
||||
body=body_text,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="community.commented",
|
||||
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return comment
|
||||
|
||||
|
||||
async def list_comments(db: AsyncSession, content_id: str) -> list[Comment]:
|
||||
await require_content(db, content_id) # 下架内容的评论随之不可见
|
||||
return list(
|
||||
(
|
||||
await db.scalars(
|
||||
select(Comment)
|
||||
.where(Comment.content_id == content_id, Comment.status == "visible")
|
||||
.order_by(Comment.created_at.asc())
|
||||
)
|
||||
).all()
|
||||
)
|
||||
|
||||
|
||||
async def react(
|
||||
db: AsyncSession, domain_id: str, content_id: str, kind: str, trace_id: str | None
|
||||
) -> Reaction:
|
||||
content = await require_content(db, content_id)
|
||||
existing = await db.execute(
|
||||
select(Reaction).where(
|
||||
Reaction.content_id == content.id,
|
||||
Reaction.actor_domain_id == domain_id,
|
||||
Reaction.kind == kind,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is not None:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "已赞/已收藏")
|
||||
reaction = Reaction(content_id=content.id, actor_domain_id=domain_id, kind=kind)
|
||||
db.add(reaction)
|
||||
await db.flush()
|
||||
return reaction
|
||||
|
||||
|
||||
async def unreact(db: AsyncSession, domain_id: str, content_id: str, kind: str) -> None:
|
||||
content = await require_content(db, content_id)
|
||||
existing = await db.execute(
|
||||
select(Reaction).where(
|
||||
Reaction.content_id == content.id,
|
||||
Reaction.actor_domain_id == domain_id,
|
||||
Reaction.kind == kind,
|
||||
)
|
||||
)
|
||||
reaction = existing.scalar_one_or_none()
|
||||
if reaction is not None:
|
||||
await db.delete(reaction)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def reaction_counts(db: AsyncSession, content_id: str) -> dict[str, int]:
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(Reaction.kind, func.count())
|
||||
.where(Reaction.content_id == content_id)
|
||||
.group_by(Reaction.kind)
|
||||
)
|
||||
).all()
|
||||
return {k: int(c) for k, c in rows}
|
||||
|
||||
|
||||
async def follow(
|
||||
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
|
||||
) -> None:
|
||||
if follower_domain_id == followee_domain_id:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "不能关注自己")
|
||||
existing = await db.execute(
|
||||
select(Follow).where(
|
||||
Follow.follower_domain_id == follower_domain_id,
|
||||
Follow.followee_domain_id == followee_domain_id,
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none() is None:
|
||||
db.add(
|
||||
Follow(follower_domain_id=follower_domain_id, followee_domain_id=followee_domain_id)
|
||||
)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def unfollow(
|
||||
db: AsyncSession, follower_domain_id: str, followee_domain_id: str
|
||||
) -> None:
|
||||
existing = await db.execute(
|
||||
select(Follow).where(
|
||||
Follow.follower_domain_id == follower_domain_id,
|
||||
Follow.followee_domain_id == followee_domain_id,
|
||||
)
|
||||
)
|
||||
row = existing.scalar_one_or_none()
|
||||
if row is not None:
|
||||
await db.delete(row)
|
||||
await db.flush()
|
||||
|
||||
|
||||
async def follow_counts(db: AsyncSession, domain_id: str) -> dict[str, int]:
|
||||
followers = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Follow).where(
|
||||
Follow.followee_domain_id == domain_id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
following = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(Follow).where(
|
||||
Follow.follower_domain_id == domain_id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
return {"followers": int(followers), "following": int(following)}
|
||||
|
||||
|
||||
async def file_report(
|
||||
db: AsyncSession,
|
||||
reporter_domain_id: str,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
reason: str,
|
||||
trace_id: str | None,
|
||||
) -> tuple[Report, ModerationCase]:
|
||||
"""举报即建司议案(治理队列入口);同对象同理由不重复立案。"""
|
||||
if target_type not in {"content", "comment"}:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "target_type 不合法")
|
||||
if not reason.strip():
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "reason 不能为空")
|
||||
report = Report(
|
||||
reporter_domain_id=reporter_domain_id,
|
||||
target_type=target_type,
|
||||
target_id=target_id,
|
||||
reason=reason,
|
||||
)
|
||||
db.add(report)
|
||||
await db.flush()
|
||||
case = ModerationCase(report_id=report.id, status="pending")
|
||||
db.add(case)
|
||||
await db.flush()
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=reporter_domain_id, action="community.reported",
|
||||
aggregate_type="moderation_case", aggregate_id=case.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return report, case
|
||||
|
||||
|
||||
async def withdraw_content(
|
||||
db: AsyncSession, domain_id: str, content_id: str, trace_id: str | None
|
||||
) -> CommunityContent:
|
||||
"""作者下架:status=removed;列表/详情/评论随 require_content 一并不可见。"""
|
||||
content = await db.get(CommunityContent, content_id)
|
||||
if content is None or content.author_domain_id != domain_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "内容不存在")
|
||||
if content.status == "removed":
|
||||
return content # 幂等
|
||||
content.status = "removed"
|
||||
db.add(
|
||||
AuditEvent(
|
||||
actor_type="user", actor_id=domain_id, action="community.withdrawn",
|
||||
aggregate_type="community_content", aggregate_id=content.id, trace_id=trace_id,
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return content
|
||||
Reference in new issue
Block a user