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:
@@ -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),
|
||||
|
||||
@@ -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}。"""
|
||||
"""运营端上传资讯封面 / 图集 / 视频封面等:统一走 OSS(news/ 业务目录),返回 {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}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user