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(() => {
{{ contactLabelsText(row) }}
-
- {{ cellNum(row, 'sendNum') }}
+
+ {{ row.send_today ?? cellNum(row, 'sendNum') }}
-
- {{ cellNum(row, 'receiveNum') }}
+
+ {{ row.receive_today ?? cellNum(row, 'receiveNum') }}
{{ cellTimeText(row, 'lastChatTime') }}