diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 815ae8a..a9db433 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -11,6 +11,7 @@ Includes: * ``GET /api/departments`` / ``/api/staff`` / ``/api/accounts`` (see :mod:`app.api.routers.organization`). * ``GET /api/messages`` (see :mod:`app.api.routers.messages`). +* ``GET /api/moments`` (see :mod:`app.api.routers.moments`). * ``GET /api/groups`` / ``/api/groups/{room_id}/members`` (see :mod:`app.api.routers.groups`). * ``GET /api/calls`` (see :mod:`app.api.routers.calls`). @@ -46,6 +47,7 @@ from app.api.routers import ( groups, media, messages, + moments, organization, search, sessions, @@ -81,6 +83,7 @@ app.include_router(customer.router) app.include_router(media.router) app.include_router(organization.router) app.include_router(messages.router) +app.include_router(moments.router) app.include_router(groups.router) app.include_router(calls.router) app.include_router(sms.router) diff --git a/yunke-server/app/api/routers/moments.py b/yunke-server/app/api/routers/moments.py new file mode 100644 index 0000000..425f7b7 --- /dev/null +++ b/yunke-server/app/api/routers/moments.py @@ -0,0 +1,85 @@ +"""GET /api/moments — 员工朋友圈帖子列表(create_time desc)+账号头部。全本地。 + +对齐云客员工聊天窗口「朋友圈」tab:顶部账号资料卡(昵称/头像/别名)+ +帖子流(新→旧)。读镜像 ``moments_post`` 表(WeChat-only,所属员工由 +``wechat_id`` 标识),头部信息查 ``wechat_account``。 + +员工微信号不存在时不 404——返回 ``header`` 全 ``None`` + 空 ``items``, +前端资料卡自行兜底(云客侧同一员工必定有账号行,本地镜像可能滞后)。 +""" +from __future__ import annotations + +from typing import Annotated + +import sqlalchemy as sa +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import get_session +from app.api.routers.organization import account_avatar +from app.api.schemas import MomentsHeaderOut, MomentsListOut, MomentsPostOut +from app.db.models import MomentsPost, WechatAccount + +router = APIRouter(prefix="/api", tags=["moments"]) + +SessionDep = Annotated[AsyncSession, Depends(get_session)] +PageParam = Annotated[int, Query(ge=1)] +PageSizeParam = Annotated[int, Query(ge=1, le=100)] + + +@router.get("/moments") +async def list_moments( + session: SessionDep, + wechat_id: Annotated[str, Query(min_length=1)], + page: PageParam = 1, + page_size: PageSizeParam = 20, +) -> MomentsListOut: + """员工朋友圈帖子列表(``create_time`` desc,分页)+ 账号头部。 + + 排序 ``nullslast(create_time desc)`` + ``id`` 稳定断尾(MySQL 下 compat + 补丁剥掉 NULLS LAST,但 DESC 语义本就把 NULL 排后,等价——项目已有验证)。 + """ + account = ( + await session.execute( + sa.select( + WechatAccount.nick_name, + WechatAccount.alias, + WechatAccount.raw_json, + ).where(WechatAccount.wx_id == wechat_id) + ) + ).first() + header = MomentsHeaderOut( + nickname=account.nick_name if account else None, + avatar=account_avatar(account.raw_json) if account else None, + alias=account.alias if account else None, + ) + + total = ( + await session.execute( + sa.select(sa.func.count()) + .select_from(MomentsPost) + .where(MomentsPost.wechat_id == wechat_id) + ) + ).scalar_one() + + rows = ( + await session.execute( + sa.select(MomentsPost) + .where(MomentsPost.wechat_id == wechat_id) + .order_by( + sa.nullslast(MomentsPost.create_time.desc()), + MomentsPost.id.asc(), + ) + .offset((page - 1) * page_size) + .limit(page_size) + ) + ).scalars().all() + + return MomentsListOut( + wechat_id=wechat_id, + header=header, + total=total, + page=page, + page_size=page_size, + items=[MomentsPostOut.model_validate(r) for r in rows], + ) diff --git a/yunke-server/app/api/routers/organization.py b/yunke-server/app/api/routers/organization.py index 635f14a..4e461eb 100644 --- a/yunke-server/app/api/routers/organization.py +++ b/yunke-server/app/api/routers/organization.py @@ -35,6 +35,16 @@ PageParam = Annotated[int, Query(ge=1)] PageSizeParam = Annotated[int, Query(ge=1, le=100)] +def account_avatar(raw_json: dict | None) -> str | None: + """从账号原始记录提取头像 URL(共享 helper,2026-08-25)。 + + WeChat ``wxUser/list`` 记录携带 ``headImageUrl``;WA ``businessSalesList`` + 记录无该键 → ``None``。accounts 列表与 moments 头部等多处复用, + 避免同键提取散落各处。 + """ + return (raw_json or {}).get("headImageUrl") + + @router.get("/departments") async def list_departments(session: SessionDep) -> list[DepartmentOut]: """Return every department ordered by ``path`` (nulls last). @@ -177,7 +187,7 @@ async def list_accounts( is_on_job=row.is_on_job, # 头像从原始记录提取(对齐云客员工聊天窗口左侧头像,2026-08-25): # WeChat headImageUrl;WA 原始记录无该键 -> None - avatar=(row.raw_json or {}).get("headImageUrl"), + avatar=account_avatar(row.raw_json), raw_json=row.raw_json, ) for row in rows diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index d7661e5..537eace 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -422,3 +422,55 @@ class SessionListOut(BaseModel): channel: str today_start: int sessions: list[SessionOut] + + +# --------------------------------------------------------------------------- +# Moments (员工朋友圈) +# --------------------------------------------------------------------------- + + +class MomentsHeaderOut(BaseModel): + """朋友圈头部 — 员工账号昵称/头像/别名(wechat_account 表)。 + + 员工微信号不存在时三个字段全 ``None``(不 404,前端资料卡兜底)。 + ``avatar`` 从 ``raw_json.headImageUrl`` 提取(共享 helper + :func:`app.api.routers.organization.account_avatar`)。 + """ + + nickname: str | None = None + avatar: str | None = None + alias: str | None = None + + +class MomentsPostOut(BaseModel): + """朋友圈帖子行(moments_post 表,``from_attributes`` 直读)。 + + 结构化列驱动帖子卡片(正文/图片/点赞/评论);点赞/评论为上游 JSON + 数组原样透传,由前端解析渲染。 + """ + + model_config = ConfigDict(from_attributes=True) + + id: str + wechat_id: str + sns_id: str | None = None + author_name: str | None = None + author_wxid: str | None = None + author_avatar: str | None = None + content: str | None = None + type_raw: str | None = None + create_time: int | None = None # ms epoch + praise_json: list[dict[str, Any]] | None = None + comment_json: list[dict[str, Any]] | None = None + image_urls: list[str] | None = None + + +class MomentsListOut(BaseModel): + """GET /api/moments 响应:账号头部 + 分页帖子(create_time desc)。""" + + wechat_id: str + header: MomentsHeaderOut + total: int + page: int + page_size: int + items: list[MomentsPostOut] diff --git a/yunke-server/tests/test_api_moments.py b/yunke-server/tests/test_api_moments.py new file mode 100644 index 0000000..0e238a6 --- /dev/null +++ b/yunke-server/tests/test_api_moments.py @@ -0,0 +1,79 @@ +"""Tests for GET /api/moments — 员工朋友圈帖子列表(create_time desc)+账号头部。""" +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import MomentsPost, WechatAccount + +STAFF = "wxid_o" + + +async def test_moments_list_desc_with_header(make_client): + """按 create_time desc 只返回该员工帖子;header 带账号昵称/头像/别名。""" + + async def seed(s: AsyncSession): + s.add(WechatAccount(wx_id=STAFF, nick_name="员工昵称", + raw_json={"headImageUrl": "https://wx.qlogo.cn/a/0"})) + s.add(MomentsPost(id="p1", wechat_id=STAFF, content="新帖", create_time=200)) + s.add(MomentsPost(id="p2", wechat_id=STAFF, content="旧帖", create_time=100)) + s.add(MomentsPost(id="p3", wechat_id="wxid_other", content="别人", create_time=300)) + await s.commit() + + client = await make_client(seed) + r = await client.get("/api/moments", params={"wechat_id": STAFF, "page": 1, "page_size": 10}) + assert r.status_code == 200 + body = r.json() + assert body["header"] == {"nickname": "员工昵称", "avatar": "https://wx.qlogo.cn/a/0", "alias": None} + assert [p["id"] for p in body["items"]] == ["p1", "p2"] # create_time desc + assert body["total"] == 2 + + +async def test_moments_empty_staff(make_client): + """无帖子:header 仍返回账号信息,items 空。""" + + async def seed(s: AsyncSession): + s.add(WechatAccount(wx_id=STAFF, nick_name="员工昵称", + raw_json={"headImageUrl": "https://wx.qlogo.cn/a/0"})) + await s.commit() + + client = await make_client(seed) + r = await client.get("/api/moments", params={"wechat_id": STAFF}) + assert r.status_code == 200 + body = r.json() + assert body["items"] == [] and body["total"] == 0 + assert body["header"]["nickname"] == "员工昵称" + + +async def test_moments_unknown_staff(make_client): + """员工不存在:header 全 None,items 空——不 404(前端资料卡兜底)。""" + + async def seed(s: AsyncSession): + await s.commit() + + client = await make_client(seed) + r = await client.get("/api/moments", params={"wechat_id": "wxid_ghost"}) + assert r.status_code == 200 + body = r.json() + assert body["header"] == {"nickname": None, "avatar": None, "alias": None} + assert body["items"] == [] and body["total"] == 0 + + +async def test_moments_pagination(make_client): + """3 帖 page_size=2:第 1 页 2 条(p1,p2),第 2 页 1 条(p3),total 恒 3。""" + + async def seed(s: AsyncSession): + s.add(WechatAccount(wx_id=STAFF, nick_name="员工昵称", alias="al_o")) + s.add_all([ + MomentsPost(id=f"p{i}", wechat_id=STAFF, create_time=300 - i * 100) + for i in range(1, 4) + ]) + await s.commit() + + client = await make_client(seed) + b1 = (await client.get("/api/moments", params={"wechat_id": STAFF, "page": 1, "page_size": 2})).json() + b2 = (await client.get("/api/moments", params={"wechat_id": STAFF, "page": 2, "page_size": 2})).json() + assert [p["id"] for p in b1["items"]] == ["p1", "p2"] + assert [p["id"] for p in b2["items"]] == ["p3"] + assert b1["total"] == b2["total"] == 3 + # alias 非空时映射到 header(列直读) + assert b1["header"]["alias"] == "al_o"