From a290e39523f90e43645b82558bb60a24e678cabf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 22 Aug 2026 14:39:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(customers):=20=E5=8F=91=E9=80=81/=E6=8E=A5?= =?UTF-8?q?=E6=94=B6=E5=88=97=E6=94=B9=E4=BB=8A=E6=97=A5=E5=8F=A3=E5=BE=84?= =?UTF-8?q?(=E5=AF=B9=E9=BD=90=E4=BA=91=E5=AE=A2;=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=A1=A8Asia/Shanghai=E4=BB=8A=E6=97=A5?= =?UTF-8?q?=E8=81=9A=E5=90=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CustomerOut 加 send_today/receive_today(路由赋值,非ORM列) - /api/customers 每页一次 GROUP BY(owner IN 走复合索引前缀) - 前端列优先今日值,无消息数据回退原raw字段;列名对齐云客「发送条数/接收条数」 - TDD:今日3发2收+昨日不计入+无消息客户0 Co-Authored-By: Claude --- yunke-server/app/api/routers/customer.py | 40 +++++++++++++++++++++++- yunke-server/app/api/schemas.py | 4 +++ yunke-server/tests/test_api_customers.py | 38 ++++++++++++++++++++++ yunkes/src/api/types.ts | 3 ++ yunkes/src/views/CustomerList.vue | 8 ++--- 5 files changed, 88 insertions(+), 5 deletions(-) diff --git a/yunke-server/app/api/routers/customer.py b/yunke-server/app/api/routers/customer.py index c3911ca..a3187fe 100644 --- a/yunke-server/app/api/routers/customer.py +++ b/yunke-server/app/api/routers/customer.py @@ -112,5 +112,43 @@ async def list_customers( ) rows = (await session.execute(list_stmt)).scalars().all() - items = [CustomerOut.model_validate(row) for row in rows] + + # 今日发送/接收条数(对齐云客口径):本地消息表按 Asia/Shanghai 今日聚合, + # 一次 GROUP BY 覆盖本页(owner IN 走 ix_message_channel_owner_talker_time 前缀) + import datetime as _dt + + from app.db.models import Message + + sh_now = _dt.datetime.now(_dt.UTC) + _dt.timedelta(hours=8) + today_start_ms = int( + (sh_now.replace(hour=0, minute=0, second=0, microsecond=0) - _dt.timedelta(hours=8)).timestamp() * 1000 + ) + owners = sorted({r.owner_account_id for r in rows if r.owner_account_id}) + today_counts: dict[tuple[str, str], tuple[int, int]] = {} + if owners: + stat_rows = ( + await session.execute( + sa.select( + Message.owner_account_id, + Message.talker_id, + sa.func.sum(sa.case((Message.is_send.is_(True), 1), else_=0)), + sa.func.sum(sa.case((Message.is_send.is_(False), 1), else_=0)), + ) + .where( + Message.channel == (channel or "wechat"), + Message.owner_account_id.in_(owners), + Message.timestamp >= today_start_ms, + ) + .group_by(Message.owner_account_id, Message.talker_id) + ) + ).all() + today_counts = {(o, t): (int(s or 0), int(rc or 0)) for o, t, s, rc in stat_rows} + + items = [] + for row in rows: + item = CustomerOut.model_validate(row) + s, rc = today_counts.get((row.owner_account_id or "", row.friend_id or ""), (0, 0)) + item.send_today = s + item.receive_today = rc + items.append(item) return Page(items=items, total=total, page=page, page_size=page_size) diff --git a/yunke-server/app/api/schemas.py b/yunke-server/app/api/schemas.py index 739884a..f307351 100644 --- a/yunke-server/app/api/schemas.py +++ b/yunke-server/app/api/schemas.py @@ -25,6 +25,10 @@ class CustomerOut(BaseModel): channel: str owner_account_id: str | None = None friend_nickname: str | None = None + # 今日发送/接收条数(本地消息表按 Asia/Shanghai 今日聚合,对齐云客口径; + # 非 ORM 列,由路由赋值,2026-08-22) + send_today: int | None = None + receive_today: int | None = None friend_remark: str | None = None real_name: str | None = None phone: str | None = None diff --git a/yunke-server/tests/test_api_customers.py b/yunke-server/tests/test_api_customers.py index a15ad58..d50ad07 100644 --- a/yunke-server/tests/test_api_customers.py +++ b/yunke-server/tests/test_api_customers.py @@ -35,3 +35,41 @@ async def test_declared_total_sums_contacts_num(make_client): assert body["wechat"]["account_count"] == 2 # 只计有值的员工 assert body["whatsapp"]["declared_total"] == 15 assert body["whatsapp"]["account_count"] == 2 + + +# --------------------------------------------------------------------------- +# send_today / receive_today — 今日发送/接收条数(对齐云客口径,2026-08-22) +# --------------------------------------------------------------------------- + +async def _seed_today_counts(session): + import datetime as dt + + from app.db.models import Customer as C, Message as M + + now_ms = int(dt.datetime.now(dt.UTC).timestamp() * 1000) + yesterday_ms = now_ms - 86_400_000 + session.add_all([ + C(channel="wechat", owner_account_id="o1", friend_id="f1"), + C(channel="wechat", owner_account_id="o1", friend_id="f2"), + ]) + # f1: 今日 3发2收 + 昨日 5发(不应计入) + for i in range(3): + session.add(M(msg_id=f"t1s{i}", channel="wechat", owner_account_id="o1", talker_id="f1", is_send=True, timestamp=now_ms - i * 1000)) + for i in range(2): + session.add(M(msg_id=f"t1r{i}", channel="wechat", owner_account_id="o1", talker_id="f1", is_send=False, timestamp=now_ms - i * 1000)) + for i in range(5): + session.add(M(msg_id=f"t0s{i}", channel="wechat", owner_account_id="o1", talker_id="f1", is_send=True, timestamp=yesterday_ms)) + # f2: 无消息 + await session.commit() + + +async def test_customers_today_send_receive(make_client): + """发送/接收今日列 = 本地消息表按 Asia/Shanghai 今日聚合。""" + client = await make_client(_seed_today_counts) + r = await client.get("/api/customers", params={"channel": "wechat"}) + assert r.status_code == 200 + by_fid = {it["friend_id"]: it for it in r.json()["items"]} + assert by_fid["f1"]["send_today"] == 3 + assert by_fid["f1"]["receive_today"] == 2 + assert by_fid["f2"]["send_today"] == 0 + assert by_fid["f2"]["receive_today"] == 0 diff --git a/yunkes/src/api/types.ts b/yunkes/src/api/types.ts index 6cb5168..aca7fd8 100644 --- a/yunkes/src/api/types.ts +++ b/yunkes/src/api/types.ts @@ -30,6 +30,9 @@ export interface Customer { head_image_url?: string | null /** ms-epoch of most recent message */ last_chat_time?: number | null + /** 今日发送/接收条数(本地消息聚合,对齐云客今日口径)。 */ + send_today?: number | null + receive_today?: number | null /** ms epoch of friend add time (from raw_json.addTime). */ add_time?: number | null message_count?: number | null diff --git a/yunkes/src/views/CustomerList.vue b/yunkes/src/views/CustomerList.vue index 102426a..4c6c465 100644 --- a/yunkes/src/views/CustomerList.vue +++ b/yunkes/src/views/CustomerList.vue @@ -450,11 +450,11 @@ onMounted(() => { - - + + - - + +