"""社区域:发布、互动、关注与治理(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