From a60f0ed1805ee70d7bc25809bc19bece81ec5579 Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 27 Aug 2026 15:28:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E8=B5=84=E8=AE=AF=E8=AF=A6?= =?UTF-8?q?=E6=83=85=20=E9=98=85=E8=AF=BB=E6=95=B0=20+=20=E7=95=99?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移0017:content_items.read_count + content_comments 表 - ContentRepository:_to_dict 加 read_count;incr_read_count/content_comments/add_content_comment - rbac_opc:GET /opc/content/{id} 自增阅读数;GET/POST /opc/content/{id}/comments(发布需 opc_member,带昵称/头像) Co-Authored-By: Claude --- alembic/versions/0017_content_detail.py | 35 +++++++++++++++++++++ app/api/routers/rbac_opc.py | 41 +++++++++++++++++++++++-- app/infrastructure/models.py | 16 ++++++++++ app/infrastructure/repositories.py | 34 ++++++++++++++++++++ 4 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 alembic/versions/0017_content_detail.py diff --git a/alembic/versions/0017_content_detail.py b/alembic/versions/0017_content_detail.py new file mode 100644 index 0000000..4b0f016 --- /dev/null +++ b/alembic/versions/0017_content_detail.py @@ -0,0 +1,35 @@ +"""content read_count + content_comments + +Revision ID: 0017_content_detail +Revises: 0016_content_media +Create Date: 2026-08-27 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0017_content_detail" +down_revision = "0016_content_media" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("read_count", sa.Integer(), nullable=False, server_default="0")) + op.create_table( + "content_comments", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("content_id", sa.String(), default="", index=True), + sa.Column("user_id", sa.String(), default=""), + sa.Column("username", sa.String(), default=""), + sa.Column("nickname", sa.String(), default=""), + sa.Column("avatar", sa.String(), default=""), + sa.Column("content", sa.Text(), default=""), + sa.Column("created_at", sa.String(), default=""), + ) + + +def downgrade() -> None: + op.drop_table("content_comments") + op.drop_column("content_items", "read_count") diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index dc48f7a..4b0774f 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -91,9 +91,8 @@ async def opc_policy( async def opc_content( type: str = "news", db: Database = Depends(get_db), - _u: dict = Depends(require_roles("opc_member")), ): - """C 端资讯:type ∈ policy(政策)/news(资讯)/skill(技能)/dynamic(动态),仅 published。""" + """C 端资讯:type ∈ policy(政策)/news(资讯)/skill(技能)/dynamic(动态),仅 published。公开浏览。""" return {"items": await db.content.list(ctype=type, status="published")} @@ -101,14 +100,50 @@ async def opc_content( async def opc_content_detail( content_id: str, db: Database = Depends(get_db), - _u: dict = Depends(require_roles("opc_member")), ): item = await db.content.get(content_id) if item is None or item.get("status") != "published": raise HTTPException(status_code=404, detail="内容不存在") + # 阅读数自增(每次详情浏览 +1) + if item.get("status") == "published": + await db.content.incr_read_count(content_id) + item["read_count"] = (item.get("read_count") or 0) + 1 return item +@router.get("/content/{content_id}/comments", summary="资讯留言列表") +async def opc_content_comments( + content_id: str, + db: Database = Depends(get_db), +): + return {"items": await db.content.content_comments(content_id)} + + +class ContentCommentBody(BaseModel): + content: str + + +@router.post("/content/{content_id}/comments", summary="发表留言") +async def opc_content_add_comment( + content_id: str, + body: ContentCommentBody, + db: Database = Depends(get_db), + user: dict = Depends(require_roles("opc_member")), +): + if not body.content.strip(): + raise HTTPException(status_code=400, detail="留言不能为空") + item = await db.content.get(content_id) + if item is None or item.get("status") != "published": + raise HTTPException(status_code=404, detail="内容不存在") + # 取当前用户资料(昵称/头像)用于留言展示 + me = await db.users.get_by_id(user["id"]) or {} + actor = {"id": user["id"], "username": user.get("username", ""), + "nickname": (me.get("nickname") or user.get("username", "")), + "avatar": me.get("avatar", "")} + comment = await db.content.add_content_comment(content_id, actor, body.content.strip()) + return {"ok": True, "comment": comment} + + @router.get("/finance", summary="财务流水") async def opc_finance( db: Database = Depends(get_db), diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index bb19bbc..7e302e0 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -306,11 +306,27 @@ class ContentItem(Base): link: Mapped[str] = mapped_column(String, default="") # 外链 publisher_name: Mapped[str] = mapped_column(String, default="") # 发布人昵称快照 publisher_avatar: Mapped[str] = mapped_column(String, default="") # 发布人头像快照 + read_count: Mapped[int] = mapped_column(Integer, default=0) # 阅读数 status: Mapped[str] = mapped_column(String, default="draft") # draft/published/offline created_at: Mapped[str] = mapped_column(String, default="") updated_at: Mapped[str] = mapped_column(String, default="") +class ContentComment(Base): + """资讯留言。""" + + __tablename__ = "content_comments" + + id: Mapped[str] = mapped_column(String, primary_key=True) + content_id: Mapped[str] = mapped_column(String, default="", index=True) + user_id: Mapped[str] = mapped_column(String, default="") + username: Mapped[str] = mapped_column(String, default="") + nickname: Mapped[str] = mapped_column(String, default="") + avatar: Mapped[str] = mapped_column(String, default="") + content: Mapped[str] = mapped_column(Text, default="") + created_at: Mapped[str] = mapped_column(String, default="") + + class SystemConfig(Base): """平台系统配置(键值)。""" diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 0fd5a8f..6bdd5dd 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -20,6 +20,7 @@ from .models import ( AuditLog, Bid, ContentItem, + ContentComment, Contract, Dispute, Escrow, @@ -1281,6 +1282,7 @@ class ContentRepository: "body": c.body, "publisher_id": c.publisher_id, "status": c.status, "cover": c.cover, "images": images, "video": c.video, "video_cover": c.video_cover, "link": c.link, "publisher_name": c.publisher_name, "publisher_avatar": c.publisher_avatar, + "read_count": c.read_count, "created_at": c.created_at, "updated_at": c.updated_at, } @@ -1346,6 +1348,38 @@ class ContentRepository: await self.session.commit() return True + # ---- 阅读数 / 留言 ---- + + async def incr_read_count(self, content_id: str) -> None: + c = await self.session.get(ContentItem, content_id) + if c is None: + return + c.read_count = (c.read_count or 0) + 1 + await self.session.commit() + + async def content_comments(self, content_id: str) -> list[dict]: + rows = (await self.session.scalars( + select(ContentComment).where(ContentComment.content_id == content_id) + .order_by(ContentComment.created_at.desc()) + )).all() + return [{ + "id": r.id, "content_id": r.content_id, "user_id": r.user_id, + "username": r.username, "nickname": r.nickname, "avatar": r.avatar, + "content": r.content, "created_at": r.created_at, + } for r in rows] + + async def add_content_comment(self, content_id: str, user: dict, text: str) -> dict | None: + row = ContentComment(id=new_id("cmt"), content_id=content_id, + user_id=user.get("id", ""), username=user.get("username", ""), + nickname=user.get("nickname") or user.get("username", ""), + avatar=user.get("avatar", ""), content=text, + created_at=utcnow_iso()) + self.session.add(row) + await self.session.commit() + return {"id": row.id, "content_id": row.content_id, "user_id": row.user_id, + "username": row.username, "nickname": row.nickname, "avatar": row.avatar, + "content": row.content, "created_at": row.created_at} + # --------------------------------------------------------------------------- # 系统配置