feat(content): 资讯发布时间/来源/发布人规范 + 点赞/留言/分享互动体系

- published_at 发布时间(迁移0022):运营端不限、园区端禁早于3天前,双方均支持定时发布;C端/大屏到点才可见
- 发布人:运营端可设(留空显示官方)、园区端固定园区名只能填来源
- 点赞/分享/留言(迁移0023-0025):content_likes 去重点赞、like_count/share_count、留言回复+评论点赞
- 各出口(内容/留言/入驻资料 docs)统一 resolve_url 生成 CDN 直链;入库统一 to_object_path 对象路径
This commit is contained in:
Pine
2026-08-28 17:17:49 +08:00
parent 985b85cb4e
commit 4f11931143
11 changed files with 451 additions and 60 deletions
+73 -8
View File
@@ -13,7 +13,7 @@ from ..dependencies import get_db, optional_current_user
from ..schemas.opc import BidRequest, ProfileUpdate, FinanceRecordCreate, TaskClaimRequest, TaskAssignRequest, TaskRecommendRequest, TaskSelectRecommendRequest
from ...rbac import require_roles, write_audit
from ...infrastructure.repositories import Database, new_id, utcnow_iso
from ...infrastructure.models import FinanceRecord
from ...infrastructure.models import FinanceRecord, ContentComment
from ... import config
from ...services import compute_client, compute_catalog
@@ -115,7 +115,16 @@ async def opc_policy(
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("opc_member")),
):
return {"items": await db.content.list(ctype="policy", status="published")}
return {"items": await db.content.list(ctype="policy", status="published", scheduled_ready=True)}
def _resolve_content_media(item: dict) -> dict:
"""C 端出口:媒体字段(对象路径)统一补全为可访问 URL(CDN 鉴权直链 / 站点绝对路径)。"""
from ...infrastructure.oss import resolve_url
for k in ("publisher_avatar", "cover", "cover_small", "video", "video_cover"):
item[k] = resolve_url(item.get(k, ""))
item["images"] = [resolve_url(u) for u in (item.get("images") or [])]
return item
@router.get("/content", summary="资讯中心(按类别,已发布)")
@@ -124,13 +133,15 @@ async def opc_content(
db: Database = Depends(get_db),
):
"""C 端资讯:type ∈ policy/news/skill/dynamic,仅 published + 公开(is_public)。公开浏览。"""
return {"items": await db.content.list(ctype=type, status="published", public_only=True)}
items = await db.content.list(ctype=type, status="published", public_only=True, scheduled_ready=True)
return {"items": [_resolve_content_media(it) for it in items]}
@router.get("/content/{content_id}", summary="资讯详情")
async def opc_content_detail(
content_id: str,
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
item = await db.content.get(content_id)
if item is None or item.get("status") != "published" or not item.get("is_public"):
@@ -139,22 +150,60 @@ async def opc_content_detail(
if item.get("status") == "published":
await db.content.incr_read_count(content_id)
item["read_count"] = (item.get("read_count") or 0) + 1
item = _resolve_content_media(item)
# 当前登录用户是否已点赞(未登录 false)
item["liked_by_me"] = await db.content.is_liked(content_id, (user or {}).get("id", ""))
return item
@router.get("/content/{content_id}/comments", summary="资讯留言列表")
async def opc_content_comments(
@router.post("/content/{content_id}/like", summary="点赞/取消点赞(登录,一人一赞)")
async def opc_content_like(
content_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
item = await db.content.get(content_id)
if item is None:
raise HTTPException(status_code=404, detail="内容不存在")
return await db.content.toggle_like(content_id, user["id"])
@router.post("/content/{content_id}/share", summary="分享计数 +1(可重复分享)")
async def opc_content_share(
content_id: str,
db: Database = Depends(get_db),
):
return {"items": await db.content.content_comments(content_id)}
"""分享落库:web 复制链接 / 小程序转发成功后调用,share_count +1。公开计数。"""
item = await db.content.get(content_id)
if item is None or item.get("status") != "published" or not item.get("is_public"):
raise HTTPException(status_code=404, detail="内容不存在")
return await db.content.add_share(content_id)
@router.get("/content/{content_id}/comments", summary="资讯留言列表(微信式嵌套)")
async def opc_content_comments(
content_id: str,
db: Database = Depends(get_db),
user: dict | None = Depends(optional_current_user),
):
from ...infrastructure.oss import resolve_url
uid = (user or {}).get("id", "")
items = await db.content.content_comments(content_id, nested=True)
for c in items:
c["avatar"] = resolve_url(c.get("avatar", ""))
c["liked_by_me"] = await db.content.is_comment_liked(c["id"], uid) if uid else False
for rp in c.get("replies", []):
rp["avatar"] = resolve_url(rp.get("avatar", ""))
rp["liked_by_me"] = await db.content.is_comment_liked(rp["id"], uid) if uid else False
return {"items": items}
class ContentCommentBody(BaseModel):
content: str
reply_to: str = "" # 回复目标评论 id(空=顶层留言)
@router.post("/content/{content_id}/comments", summary="发表留言")
@router.post("/content/{content_id}/comments", summary="发表留言/回复")
async def opc_content_add_comment(
content_id: str,
body: ContentCommentBody,
@@ -171,10 +220,26 @@ async def opc_content_add_comment(
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())
comment = await db.content.add_content_comment(content_id, actor, body.content.strip(),
reply_to=body.reply_to.strip())
if comment:
from ...infrastructure.oss import resolve_url
comment["avatar"] = resolve_url(comment.get("avatar", ""))
return {"ok": True, "comment": comment}
@router.post("/comments/{comment_id}/like", summary="评论点赞/取消(登录,一人一赞)")
async def opc_comment_like(
comment_id: str,
db: Database = Depends(get_db),
user: dict = Depends(require_roles("opc_member")),
):
c = await db.session.get(ContentComment, comment_id)
if c is None:
raise HTTPException(status_code=404, detail="留言不存在")
return await db.content.toggle_comment_like(comment_id, user["id"])
@router.get("/finance", summary="财务流水")
async def opc_finance(
db: Database = Depends(get_db),
+20 -8
View File
@@ -300,6 +300,16 @@ async def update_provider(
# ── 内容管理 ──────────────────────────────────────────────────────────────
def _resolve_content_media(items: list[dict]) -> list[dict]:
"""运营端出口:封面/视频/发布人头像等对象路径统一生成 CDN 直链。"""
from ...infrastructure.oss import resolve_url
for it in items:
for k in ("cover", "cover_small", "video", "video_cover", "publisher_avatar"):
it[k] = resolve_url(it.get(k, ""))
it["images"] = [resolve_url(u) for u in (it.get("images") or [])]
return items
@router.get("/content", summary="内容列表")
async def list_content(
ctype: str | None = None,
@@ -307,7 +317,7 @@ async def list_content(
db: Database = Depends(get_db),
_u: dict = Depends(require_roles("operator")),
):
return await db.content.list(ctype=ctype, status=status)
return _resolve_content_media(await db.content.list(ctype=ctype, status=status))
@router.get("/content/{content_id}", summary="内容详情(编辑回填)")
@@ -319,7 +329,7 @@ async def get_content(
item = await db.content.get(content_id)
if item is None:
raise HTTPException(status_code=404, detail="Content not found")
return item
return _resolve_content_media([item])[0]
@router.post("/content", summary="创建内容")
@@ -330,12 +340,14 @@ async def create_content(
actor: dict = Depends(require_permission("action:content.manage")),
):
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 ""
payload["publisher_name"] = "官方"
payload["publisher_avatar"] = ""
elif payload.get("publisher_avatar"):
from ...infrastructure.oss import to_object_path
payload["publisher_avatar"] = to_object_path(payload["publisher_avatar"])
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)
@@ -347,9 +359,9 @@ async def admin_upload(
file: UploadFile = File(...),
actor: dict = Depends(require_permission("action:content.manage")),
):
"""运营端上传资讯封面 / 图集 / 视频封面等:存 uploads 目录,返回 {ok, url}。"""
"""运营端上传资讯封面 / 图集 / 视频封面等:统一走 OSSnews/ 业务目录,返回 {ok, url}。"""
from ...services import media_upload
url = await media_upload.save_media(file)
url = await media_upload.save_media(file, dir="news")
return {"ok": True, "url": url}
+3 -1
View File
@@ -117,7 +117,8 @@ class ContentCreateRequest(BaseModel):
video: str = "" # 视频 URL
video_cover: str = "" # 视频封面
link: str = "" # 外链
publisher_name: str = "" # 发布人昵称(空则用操作者
publisher_name: str = "" # 发布人昵称(仅运营端可设;空则显示「官方」
published_at: str = "" # 发布时间(ISO;可未来=定时发布;空则发布时取当前)
publisher_avatar: str = "" # 发布人头像
card_mode: str = "big" # big 大图 | small 小图(运营端设)
is_public: bool = True # 是否公开
@@ -148,6 +149,7 @@ class ContentUpdateRequest(BaseModel):
link: str | None = None
publisher_name: str | None = None
publisher_avatar: str | None = None
published_at: str | None = None
card_mode: str | None = None
is_public: bool | None = None
source: str | None = None