From 89bd7e83905c6891af0ce45d2438bba24c1ae722 Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 27 Aug 2026 15:42:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E5=8F=91=E5=B8=83=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=20=E5=A4=A7/=E5=B0=8F=E5=9B=BE=20+=20=E5=85=AC?= =?UTF-8?q?=E5=BC=80=20+=20=E5=9B=AD=E5=8C=BA=E7=AB=AF=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移0018:content_items 加 card_mode/is_public/source/tenant_id - ContentRepository:_to_dict/create/update 含新字段;list 加 public_only/tenant_id 过滤 - /opc/content 仅 published+公开;/opc/content/{id} 非公开→404 - 园区端 POST /park/api/content:发布人强制=园区名、source=carrier、tenant=本园、is_public 默认false(仅大屏)、card_mode默认small - 园区端 GET /park/api/content:本园区内容(含未公开)供大屏 - schemas 补 card_mode/is_public Co-Authored-By: Claude --- alembic/versions/0018_content_publish.py | 29 +++++++++++++++ app/api/routers/rbac_opc.py | 6 ++-- app/api/schemas/operator.py | 6 ++++ app/infrastructure/models.py | 4 +++ app/infrastructure/repositories.py | 16 +++++++-- app/park/routers.py | 45 ++++++++++++++++++++++++ 6 files changed, 100 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/0018_content_publish.py diff --git a/alembic/versions/0018_content_publish.py b/alembic/versions/0018_content_publish.py new file mode 100644 index 0000000..fc7f39d --- /dev/null +++ b/alembic/versions/0018_content_publish.py @@ -0,0 +1,29 @@ +"""资讯发布体系:card_mode/is_public/source/tenant_id + +Revision ID: 0018_content_publish +Revises: 0017_content_detail +Create Date: 2026-08-27 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0018_content_publish" +down_revision = "0017_content_detail" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("card_mode", sa.String(), nullable=False, server_default="big")) + op.add_column("content_items", sa.Column("is_public", sa.Boolean(), nullable=False, server_default="1")) + op.add_column("content_items", sa.Column("source", sa.String(), nullable=False, server_default="operator")) + op.add_column("content_items", sa.Column("tenant_id", sa.String(), nullable=False, server_default="")) + + +def downgrade() -> None: + op.drop_column("content_items", "tenant_id") + op.drop_column("content_items", "source") + op.drop_column("content_items", "is_public") + op.drop_column("content_items", "card_mode") diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index 4b0774f..e0e792f 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -92,8 +92,8 @@ async def opc_content( type: str = "news", db: Database = Depends(get_db), ): - """C 端资讯:type ∈ policy(政策)/news(资讯)/skill(技能)/dynamic(动态),仅 published。公开浏览。""" - return {"items": await db.content.list(ctype=type, status="published")} + """C 端资讯:type ∈ policy/news/skill/dynamic,仅 published + 公开(is_public)。公开浏览。""" + return {"items": await db.content.list(ctype=type, status="published", public_only=True)} @router.get("/content/{content_id}", summary="资讯详情") @@ -102,7 +102,7 @@ async def opc_content_detail( db: Database = Depends(get_db), ): item = await db.content.get(content_id) - if item is None or item.get("status") != "published": + if item is None or item.get("status") != "published" or not item.get("is_public"): raise HTTPException(status_code=404, detail="内容不存在") # 阅读数自增(每次详情浏览 +1) if item.get("status") == "published": diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 70a1534..e9df58d 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -93,6 +93,8 @@ class ContentCreateRequest(BaseModel): link: str = "" # 外链 publisher_name: str = "" # 发布人昵称(空则用操作者) publisher_avatar: str = "" # 发布人头像 + card_mode: str = "big" # big 大图 | small 小图(运营端设) + is_public: bool = True # 是否公开 status: str = "draft" @@ -113,6 +115,10 @@ class ContentUpdateRequest(BaseModel): link: str | None = None publisher_name: str | None = None publisher_avatar: str | None = None + card_mode: str | None = None + is_public: bool | None = None + source: str | None = None + tenant_id: str | None = None status: str | None = None diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 7e302e0..38a2109 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -307,6 +307,10 @@ class ContentItem(Base): 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) # 阅读数 + card_mode: Mapped[str] = mapped_column(String, default="big") # big 大图 | small 小图(运营端设) + is_public: Mapped[bool] = mapped_column(Boolean, default=True) # 是否公开(公开=C端,否则仅园区大屏) + source: Mapped[str] = mapped_column(String, default="operator") # operator 运营端 | carrier 园区端 + tenant_id: Mapped[str] = mapped_column(String, default="") # 园区端发布所属园区 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="") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 6bdd5dd..c92ee88 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -1270,7 +1270,7 @@ class ContentRepository: # 富字段白名单(images 为 list,落库转 json) _FIELDS = {"type", "title", "summary", "body", "status", "publisher_id", "cover", "images", "video", "video_cover", "link", - "publisher_name", "publisher_avatar"} + "publisher_name", "publisher_avatar", "card_mode", "is_public", "source", "tenant_id"} def _to_dict(self, c: ContentItem) -> dict: try: @@ -1282,16 +1282,22 @@ 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, + "read_count": c.read_count, "card_mode": c.card_mode, "is_public": bool(c.is_public), + "source": c.source, "tenant_id": c.tenant_id, "created_at": c.created_at, "updated_at": c.updated_at, } - async def list(self, ctype: str | None = None, status: str | None = None) -> list[dict]: + async def list(self, ctype: str | None = None, status: str | None = None, + public_only: bool = False, tenant_id: str | None = None) -> list[dict]: q = select(ContentItem).order_by(ContentItem.created_at.desc()) if ctype: q = q.where(ContentItem.type == ctype) if status: q = q.where(ContentItem.status == status) + if public_only: + q = q.where(ContentItem.is_public.is_(True)) + if tenant_id: + q = q.where(ContentItem.tenant_id == tenant_id) return [self._to_dict(c) for c in await self.session.scalars(q)] async def create(self, fields: dict) -> dict: @@ -1306,6 +1312,10 @@ class ContentRepository: link=fields.get("link", ""), publisher_name=fields.get("publisher_name", ""), publisher_avatar=fields.get("publisher_avatar", ""), + card_mode=fields.get("card_mode", "big"), + is_public=bool(fields.get("is_public", True)), + source=fields.get("source", "operator"), + tenant_id=fields.get("tenant_id", ""), status=fields.get("status", "draft"), created_at=now, updated_at=now) self.session.add(c) await self.session.commit() diff --git a/app/park/routers.py b/app/park/routers.py index 367e1e6..4fe75ea 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -1079,3 +1079,48 @@ async def carrier_company_compute(cid: str, body: CompanyComputeBody, user: dict logging.getLogger(__name__).warning("企业配额发放失败 %s: %s", m.get("username"), exc) return {"ok": True, "company": updated, "quota_add": body.quota_add} + + +# ==================== 园区端:发布资讯 + 大屏内容 ==================== + +class ParkContentBody(BaseModel): + type: str = "news" + title: str + summary: str = "" + body: str = "" + cover: str = "" + video: str = "" + video_cover: str = "" + link: str = "" + card_mode: str = "small" # 园区默认小图;运营端可改大/小图 + + +@router.post("/api/content", summary="园区端发布资讯(发布人=园区名,默认不公开)") +async def carrier_publish_content( + body: ParkContentBody, + request: Request, + db=Depends(_carrier_db), + user: dict = Depends(_carrier_user), +): + t = await _my_park(user) + item = await db.content.create({ + "type": body.type, "title": body.title, "summary": body.summary, "body": body.body, + "cover": body.cover, "video": body.video, "video_cover": body.video_cover, "link": body.link, + "publisher_name": t["name"], "publisher_avatar": "", # 发布人强制=园区名,不可改 + "source": "carrier", "tenant_id": t["id"], + "card_mode": body.card_mode or "small", + "is_public": False, # 默认不公开:仅本园区大屏;由运营端切公开 + "status": "published", + }) + await write_audit(db, action="content.publish", resource="content", resource_id=item["id"], + detail=f"carrier {item['title']}", user=user, request=request) + return {"ok": True, "content": item} + + +@router.get("/api/content", summary="园区资讯列表(本园区,含未公开,供大屏)") +async def carrier_content_list( + db=Depends(_carrier_db), + user: dict = Depends(_carrier_user), +): + t = await _my_park(user) + return {"items": await db.content.list(tenant_id=t["id"])}