feat(content): 发布体系 大/小图 + 公开 + 园区端发布

- 迁移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 <noreply@anthropic.com>
This commit is contained in:
Pine
2026-08-27 15:42:39 +08:00
parent a60f0ed180
commit 89bd7e8390
6 changed files with 100 additions and 6 deletions
+29
View File
@@ -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")
+3 -3
View File
@@ -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":
+6
View File
@@ -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
+4
View File
@@ -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="")
+13 -3
View File
@@ -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()
+45
View File
@@ -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"])}