From 71b1f03bcacd5a0fcc5407ab774964b2d4ae411b Mon Sep 17 00:00:00 2001 From: Pine Date: Thu, 27 Aug 2026 14:35:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E8=B5=84=E8=AE=AF=E5=AF=8C?= =?UTF-8?q?=E7=BC=96=E8=BE=91(=E5=B0=81=E9=9D=A2/=E5=9B=BE=E9=9B=86/?= =?UTF-8?q?=E8=A7=86=E9=A2=91/=E5=A4=96=E9=93=BE/=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E4=BA=BA)=20+=20=E5=AA=92=E4=BD=93=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移0016:content_items 加 cover/images_json/video/video_cover/link/publisher_name/publisher_avatar - ContentRepository:_to_dict/create/update 支持富字段;images(list)↔images_json;update 白名单含 publisher_id - schemas:ContentCreate/UpdateRequest 加富字段 - rbac_operator:POST /content 注入发布人(actor 昵称/头像快照);新增 POST /admin/upload(媒体上传) - app/services/media_upload.py:统一上传(serverdata/uploads + abs_url),与 /api/upload 同契约 Co-Authored-By: Claude --- alembic/versions/0016_content_media.py | 35 +++++++++++++++++++ app/api/routers/rbac_operator.py | 22 ++++++++++-- app/api/schemas/operator.py | 15 +++++++- app/infrastructure/models.py | 8 +++++ app/infrastructure/repositories.py | 25 ++++++++++++-- app/services/media_upload.py | 48 ++++++++++++++++++++++++++ 6 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 alembic/versions/0016_content_media.py create mode 100644 app/services/media_upload.py diff --git a/alembic/versions/0016_content_media.py b/alembic/versions/0016_content_media.py new file mode 100644 index 0000000..409d3de --- /dev/null +++ b/alembic/versions/0016_content_media.py @@ -0,0 +1,35 @@ +"""资讯富字段:封面/图集/视频/外链/发布人 + +Revision ID: 0016_content_media +Revises: 0015_company_compute +Create Date: 2026-08-27 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0016_content_media" +down_revision = "0015_company_compute" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("cover", sa.String(), nullable=False, server_default="")) + op.add_column("content_items", sa.Column("images_json", sa.Text(), nullable=False, server_default="[]")) + op.add_column("content_items", sa.Column("video", sa.String(), nullable=False, server_default="")) + op.add_column("content_items", sa.Column("video_cover", sa.String(), nullable=False, server_default="")) + op.add_column("content_items", sa.Column("link", sa.String(), nullable=False, server_default="")) + op.add_column("content_items", sa.Column("publisher_name", sa.String(), nullable=False, server_default="")) + op.add_column("content_items", sa.Column("publisher_avatar", sa.String(), nullable=False, server_default="")) + + +def downgrade() -> None: + op.drop_column("content_items", "publisher_avatar") + op.drop_column("content_items", "publisher_name") + op.drop_column("content_items", "link") + op.drop_column("content_items", "video_cover") + op.drop_column("content_items", "video") + op.drop_column("content_items", "images_json") + op.drop_column("content_items", "cover") diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index f5bb44b..57aa199 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -7,7 +7,7 @@ from __future__ import annotations import json -from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile from pydantic import BaseModel from ..dependencies import get_db @@ -209,12 +209,30 @@ async def create_content( db: Database = Depends(get_db), actor: dict = Depends(require_permission("action:content.manage")), ): - item = await db.content.create(req.model_dump(exclude_none=True)) + payload = req.model_dump(exclude_none=True) + # 发布人:未显式指定时用当前操作者(昵称/头像快照) + payload.setdefault("publisher_id", actor.get("id", "")) + if not payload.get("publisher_name"): + u = await db.users.get_by_id(actor.get("id", "")) + payload["publisher_name"] = (u or {}).get("nickname") or actor.get("username") or "平台" + payload["publisher_avatar"] = (u or {}).get("avatar", "") or "" + item = await db.content.create(payload) await write_audit(db, action="content.create", resource="content", resource_id=item["id"], detail=item["title"], user=actor, request=request) return item +@router.post("/upload", summary="上传媒体(图片/pdf/doc/docx)") +async def admin_upload( + file: UploadFile = File(...), + actor: dict = Depends(require_permission("action:content.manage")), +): + """运营端上传资讯封面 / 图集 / 视频封面等:存 uploads 目录,返回 {ok, url}。""" + from ...services import media_upload + url = await media_upload.save_media(file) + return {"ok": True, "url": url} + + @router.post("/content/{content_id}/status", summary="发布/下架内容") async def set_content_status( content_id: str, diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index b07c005..70a1534 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -86,10 +86,16 @@ class ContentCreateRequest(BaseModel): title: str summary: str = "" body: str = "" + cover: str = "" # 封面图 + images: list[str] = [] # 图集 + video: str = "" # 视频 URL + video_cover: str = "" # 视频封面 + link: str = "" # 外链 + publisher_name: str = "" # 发布人昵称(空则用操作者) + publisher_avatar: str = "" # 发布人头像 status: str = "draft" - class ContentStatusRequest(BaseModel): status: str # published/offline/draft @@ -100,6 +106,13 @@ class ContentUpdateRequest(BaseModel): title: str | None = None summary: str | None = None body: str | None = None + cover: str | None = None + images: list[str] | None = None + video: str | None = None + video_cover: str | None = None + link: str | None = None + publisher_name: str | None = None + publisher_avatar: str | None = None status: str | None = None diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index c0a7f9b..bb19bbc 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -298,6 +298,14 @@ class ContentItem(Base): summary: Mapped[str] = mapped_column(String, default="") body: Mapped[str] = mapped_column(Text, default="") publisher_id: Mapped[str | None] = mapped_column(ForeignKey("users.id"), nullable=True) + # 资讯富字段:封面/图集/视频/外链/发布人 + cover: Mapped[str] = mapped_column(String, default="") # 封面图 URL + images_json: Mapped[str] = mapped_column(Text, default="[]") # 图集(JSON url 数组) + video: Mapped[str] = mapped_column(String, default="") # 视频 URL + video_cover: Mapped[str] = mapped_column(String, default="") # 视频封面 + link: Mapped[str] = mapped_column(String, default="") # 外链 + publisher_name: Mapped[str] = mapped_column(String, default="") # 发布人昵称快照 + publisher_avatar: 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 ab48849..0fd5a8f 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -1266,10 +1266,21 @@ class ContentRepository: def __init__(self, session: AsyncSession): self.session = session + # 富字段白名单(images 为 list,落库转 json) + _FIELDS = {"type", "title", "summary", "body", "status", "publisher_id", + "cover", "images", "video", "video_cover", "link", + "publisher_name", "publisher_avatar"} + def _to_dict(self, c: ContentItem) -> dict: + try: + images = json.loads(c.images_json or "[]") + except (ValueError, TypeError): + images = [] return { "id": c.id, "type": c.type, "title": c.title, "summary": c.summary, "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, "created_at": c.created_at, "updated_at": c.updated_at, } @@ -1283,9 +1294,16 @@ class ContentRepository: async def create(self, fields: dict) -> dict: now = utcnow_iso() + images = fields.get("images") or [] + images_json = json.dumps(images, ensure_ascii=False) if isinstance(images, list) else "[]" c = ContentItem(id=new_id("cont"), type=fields.get("type", "news"), title=fields.get("title", ""), summary=fields.get("summary", ""), body=fields.get("body", ""), publisher_id=fields.get("publisher_id"), + cover=fields.get("cover", ""), images_json=images_json, + video=fields.get("video", ""), video_cover=fields.get("video_cover", ""), + link=fields.get("link", ""), + publisher_name=fields.get("publisher_name", ""), + publisher_avatar=fields.get("publisher_avatar", ""), status=fields.get("status", "draft"), created_at=now, updated_at=now) self.session.add(c) await self.session.commit() @@ -1301,9 +1319,10 @@ class ContentRepository: return self._to_dict(c) async def update(self, content_id: str, fields: dict) -> dict | None: - """更新内容(仅传入字段;type/title/summary/body/status)。""" - allowed = {"type", "title", "summary", "body", "status"} - patch = {k: v for k, v in fields.items() if v is not None and k in allowed} + """更新内容(仅传入字段;含富字段 封面/图集/视频/发布人)。""" + patch = {k: v for k, v in fields.items() if v is not None and k in self._FIELDS} + if "images" in patch and isinstance(patch["images"], list): + patch["images_json"] = json.dumps(patch.pop("images"), ensure_ascii=False) if not patch: return await self.get(content_id) c = await self.session.get(ContentItem, content_id) diff --git a/app/services/media_upload.py b/app/services/media_upload.py new file mode 100644 index 0000000..1295194 --- /dev/null +++ b/app/services/media_upload.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +"""媒体上传(图片/pdf/doc 等)—— 存 serverdata/uploads,返回绝对 URL。 + +与 app/training/main.py 的 /api/upload 共用同一目录与返回契约 {ok, url}。 +""" +from __future__ import annotations + +import secrets +import time +from pathlib import Path + +from fastapi import HTTPException, UploadFile + +# server-data 上传目录(server-core 根下 serverdata/uploads) +SERVER_CORE_DIR = Path(__file__).resolve().parents[2] +UPLOAD_DIR = SERVER_CORE_DIR / "serverdata" / "uploads" +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + +# 站点公开基础地址(把 /uploads 相对路径补全为完整 HTTPS) +PUBLIC_BASE = __import__("os").environ.get("PUBLIC_BASE", "https://opc.pinesound.cn").rstrip("/") + +ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf", ".doc", ".docx"} +MAX_SIZE = 20 * 1024 * 1024 # 20MB + + +def abs_url(u: str | None) -> str: + """相对路径补全为绝对 URL;已是完整 URL 或空则原样返回。""" + if not u: + return u or "" + u = str(u).strip() + if u.startswith("http://") or u.startswith("https://"): + return u + return PUBLIC_BASE + u + + +async def save_media(file: UploadFile) -> str: + """保存上传文件到 uploads 目录,返回绝对 URL;不合法抛 400/413。""" + name = (file.filename or "").rsplit("/", 1)[-1] + ext = ("." + name.split(".")[-1].lower()) if "." in name else "" + if ext not in ALLOWED_EXT: + raise HTTPException(status_code=400, detail=f"不支持的文件类型({ext or '无扩展名'})") + payload = await file.read() + if len(payload) > MAX_SIZE: + raise HTTPException(status_code=413, detail="文件超过 20MB 限制") + fname = f"up_{int(time.time())}_{secrets.token_hex(2)}{ext}" + with open(UPLOAD_DIR / fname, "wb") as f: + f.write(payload) + return abs_url(f"/uploads/{fname}")