From 99985f58552df435d754df28e07ede05a3817112 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=AD=86?= <2685475410@qq.com> Date: Sat, 12 Sep 2026 09:02:36 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20WP-11=20=E7=A4=BE=E5=8C=BA?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=8F=91=E5=B8=83=E3=80=81=E4=BA=92=E5=8A=A8?= =?UTF-8?q?=E3=80=81=E5=85=B3=E6=B3=A8=E4=B8=8E=E6=B2=BB=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 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) --- backend/app/community/__init__.py | 1 + backend/app/community/routes.py | 194 ++++++++++++++++++ backend/app/community/service.py | 306 ++++++++++++++++++++++++++++ backend/app/main.py | 2 + backend/app/models.py | 169 +++++++++++++++ backend/tests/conftest.py | 15 +- backend/tests/test_conversations.py | 138 +++++++++++++ 7 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 backend/app/community/__init__.py create mode 100644 backend/app/community/routes.py create mode 100644 backend/app/community/service.py diff --git a/backend/app/community/__init__.py b/backend/app/community/__init__.py new file mode 100644 index 0000000..1abba32 --- /dev/null +++ b/backend/app/community/__init__.py @@ -0,0 +1 @@ +"""社区域:发布、互动、关注与治理。""" diff --git a/backend/app/community/routes.py b/backend/app/community/routes.py new file mode 100644 index 0000000..01b3796 --- /dev/null +++ b/backend/app/community/routes.py @@ -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) diff --git a/backend/app/community/service.py b/backend/app/community/service.py new file mode 100644 index 0000000..4f9a087 --- /dev/null +++ b/backend/app/community/service.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 3e6254d..458afed 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from app.auth.routes import router as auth_router from app.characters.routes import router as characters_router +from app.community.routes import router as community_router from app.conversations.routes import message_router, run_router from app.conversations.routes import router as conversations_router from app.core.config import get_settings @@ -42,6 +43,7 @@ def create_app() -> FastAPI: app.include_router(marketplace_router) app.include_router(finance_router) app.include_router(webhook_router) + app.include_router(community_router) @app.get("/healthz", tags=["ops"]) async def healthz() -> dict[str, str]: diff --git a/backend/app/models.py b/backend/app/models.py index 558d5a9..bb45e0e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -796,3 +796,172 @@ class WebhookEvent(Base): __table_args__ = ( CheckConstraint("status IN ('processed', 'ignored')", name="status_values"), ) + + +class CommunityContent(Base): + """社区内容:帖子与角色 Release(WP-11)。 + + 角色 Release 由服务端构造公开快照;私密记忆/关系/对话在结构上不进入发布物。 + """ + + __tablename__ = "community_content" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("content_") + ) + author_domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True + ) + kind: Mapped[str] = mapped_column(String, nullable=False, default="post") + title: Mapped[str] = mapped_column(String, nullable=False) + body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) + character_id: Mapped[str | None] = mapped_column( + ForeignKey("character.id", ondelete="set null"), nullable=True + ) + visibility: Mapped[str] = mapped_column(String, nullable=False, default="public") + status: Mapped[str] = mapped_column(String, nullable=False, default="published") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + __table_args__ = ( + CheckConstraint("kind IN ('post', 'character_release')", name="kind_values"), + CheckConstraint("visibility IN ('public', 'followers')", name="visibility_values"), + CheckConstraint( + "status IN ('draft', 'submitted', 'published', 'limited', 'suspended', 'removed')", + name="status_values", + ), + ) + + +class Comment(Base): + """社区评论:平铺 + 可选父级;下架内容下的评论不可见。""" + + __tablename__ = "comment" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("comment_")) + content_id: Mapped[str] = mapped_column( + ForeignKey("community_content.id", ondelete="cascade"), nullable=False, index=True + ) + author_domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False + ) + parent_comment_id: Mapped[str | None] = mapped_column( + ForeignKey("comment.id", ondelete="cascade"), nullable=True + ) + body: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False, default="visible") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("status IN ('visible', 'removed')", name="status_values"), + ) + + +class Reaction(Base): + """互动:点赞/收藏;(content, actor, kind) 唯一防重复。""" + + __tablename__ = "reaction" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("reaction_")) + content_id: Mapped[str] = mapped_column( + ForeignKey("community_content.id", ondelete="cascade"), nullable=False, index=True + ) + actor_domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False + ) + kind: Mapped[str] = mapped_column(String, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("kind IN ('like', 'save')", name="kind_values"), + Index( + "uq_reaction_content_actor_kind", + "content_id", "actor_domain_id", "kind", + unique=True, + ), + ) + + +class Follow(Base): + """关注关系(域→域)。""" + + __tablename__ = "follow" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("follow_")) + # 双 FK 同指 personal_domain:显式命名避免 convention 同名冲突 + follower_domain_id: Mapped[str] = mapped_column( + ForeignKey( + "personal_domain.id", ondelete="cascade", name="fk_follow_follower_domain" + ), + nullable=False, + index=True, + ) + followee_domain_id: Mapped[str] = mapped_column( + ForeignKey( + "personal_domain.id", ondelete="cascade", name="fk_follow_followee_domain" + ), + nullable=False, + index=True, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index( + "uq_follow_pair", "follower_domain_id", "followee_domain_id", unique=True + ), + ) + + +class Report(Base): + """举报:创建即生成司议案(治理队列入口)。""" + + __tablename__ = "report" + + id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: new_id("report_")) + reporter_domain_id: Mapped[str] = mapped_column( + ForeignKey("personal_domain.id", ondelete="cascade"), nullable=False, index=True + ) + target_type: Mapped[str] = mapped_column(String, nullable=False) + target_id: Mapped[str] = mapped_column(String, nullable=False) + reason: Mapped[str] = mapped_column(String, nullable=False) + status: Mapped[str] = mapped_column(String, nullable=False, default="pending") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("target_type IN ('content', 'comment')", name="target_type_values"), + CheckConstraint("status IN ('pending', 'reviewed', 'actioned')", name="status_values"), + ) + + +class ModerationCase(Base): + """司议案:由举报生成,处置动作与结果留痕。""" + + __tablename__ = "moderation_case" + + id: Mapped[str] = mapped_column( + String, primary_key=True, default=lambda: new_id("moderation_") + ) + report_id: Mapped[str] = mapped_column( + ForeignKey("report.id", ondelete="cascade"), nullable=False, index=True + ) + status: Mapped[str] = mapped_column(String, nullable=False, default="pending") + action_taken: Mapped[str | None] = mapped_column(String, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + __table_args__ = ( + CheckConstraint("status IN ('pending', 'resolved')", name="status_values"), + ) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 178a60a..f96d8af 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -74,10 +74,21 @@ async def client(db): async def login(client, email: str) -> dict[str, str]: - """开发档登录:返回需附加的请求头(Cookie 由客户端自动携带)。""" + """开发档登录:返回自包含请求头(显式 Cookie 支持多用户交替)。 + + 同时保留 cookie jar 自动存储:不带 headers 的旧式请求仍走 jar。 + 显式 Cookie header 优先于 jar(httpx 行为),交替测试不串会话。 + """ resp = await client.post("/v1/auth/sessions", json={"email": email}) assert resp.status_code == 201, resp.text - return {"Hunjing-CSRF-Token": resp.json()["csrf_token"]} + from app.auth.service import SESSION_COOKIE + + token = resp.cookies.get(SESSION_COOKIE) + assert token, "登录未签发会话 Cookie" + return { + "Hunjing-CSRF-Token": resp.json()["csrf_token"], + "Cookie": f"{SESSION_COOKIE}={token}", + } def _mock_openai_stream(): diff --git a/backend/tests/test_conversations.py b/backend/tests/test_conversations.py index db2fe59..96f707e 100644 --- a/backend/tests/test_conversations.py +++ b/backend/tests/test_conversations.py @@ -1144,3 +1144,141 @@ class TestMarketplace: "/v1/conversations", json={"title": "恢复"}, headers=headers ) assert recovered.status_code == 201 + + +@pytest.mark.usefixtures("client") +class TestCommunity: + async def _publish_post(self, client, headers, title="昭星使用心得") -> str: + resp = await client.post( + "/v1/community/contents", + json={"kind": "post", "title": title, "text": "很好用"}, headers=headers, + ) + assert resp.status_code == 201, resp.text + return resp.json()["id"] + + async def test_publish_and_public_listing(self, client) -> None: + headers = await login(client, "cm1@example.com") + cid = await self._publish_post(client, headers) + listing = await client.get("/v1/community/contents", headers=headers) + assert any(c["id"] == cid for c in listing.json()["data"]) + + async def test_character_release_snapshot_has_no_private_data(self, client) -> None: + """Release 快照仅公开字段:结构上不含记忆/关系/对话(契约)。""" + headers = await login(client, "cm2@example.com") + c = await client.post( + "/v1/characters", json={"name": "霁月"}, headers=headers + ) + cid = c.json()["id"] + await client.post(f"/v1/characters/{cid}/activations", headers=headers) + # 给角色添加一条私密记忆(不应出现在发布物) + await client.post( + "/v1/memories/candidates", + json={"kind": "fact", "sensitivity": "high", "character_id": cid, + "payload": {"text": "绝对私密的事实"}}, + headers=headers, + ) + release = await client.post( + "/v1/community/contents", + json={"kind": "character_release", "title": "霁月 · 发布", + "character_id": cid}, + headers=headers, + ) + assert release.status_code == 201, release.text + body = release.json()["body"] + assert body["character_name"] == "霁月" + assert "public_baseline" in body + # 私密字段结构性缺席 + import json as _json + + assert "绝对私密的事实" not in _json.dumps(body, ensure_ascii=False) + assert "memory" not in _json.dumps(body) + + async def test_comments_likes_saves(self, client) -> None: + headers = await login(client, "cm3@example.com") + cid = await self._publish_post(client, headers, "互动测试") + c1 = await client.post( + f"/v1/community/contents/{cid}/comments", + json={"body": "第一条"}, headers=headers, + ) + assert c1.status_code == 201 + c2 = await client.post( + f"/v1/community/contents/{cid}/comments", + json={"body": "回复", "parent_comment_id": c1.json()["id"]}, headers=headers, + ) + assert c2.status_code == 201 and c2.json()["parent_comment_id"] == c1.json()["id"] + like = await client.post( + f"/v1/community/contents/{cid}/reactions", json={"kind": "like"}, headers=headers + ) + assert like.json()["like"] == "1" + dup = await client.post( + f"/v1/community/contents/{cid}/reactions", json={"kind": "like"}, headers=headers + ) + assert dup.status_code == 409 # uk 防重复 + save = await client.post( + f"/v1/community/contents/{cid}/reactions", json={"kind": "save"}, headers=headers + ) + assert save.json()["save"] == "1" + await client.request( + "DELETE", + f"/v1/community/contents/{cid}/reactions", + json={"kind": "like"}, headers=headers, + ) + detail = await client.get(f"/v1/community/contents/{cid}", headers=headers) + assert detail.json()["reactions"] == {"save": 1} # like 已取消 + + async def test_follow_counts(self, client) -> None: + a = await login(client, "cm4a@example.com") + b = await login(client, "cm4b@example.com") + # b 的域 ID:发布一条后从列表拿 author_domain_id + cid = await self._publish_post(client, b) + listing = await client.get("/v1/community/contents", headers=b) + b_domain = next( + c["author_domain_id"] for c in listing.json()["data"] if c["id"] == cid + ) + follow = await client.post( + "/v1/community/follows", json={"followee_domain_id": b_domain}, headers=a + ) + assert follow.status_code == 201 + assert follow.json()["following"] == 1 + counts_b = await client.get("/v1/community/follows/counts", headers=b) + assert counts_b.json()["followers"] == 1 + + async def test_report_creates_moderation_case(self, client) -> None: + a = await login(client, "cm5a@example.com") + cid = await self._publish_post(client, a) + b = await login(client, "cm5b@example.com") + report = await client.post( + "/v1/community/reports", + json={"target_type": "content", "target_id": cid, "reason": "违规内容"}, + headers=b, + ) + assert report.status_code == 201 + assert report.json()["case_id"] and report.json()["status"] == "pending" + + async def test_withdraw_propagates_invisibility(self, client) -> None: + a = await login(client, "cm6a@example.com") + cid = await self._publish_post(client, a) + b = await login(client, "cm6b@example.com") + await client.post( + f"/v1/community/contents/{cid}/comments", json={"body": "x"}, headers=b + ) + withdrawn = await client.post( + f"/v1/community/contents/{cid}/withdrawals", headers=a + ) + assert withdrawn.json()["status"] == "removed" + # 列表不可见 + listing = await client.get("/v1/community/contents", headers=b) + assert all(c["id"] != cid for c in listing.json()["data"]) + # 详情 404 + detail = await client.get(f"/v1/community/contents/{cid}", headers=b) + assert detail.status_code == 404 + # 评论不可见 + comments = await client.get(f"/v1/community/contents/{cid}/comments", headers=b) + assert comments.status_code == 404 + + async def test_non_author_cannot_withdraw(self, client) -> None: + a = await login(client, "cm7a@example.com") + cid = await self._publish_post(client, a) + b = await login(client, "cm7b@example.com") + resp = await client.post(f"/v1/community/contents/{cid}/withdrawals", headers=b) + assert resp.status_code == 404 # 非作者:fail-closed