✨ 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:
chenxin committed 2026-09-12 09:02:36 +08:00
1 parent 34c481ab9e
commit 99985f5855
7 files changed
+823 -2

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""社区域:发布、互动、关注与治理。"""
+194
View File
@@ -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)
+306
View File
@@ -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
+2
View File
@@ -4,6 +4,7 @@ from fastapi import FastAPI
from app.auth.routes import router as auth_router from app.auth.routes import router as auth_router
from app.characters.routes import router as characters_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 message_router, run_router
from app.conversations.routes import router as conversations_router from app.conversations.routes import router as conversations_router
from app.core.config import get_settings from app.core.config import get_settings
@@ -42,6 +43,7 @@ def create_app() -> FastAPI:
app.include_router(marketplace_router) app.include_router(marketplace_router)
app.include_router(finance_router) app.include_router(finance_router)
app.include_router(webhook_router) app.include_router(webhook_router)
app.include_router(community_router)
@app.get("/healthz", tags=["ops"]) @app.get("/healthz", tags=["ops"])
async def healthz() -> dict[str, str]: async def healthz() -> dict[str, str]:
+169
View File
@@ -796,3 +796,172 @@ class WebhookEvent(Base):
__table_args__ = ( __table_args__ = (
CheckConstraint("status IN ('processed', 'ignored')", name="status_values"), 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"),
)
+13 -2
View File
@@ -74,10 +74,21 @@ async def client(db):
async def login(client, email: str) -> dict[str, str]: 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}) resp = await client.post("/v1/auth/sessions", json={"email": email})
assert resp.status_code == 201, resp.text 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(): def _mock_openai_stream():
+138
View File
@@ -1144,3 +1144,141 @@ class TestMarketplace:
"/v1/conversations", json={"title": "恢复"}, headers=headers "/v1/conversations", json={"title": "恢复"}, headers=headers
) )
assert recovered.status_code == 201 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