diff --git a/alembic/versions/0022_content_published_at.py b/alembic/versions/0022_content_published_at.py new file mode 100644 index 0000000..faab703 --- /dev/null +++ b/alembic/versions/0022_content_published_at.py @@ -0,0 +1,23 @@ +"""内容发布时间:published_at(支持定时发布与运营端回填) + +Revision ID: 0022_content_published_at +Revises: 0021_park_profile +Create Date: 2026-08-28 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0022_content_published_at" +down_revision = "0021_park_profile" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("published_at", sa.String(), nullable=False, server_default="")) + + +def downgrade() -> None: + op.drop_column("content_items", "published_at") diff --git a/alembic/versions/0023_content_likes.py b/alembic/versions/0023_content_likes.py new file mode 100644 index 0000000..22aa309 --- /dev/null +++ b/alembic/versions/0023_content_likes.py @@ -0,0 +1,31 @@ +"""资讯点赞:like_count 计数 + content_likes 去重表 + +Revision ID: 0023_content_likes +Revises: 0022_content_published_at +Create Date: 2026-08-28 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0023_content_likes" +down_revision = "0022_content_published_at" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("like_count", sa.Integer(), nullable=False, server_default="0")) + op.create_table( + "content_likes", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("content_id", sa.String(), nullable=False, server_default="", index=True), + sa.Column("user_id", sa.String(), nullable=False, server_default="", index=True), + sa.Column("created_at", sa.String(), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_table("content_likes") + op.drop_column("content_items", "like_count") diff --git a/alembic/versions/0024_content_share_count.py b/alembic/versions/0024_content_share_count.py new file mode 100644 index 0000000..45a9ac4 --- /dev/null +++ b/alembic/versions/0024_content_share_count.py @@ -0,0 +1,23 @@ +"""资讯分享计数:share_count + +Revision ID: 0024_content_share_count +Revises: 0023_content_likes +Create Date: 2026-08-28 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0024_content_share_count" +down_revision = "0023_content_likes" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_items", sa.Column("share_count", sa.Integer(), nullable=False, server_default="0")) + + +def downgrade() -> None: + op.drop_column("content_items", "share_count") diff --git a/alembic/versions/0025_comment_reply_like.py b/alembic/versions/0025_comment_reply_like.py new file mode 100644 index 0000000..600a14f --- /dev/null +++ b/alembic/versions/0025_comment_reply_like.py @@ -0,0 +1,33 @@ +"""留言区微信化:回复(reply_to)+ 评论点赞(like_count / comment_likes) + +Revision ID: 0025_comment_reply_like +Revises: 0024_content_share_count +Create Date: 2026-08-28 +""" +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0025_comment_reply_like" +down_revision = "0024_content_share_count" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("content_comments", sa.Column("reply_to", sa.String(), nullable=False, server_default="")) + op.add_column("content_comments", sa.Column("like_count", sa.Integer(), nullable=False, server_default="0")) + op.create_table( + "content_comment_likes", + sa.Column("id", sa.String(), primary_key=True), + sa.Column("comment_id", sa.String(), nullable=False, server_default="", index=True), + sa.Column("user_id", sa.String(), nullable=False, server_default="", index=True), + sa.Column("created_at", sa.String(), nullable=False, server_default=""), + ) + + +def downgrade() -> None: + op.drop_table("content_comment_likes") + op.drop_column("content_comments", "like_count") + op.drop_column("content_comments", "reply_to") diff --git a/app/api/routers/rbac_opc.py b/app/api/routers/rbac_opc.py index b13c6dd..e3ba32f 100644 --- a/app/api/routers/rbac_opc.py +++ b/app/api/routers/rbac_opc.py @@ -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), diff --git a/app/api/routers/rbac_operator.py b/app/api/routers/rbac_operator.py index 74d1672..bcfed99 100644 --- a/app/api/routers/rbac_operator.py +++ b/app/api/routers/rbac_operator.py @@ -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} diff --git a/app/api/schemas/operator.py b/app/api/schemas/operator.py index 759b266..1aa17e8 100644 --- a/app/api/schemas/operator.py +++ b/app/api/schemas/operator.py @@ -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 diff --git a/app/infrastructure/models.py b/app/infrastructure/models.py index 7735b7e..add1c92 100644 --- a/app/infrastructure/models.py +++ b/app/infrastructure/models.py @@ -327,18 +327,32 @@ 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) # 阅读数 + like_count: Mapped[int] = mapped_column(Integer, default=0) # 点赞数 + share_count: Mapped[int] = mapped_column(Integer, default=0) # 分享数(每转发一次 +1) 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="") # 园区端发布所属园区 priority: Mapped[int] = mapped_column(Integer, default=0) # 优先级 0-999 越大越靠前(C端排序) status: Mapped[str] = mapped_column(String, default="draft") # draft/pending/published/offline(pending=待审核) + published_at: Mapped[str] = mapped_column(String, default="") # 发布时间(可未来=定时发布;C端到点才可见) created_at: Mapped[str] = mapped_column(String, default="") updated_at: Mapped[str] = mapped_column(String, default="") +class ContentLike(Base): + """资讯点赞(一人一赞,可取消)。""" + + __tablename__ = "content_likes" + + id: Mapped[str] = mapped_column(String, primary_key=True) + content_id: Mapped[str] = mapped_column(String, default="", index=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) + created_at: Mapped[str] = mapped_column(String, default="") + + class ContentComment(Base): - """资讯留言。""" + """资讯留言(支持回复:reply_to 为空=顶层,否则为父评论 id)。""" __tablename__ = "content_comments" @@ -349,6 +363,19 @@ class ContentComment(Base): nickname: Mapped[str] = mapped_column(String, default="") avatar: Mapped[str] = mapped_column(String, default="") content: Mapped[str] = mapped_column(Text, default="") + reply_to: Mapped[str] = mapped_column(String, default="") # 父评论 id(回复嵌套) + like_count: Mapped[int] = mapped_column(Integer, default=0) # 评论点赞数 + created_at: Mapped[str] = mapped_column(String, default="") + + +class ContentCommentLike(Base): + """资讯评论点赞(一人一赞,可取消)。""" + + __tablename__ = "content_comment_likes" + + id: Mapped[str] = mapped_column(String, primary_key=True) + comment_id: Mapped[str] = mapped_column(String, default="", index=True) + user_id: Mapped[str] = mapped_column(String, default="", index=True) created_at: Mapped[str] = mapped_column(String, default="") diff --git a/app/infrastructure/repositories.py b/app/infrastructure/repositories.py index 6a1c1f2..0bcc9c4 100644 --- a/app/infrastructure/repositories.py +++ b/app/infrastructure/repositories.py @@ -21,6 +21,8 @@ from .models import ( Bid, ContentItem, ContentComment, + ContentLike, + ContentCommentLike, Contract, Dispute, Escrow, @@ -587,7 +589,11 @@ class UserRepository: async def to_profile(self, user: dict) -> dict: """把用户记录裁剪成对外暴露的资料结构(不含任何密码字段)。""" + from .oss import resolve_url profile = {field: user.get(field, "") for field in PROFILE_FIELDS} + # 头像等资源字段统一补全为绝对直链(各端口 /auth/me 直接可显示) + profile["avatar"] = resolve_url(user.get("avatar", "")) + profile["company_avatar"] = resolve_url(user.get("company_avatar", "")) # 小程序契约字段(C 端 /me 与登录响应共用):显示名 / 手机 / 绑定 / OPC 现状标签 / 关注主题 profile["name"] = user.get("nickname") or user.get("username", "") profile["phone"] = user.get("phone", "") @@ -1329,7 +1335,8 @@ class ContentRepository: # 富字段白名单(images 为 list,落库转 json) _FIELDS = {"type", "title", "summary", "body", "status", "publisher_id", "cover", "cover_small", "images", "video", "video_cover", "link", - "publisher_name", "publisher_avatar", "card_mode", "is_public", "source", "tenant_id", "priority"} + "publisher_name", "publisher_avatar", "card_mode", "is_public", "source", "tenant_id", "priority", + "published_at"} def _to_dict(self, c: ContentItem) -> dict: try: @@ -1341,13 +1348,17 @@ class ContentRepository: "body": c.body, "publisher_id": c.publisher_id, "status": c.status, "cover": c.cover, "cover_small": c.cover_small, "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, "card_mode": c.card_mode, "is_public": bool(c.is_public), + "read_count": c.read_count, "like_count": c.like_count or 0, "share_count": c.share_count or 0, + "card_mode": c.card_mode, "is_public": bool(c.is_public), "source": c.source, "tenant_id": c.tenant_id, "priority": c.priority or 0, + "published_at": c.published_at or "", "created_at": c.created_at, "updated_at": c.updated_at, } async def list(self, ctype: str | None = None, status: str | None = None, - public_only: bool = False, tenant_id: str | None = None) -> list[dict]: + public_only: bool = False, tenant_id: str | None = None, + scheduled_ready: bool = False) -> list[dict]: + """scheduled_ready=True 时只返回已到发布时间的内容(C 端展示用,定时发布未到点不出现)。""" q = select(ContentItem).order_by(ContentItem.priority.desc(), ContentItem.created_at.desc()) if ctype: q = q.where(ContentItem.type == ctype) @@ -1357,7 +1368,11 @@ class ContentRepository: 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)] + items = [self._to_dict(c) for c in await self.session.scalars(q)] + if scheduled_ready: + now = utcnow_iso() + items = [c for c in items if (c.get("published_at") or "") <= now] + return items async def create(self, fields: dict) -> dict: now = utcnow_iso() @@ -1377,11 +1392,60 @@ class ContentRepository: source=fields.get("source", "operator"), tenant_id=fields.get("tenant_id", ""), priority=int(fields.get("priority", 0) or 0), + published_at=fields.get("published_at", ""), status=fields.get("status", "draft"), created_at=now, updated_at=now) self.session.add(c) await self.session.commit() return self._to_dict(c) + async def update(self, content_id: str, fields: dict) -> dict | None: + """增量更新(含 published_at / publisher / source)。""" + c = await self.session.get(ContentItem, content_id) + if c is None: + return None + images = fields.pop("images", None) + if images is not None: + c.images_json = json.dumps(images, ensure_ascii=False) if isinstance(images, list) else "[]" + for k, v in fields.items(): + if k in self._FIELDS: + setattr(c, k, v) + c.updated_at = utcnow_iso() + await self.session.commit() + return self._to_dict(c) + + async def toggle_like(self, content_id: str, user_id: str) -> dict: + """点赞/取消点赞(幂等 toggle)。返回 {liked, like_count}。""" + row = await self.session.scalar( + select(ContentLike).where(ContentLike.content_id == content_id, ContentLike.user_id == user_id), + ) + liked = row is None + if row is None: + self.session.add(ContentLike(id=new_id("clike"), content_id=content_id, user_id=user_id, created_at=utcnow_iso())) + else: + await self.session.delete(row) + c = await self.session.get(ContentItem, content_id) + if c is not None: + c.like_count = max(0, (c.like_count or 0) + (1 if liked else -1)) + await self.session.commit() + return {"liked": liked, "like_count": (c.like_count if c is not None else 0) or 0} + + async def is_liked(self, content_id: str, user_id: str) -> bool: + if not user_id: + return False + row = await self.session.scalar( + select(ContentLike).where(ContentLike.content_id == content_id, ContentLike.user_id == user_id), + ) + return row is not None + + async def add_share(self, content_id: str) -> dict: + """分享计数 +1(可重复分享,每次 +1)。返回 {share_count}。""" + c = await self.session.get(ContentItem, content_id) + if c is None: + return {"share_count": 0} + c.share_count = (c.share_count or 0) + 1 + await self.session.commit() + return {"share_count": c.share_count} + async def set_status(self, content_id: str, status: str) -> dict | None: c = await self.session.get(ContentItem, content_id) if c is None: @@ -1439,28 +1503,82 @@ class ContentRepository: c.read_count = (c.read_count or 0) + 1 await self.session.commit() - async def content_comments(self, content_id: str) -> list[dict]: + async def content_comments(self, content_id: str, nested: bool = False) -> list[dict]: + """留言列表;nested=True 时按微信式组装:顶层带 replies(按时间正序)与 reply_count。""" rows = (await self.session.scalars( select(ContentComment).where(ContentComment.content_id == content_id) - .order_by(ContentComment.created_at.desc()) + .order_by(ContentComment.created_at.asc()) )).all() - return [{ - "id": r.id, "content_id": r.content_id, "user_id": r.user_id, - "username": r.username, "nickname": r.nickname, "avatar": r.avatar, - "content": r.content, "created_at": r.created_at, - } for r in rows] - async def add_content_comment(self, content_id: str, user: dict, text: str) -> dict | None: + def _dump(r: ContentComment) -> dict: + return { + "id": r.id, "content_id": r.content_id, "user_id": r.user_id, + "username": r.username, "nickname": r.nickname, "avatar": r.avatar, + "content": r.content, "reply_to": r.reply_to, + "like_count": r.like_count or 0, "created_at": r.created_at, + } + + if not nested: + return [_dump(r) for r in reversed(rows)] # 兼容旧调用:最新在前 + + by_id = {r.id: _dump(r) for r in rows} + tops: list[dict] = [] + for r in rows: + item = by_id[r.id] + if r.reply_to and r.reply_to in by_id: + # 回复挂到顶层评论(二级嵌套,不递归更深) + root = r.reply_to + while by_id[root]["reply_to"] and by_id[root]["reply_to"] in by_id: + root = by_id[root]["reply_to"] + by_id[root].setdefault("replies", []).append(item) + else: + item.setdefault("replies", []) + tops.append(item) + for t in tops: + t["reply_count"] = len(t["replies"]) + t["replies"] = t["replies"][-10:] # 默认最多展示 10 条,前端可展开提示 + return list(reversed(tops)) # 最新在前 + + async def add_content_comment(self, content_id: str, user: dict, text: str, + reply_to: str = "") -> dict | None: row = ContentComment(id=new_id("cmt"), content_id=content_id, user_id=user.get("id", ""), username=user.get("username", ""), nickname=user.get("nickname") or user.get("username", ""), avatar=user.get("avatar", ""), content=text, - created_at=utcnow_iso()) + reply_to=reply_to or "", created_at=utcnow_iso()) self.session.add(row) await self.session.commit() return {"id": row.id, "content_id": row.content_id, "user_id": row.user_id, "username": row.username, "nickname": row.nickname, "avatar": row.avatar, - "content": row.content, "created_at": row.created_at} + "content": row.content, "reply_to": row.reply_to, + "like_count": 0, "created_at": row.created_at} + + async def toggle_comment_like(self, comment_id: str, user_id: str) -> dict: + """评论点赞/取消(一人一赞)。返回 {liked, like_count}。""" + row = await self.session.scalar( + select(ContentCommentLike).where(ContentCommentLike.comment_id == comment_id, + ContentCommentLike.user_id == user_id), + ) + liked = row is None + if row is None: + self.session.add(ContentCommentLike(id=new_id("clike"), comment_id=comment_id, + user_id=user_id, created_at=utcnow_iso())) + else: + await self.session.delete(row) + c = await self.session.get(ContentComment, comment_id) + if c is not None: + c.like_count = max(0, (c.like_count or 0) + (1 if liked else -1)) + await self.session.commit() + return {"liked": liked, "like_count": (c.like_count if c is not None else 0) or 0} + + async def is_comment_liked(self, comment_id: str, user_id: str) -> bool: + if not user_id: + return False + row = await self.session.scalar( + select(ContentCommentLike).where(ContentCommentLike.comment_id == comment_id, + ContentCommentLike.user_id == user_id), + ) + return row is not None # --------------------------------------------------------------------------- diff --git a/app/park/routers.py b/app/park/routers.py index 76b42ed..fee6dae 100644 --- a/app/park/routers.py +++ b/app/park/routers.py @@ -287,14 +287,21 @@ async def health(): @router.post("/api/upload") async def upload_media(file: UploadFile = File(...), tenant_id: str | None = None): - """上传媒体(图片/视频)到园区媒体目录,返回 {path, url}。""" - from .config import settings - fname = Path(file.filename or "upload.bin").name - dest = settings.MEDIA_DIR / fname - dest.parent.mkdir(parents=True, exist_ok=True) + """上传媒体(图片/视频):统一走 OSS(infrastructure.oss),返回 {path, url}。""" + from ...infrastructure.oss import oss as _oss + from ...services.media_upload import abs_url, _MIME, build_key + + name = Path(file.filename or "upload.bin").name + ext = ("." + name.split(".")[-1].lower()) if "." in name else "" + if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".mp4", ".mov", ".webm", ".m4a", ".mp3"): + raise HTTPException(400, f"不支持的文件类型({ext or '无扩展名'})") data = await file.read() - dest.write_bytes(data) - return {"ok": True, "path": f"/park/file/{fname}", "url": f"/park/file/{fname}"} + if len(data) > 200 * 1024 * 1024: + raise HTTPException(413, "文件超过 200MB 限制") + key = build_key("park", ext) + await _oss.upload(key, data, content_type=_MIME.get(ext, "application/octet-stream")) + # url 直接返回 CDN/OSS 直链(不经服务端代理);path 保留 /oss/ 作稳定引用 + return {"ok": True, "path": _oss.object_url(key), "url": abs_url(_oss.direct_url(key))} @router.get("/api/state") @@ -1340,6 +1347,8 @@ class ParkContentBody(BaseModel): video_cover: str = "" link: str = "" card_mode: str = "small" # 园区默认小图;运营端可改大/小图 + published_at: str = "" # 发布时间(可未来=定时发布;不得早于 3 天前) + source: str = "" # 来源(园区端只能填来源,发布人固定=园区名) @router.post("/api/content", summary="园区端发布资讯(发布人=园区名,默认不公开)") @@ -1350,12 +1359,25 @@ async def carrier_publish_content( user: dict = Depends(_carrier_user), ): t = await _my_park(user) + # 发布时间:园区端不可回填超过 3 天前的时间;可填未来时间做定时发布 + published_at = (body.published_at or "").strip() + if published_at: + from datetime import datetime, timedelta, timezone + try: + pt = datetime.fromisoformat(published_at.replace("Z", "+00:00")) + except ValueError: + raise HTTPException(400, "发布时间格式不合法(ISO 时间)") + floor = datetime.now(timezone.utc) - timedelta(days=3) + if pt < floor: + raise HTTPException(400, "发布时间不能早于 3 天前") + published_at = pt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") 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"], + "source": (body.source or "").strip() or "carrier", "tenant_id": t["id"], "card_mode": body.card_mode or "small", + "published_at": published_at, "is_public": False, # 默认不公开:审核通过后由运营端切公开(进 C 端) "status": "pending", # 待审核:需运营端在资讯审核通过后才上线 }) @@ -1382,7 +1404,7 @@ async def park_content_feed( tid = await _resolve_tenant(authorization, tenant_id) db = Database() try: - items = await db.content.list(status="published", tenant_id=tid) + items = await db.content.list(status="published", tenant_id=tid, scheduled_ready=True) finally: await db.close() return {"items": items} diff --git a/app/training/main.py b/app/training/main.py index a127252..86e59a8 100644 --- a/app/training/main.py +++ b/app/training/main.py @@ -8,8 +8,9 @@ import json import os import secrets import time +from pathlib import Path from typing import Optional -from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File +from fastapi import FastAPI, Header, Request, HTTPException, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -38,11 +39,47 @@ app = FastAPI(title="云超服 OPC 培训站后端", version="0.1", lifespan=lif ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # server-core 根 -# ---- 上传目录 + 静态挂载(供活动封面图等) ---- +# ---- 上传目录 + 静态挂载(历史本地文件;新上传统一走 OSS,见 infrastructure.oss) ---- UPLOAD_DIR = os.path.join(ROOT, "serverdata", "uploads") os.makedirs(UPLOAD_DIR, exist_ok=True) app.mount("/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") +# ---- OSS 统一下载路由:/oss/ 307 跳预签名 URL(桶私有也可读);未配置 OSS 时回退本地文件 ---- +from fastapi.responses import FileResponse, RedirectResponse # noqa: E402 +from ..infrastructure.oss import oss as _oss, resolve_url as _resolve_url # noqa: E402 + + +@app.get("/oss/{key:path}") +async def oss_download(key: str): + try: + key = _oss.clean_key(key) + except ValueError: + raise HTTPException(400, "非法对象路径") + if not _oss.enabled: + local = Path(UPLOAD_DIR) / key + if not local.is_file(): + raise HTTPException(404, "文件不存在") + return FileResponse(local) + return RedirectResponse(await _oss.download_url(key), status_code=307) + + +@app.post("/api/oss/presign") +async def oss_presign(request: Request, authorization: str = Header(default="")): + """生成直传链接:body {filename, contentType?, dir?} → {key, uploadUrl, objectUrl}(前端 PUT 直传 OSS)。""" + require_auth(authorization) + body = await request.json() + name = os.path.basename(str(body.get("filename") or "file")) + ext = os.path.splitext(name)[1].lower() + if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf", ".doc", ".docx", + ".mp4", ".mov", ".m4a", ".mp3"): + raise HTTPException(400, f"不支持的文件类型({ext or '无扩展名'})") + subdir = str(body.get("dir") or "misc").strip("/ ").replace("..", "") + from ..services.media_upload import build_key + key = build_key(subdir, ext) + upload_url = await _oss.presigned_put(key, content_type=body.get("contentType") or "application/octet-stream") + # objectUrl 为 CDN/OSS 直链,前端直传完成后直接使用,不经服务端代理 + return {"ok": True, "key": key, "uploadUrl": upload_url, "objectUrl": _oss.direct_url(key)} + # ---- 微信小程序配置(真实登录) ---- # 微信 AppID / AppSecret 由 server-core 环境变量或 server-core/.env 提供。 def _load_dotenv(): @@ -158,7 +195,7 @@ def _user_payload(u): return { "username": u.get("username", ""), "name": u.get("nickname") or u.get("username", ""), - "avatar": abs_url(u.get("avatar", "")), + "avatar": _resolve_url(u.get("avatar", "")), "phone": u.get("phone", "") or "", "phoneBound": bool(u.get("phone", "")), "status": u.get("opc_status", "") or "", @@ -385,7 +422,8 @@ async def update_profile(req: Request, authorization: str = Header(default="")): if b.get("name") is not None and str(b["name"]).strip(): patch["nickname"] = str(b["name"]).strip() if b.get("avatar") is not None: - patch["avatar"] = str(b["avatar"]).strip() + from ..infrastructure.oss import to_object_path + patch["avatar"] = to_object_path(str(b["avatar"]).strip()) if b.get("status") is not None: patch["opc_status"] = str(b["status"]).strip() if b.get("source") is not None: @@ -554,6 +592,9 @@ async def update_event(eid: str, req: Request, authorization: str = Header(defau src = k if k in b else (alias if alias else None) if src and b.get(src) is not None: patch[k] = b[src] + if patch.get("image"): + from ..infrastructure.oss import to_object_path + patch["image"] = to_object_path(str(patch["image"])) if b.get("type") in ("salon", "free"): patch["type"] = b["type"] if b.get("mode") in ("online", "offline"): @@ -941,23 +982,14 @@ def _parse_ms(iso): # ================= 图像上传 ================= @app.post("/api/upload") -async def upload(request: Request, file: UploadFile = File(...), authorization: str = Header(default="")): +async def upload(request: Request, file: UploadFile = File(...), dir: str = Form("misc"), authorization: str = Header(default="")): # 管理端上传需登录(pine 后台);公开端也可保留(依据调用方),这里允许管理端 token require_auth(authorization) - if not file.filename: - raise HTTPException(400, "未选择文件") - ext = os.path.splitext(file.filename or "")[1].lower() - if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif", ".pdf", ".doc", ".docx"): - raise HTTPException(400, "仅支持 图片 / pdf / word 文件") - fname = f"up_{int(time.time())}_{os.urandom(4).hex()}{ext}" - dest = os.path.join(UPLOAD_DIR, fname) - content = await file.read() - if len(content) > 20 * 1024 * 1024: - raise HTTPException(400, "文件过大(>20MB)") - with open(dest, "wb") as f: - f.write(content) - url = f"/uploads/{fname}" - return {"ok": True, "url": abs_url(url)} + # OSS 优先(已配置 OSS_* 时走桶),未配置则 save_media 内部落本地 uploads;统一返回 CDN/OSS 直链 + # dir 为 OSS 业务目录(avatar / park-admission / news / event / misc),按《OSS 路径规范》分目录 + from ..services.media_upload import save_media + url = await save_media(file, dir=dir) + return {"ok": True, "url": url} # ================= 园区入驻申请 ================= @@ -1010,6 +1042,9 @@ async def park_admission_submit(request: Request, authorization: str = Header(de raise HTTPException(404, "园区不存在") form = body.get("form") if isinstance(body.get("form"), dict) else {} docs = body.get("docs") if isinstance(body.get("docs"), dict) else {} + if docs: + from ..infrastructure.oss import to_object_path + docs = {k: to_object_path(v) for k, v in docs.items()} u = _current_user(auth) now = now_iso() rec = {