feat(api): GET /api/moments 朋友圈列表(desc分页+账号头部)

对齐云客员工聊天窗口朋友圈tab:帖子 create_time desc 分页 +
wechat_account 头部(昵称/头像/别名);员工不存在不404(header全None
前端兜底)。头像提取抽共享 helper account_avatar 放 organization.py,
accounts 列表与 moments 头部复用。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-25 15:57:53 +08:00
parent d3d7146887
commit 2837500bc0
5 changed files with 230 additions and 1 deletions
+3
View File
@@ -11,6 +11,7 @@ Includes:
* ``GET /api/departments`` / ``/api/staff`` / ``/api/accounts`` * ``GET /api/departments`` / ``/api/staff`` / ``/api/accounts``
(see :mod:`app.api.routers.organization`). (see :mod:`app.api.routers.organization`).
* ``GET /api/messages`` (see :mod:`app.api.routers.messages`). * ``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`` * ``GET /api/groups`` / ``/api/groups/{room_id}/members``
(see :mod:`app.api.routers.groups`). (see :mod:`app.api.routers.groups`).
* ``GET /api/calls`` (see :mod:`app.api.routers.calls`). * ``GET /api/calls`` (see :mod:`app.api.routers.calls`).
@@ -46,6 +47,7 @@ from app.api.routers import (
groups, groups,
media, media,
messages, messages,
moments,
organization, organization,
search, search,
sessions, sessions,
@@ -81,6 +83,7 @@ app.include_router(customer.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(organization.router) app.include_router(organization.router)
app.include_router(messages.router) app.include_router(messages.router)
app.include_router(moments.router)
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(calls.router) app.include_router(calls.router)
app.include_router(sms.router) app.include_router(sms.router)
+85
View File
@@ -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],
)
+11 -1
View File
@@ -35,6 +35,16 @@ PageParam = Annotated[int, Query(ge=1)]
PageSizeParam = Annotated[int, Query(ge=1, le=100)] 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") @router.get("/departments")
async def list_departments(session: SessionDep) -> list[DepartmentOut]: async def list_departments(session: SessionDep) -> list[DepartmentOut]:
"""Return every department ordered by ``path`` (nulls last). """Return every department ordered by ``path`` (nulls last).
@@ -177,7 +187,7 @@ async def list_accounts(
is_on_job=row.is_on_job, is_on_job=row.is_on_job,
# 头像从原始记录提取(对齐云客员工聊天窗口左侧头像,2026-08-25): # 头像从原始记录提取(对齐云客员工聊天窗口左侧头像,2026-08-25):
# WeChat headImageUrl;WA 原始记录无该键 -> None # WeChat headImageUrl;WA 原始记录无该键 -> None
avatar=(row.raw_json or {}).get("headImageUrl"), avatar=account_avatar(row.raw_json),
raw_json=row.raw_json, raw_json=row.raw_json,
) )
for row in rows for row in rows
+52
View File
@@ -422,3 +422,55 @@ class SessionListOut(BaseModel):
channel: str channel: str
today_start: int today_start: int
sessions: list[SessionOut] 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]
+79
View File
@@ -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"