feat(api): staff-chat-stats 聚合端点(微信员工 5 数字 + 客户明细,全本地 message)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -46,6 +46,7 @@ from app.api.routers import (
|
||||
organization,
|
||||
search,
|
||||
sms,
|
||||
staff_chat_stats,
|
||||
sync,
|
||||
)
|
||||
|
||||
@@ -65,6 +66,7 @@ app.include_router(messages.router)
|
||||
app.include_router(groups.router)
|
||||
app.include_router(calls.router)
|
||||
app.include_router(sms.router)
|
||||
app.include_router(staff_chat_stats.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(export.router)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
"""GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 表实时聚合)。
|
||||
|
||||
数据全本地:按 员工微信 wxid × ms-epoch 日期窗 聚合 1:1 私聊(room_id 过滤),
|
||||
不访问云客。5 汇总数从 customers 在 Python 侧派生(spec §5)。
|
||||
跨库日期提取(codebase 首例)镜像 app/storage/repos.py 的方言检测写法。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
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.schemas import StaffChatCustomerOut, StaffChatStatsOut, StaffChatStatsSummary
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["staff-chat-stats"])
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_MS_PER_DAY = 86_400_000
|
||||
|
||||
|
||||
def _private_window(msg, owner: str, start: int, end: int):
|
||||
"""1:1 私聊 + 窗口过滤条件(channel 固定 wechat),排除 timestamp IS NULL。"""
|
||||
return [
|
||||
msg.channel == "wechat",
|
||||
msg.owner_account_id == owner,
|
||||
sa.or_(msg.room_id.is_(None), msg.room_id == ""),
|
||||
msg.timestamp.is_not(None),
|
||||
msg.timestamp >= start,
|
||||
msg.timestamp <= end,
|
||||
]
|
||||
|
||||
|
||||
async def _aggregate(session: AsyncSession, owner: str, start: int, end: int):
|
||||
"""返回 [(talker_id, send, recv, interaction_days, last_time)] + last_is_send map。"""
|
||||
# 跨库日期提取:从 ms-epoch 取日期。镜像 repos.py 方言检测。
|
||||
dialect = session.bind.name # 镜像 app/storage/repos.py(.name 等价 .dialect.name)
|
||||
if dialect == "sqlite":
|
||||
day_expr = sa.func.date(Message.timestamp / 1000, "unixepoch")
|
||||
else: # mysql(生产)
|
||||
day_expr = sa.func.date(sa.func.from_unixtime(Message.timestamp / 1000))
|
||||
|
||||
base = _private_window(Message, owner, start, end)
|
||||
agg_stmt = (
|
||||
sa.select(
|
||||
Message.talker_id,
|
||||
sa.func.sum(sa.case((Message.is_send.is_(True), 1), else_=0)).label("send_count"),
|
||||
sa.func.sum(sa.case((Message.is_send.is_(False), 1), else_=0)).label("receive_count"),
|
||||
sa.func.count(sa.func.distinct(day_expr)).label("interaction_days"),
|
||||
sa.func.max(Message.timestamp).label("last_time"),
|
||||
)
|
||||
.where(*base)
|
||||
.group_by(Message.talker_id)
|
||||
)
|
||||
agg_rows = (await session.execute(agg_stmt)).all()
|
||||
|
||||
# 每客户最后一条消息方向:ROW_NUMBER 窗口取 rn=1(tie-break msg_id 保证确定性)
|
||||
inner = (
|
||||
sa.select(
|
||||
Message.talker_id,
|
||||
Message.is_send,
|
||||
sa.func.row_number()
|
||||
.over(
|
||||
partition_by=Message.talker_id,
|
||||
order_by=(Message.timestamp.desc(), Message.msg_id.desc()),
|
||||
)
|
||||
.label("rn"),
|
||||
)
|
||||
.where(*base)
|
||||
)
|
||||
sub = inner.subquery("last_msg")
|
||||
last_stmt = sa.select(sub.c.talker_id, sub.c.is_send).where(sub.c.rn == 1)
|
||||
last_map = {tid: is_send for tid, is_send in (await session.execute(last_stmt)).all()}
|
||||
|
||||
return agg_rows, last_map
|
||||
|
||||
|
||||
async def _customer_names(session: AsyncSession, owner: str, talker_ids: list[str]) -> dict[str, str]:
|
||||
if not talker_ids:
|
||||
return {}
|
||||
stmt = sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_remark,
|
||||
Customer.friend_nickname,
|
||||
Customer.real_name,
|
||||
).where(
|
||||
Customer.channel == "wechat",
|
||||
Customer.owner_account_id == owner,
|
||||
Customer.friend_id.in_(talker_ids),
|
||||
)
|
||||
names: dict[str, str] = {}
|
||||
for fid, remark, nick, real in (await session.execute(stmt)).all():
|
||||
names[fid] = remark or nick or real or fid
|
||||
return names
|
||||
|
||||
|
||||
@router.get("/staff-chat-stats")
|
||||
async def get_staff_chat_stats(
|
||||
session: SessionDep,
|
||||
owner_account_id: Annotated[str, Query(min_length=1)],
|
||||
start: Annotated[int | None, Query(ge=0)] = None,
|
||||
end: Annotated[int | None, Query(ge=0)] = None,
|
||||
) -> StaffChatStatsOut:
|
||||
"""微信员工沟通统计:5 数字 + 客户明细(全本地 message 聚合,1:1 私聊)。"""
|
||||
if end is None:
|
||||
end = int(time.time() * 1000)
|
||||
if start is None:
|
||||
start = end - 3 * _MS_PER_DAY # 默认近 3 天
|
||||
|
||||
agg_rows, last_map = await _aggregate(session, owner_account_id, start, end)
|
||||
talker_ids = [r.talker_id for r in agg_rows]
|
||||
names = await _customer_names(session, owner_account_id, talker_ids)
|
||||
|
||||
customers: list[StaffChatCustomerOut] = []
|
||||
for r in agg_rows:
|
||||
tid = r.talker_id
|
||||
last_is_send = last_map.get(tid) # bool | None(None=最后一条方向未知)
|
||||
customers.append(
|
||||
StaffChatCustomerOut(
|
||||
talker_id=tid,
|
||||
friend_name=names.get(tid, tid),
|
||||
send_count=int(r.send_count or 0),
|
||||
receive_count=int(r.receive_count or 0),
|
||||
interaction_days=int(r.interaction_days or 0),
|
||||
# True→1, False→0, None→None(方向未知不计入两率;summary 用 ==1/==0 自动排除 None)
|
||||
last_is_send=int(last_is_send) if last_is_send is not None else None,
|
||||
last_time=r.last_time,
|
||||
)
|
||||
)
|
||||
|
||||
summary = StaffChatStatsSummary(
|
||||
chat_count=len(customers),
|
||||
effective_count=sum(1 for c in customers if c.send_count >= 1 and c.receive_count >= 1),
|
||||
follow_count=sum(1 for c in customers if c.interaction_days >= 2),
|
||||
last_reply_count=sum(1 for c in customers if c.last_is_send == 1),
|
||||
no_reply_count=sum(1 for c in customers if c.last_is_send == 0),
|
||||
)
|
||||
return StaffChatStatsOut(
|
||||
owner_account_id=owner_account_id,
|
||||
start=start,
|
||||
end=end,
|
||||
summary=summary,
|
||||
customers=customers,
|
||||
)
|
||||
@@ -290,3 +290,38 @@ class SyncStatusOut(BaseModel):
|
||||
|
||||
states: list[SyncStateOut]
|
||||
recent_runs: list[SyncRunOut]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Staff chat stats (WeChat staff 5-number summary + customer detail)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class StaffChatCustomerOut(BaseModel):
|
||||
"""单个客户在日期窗内的私聊聚合行(本地 message 表派生)。"""
|
||||
|
||||
talker_id: str
|
||||
friend_name: str # remark > nickname > real_name > talker_id
|
||||
send_count: int # 员工发出(is_send True)
|
||||
receive_count: int # 客户发出(is_send False)
|
||||
interaction_days: int # 窗口内不同日期数
|
||||
last_is_send: int | None # 1=员工最后发, 0=客户最后发, None=最后一条方向未知(不计入两率)
|
||||
last_time: int | None = None # ms-epoch,最后一条时间
|
||||
|
||||
|
||||
class StaffChatStatsSummary(BaseModel):
|
||||
"""5 个汇总数字(从 customers 派生)。"""
|
||||
|
||||
chat_count: int # 沟通人数:窗口内 ≥1 条消息的去重客户数
|
||||
effective_count: int # 有效沟通数:send≥1 且 receive≥1 的客户数
|
||||
follow_count: int # 跟进客户数:interaction_days≥2 的客户数(近似口径)
|
||||
last_reply_count: int # 员工最后回复数:last_is_send=1 的客户数
|
||||
no_reply_count: int # 未回复客户数:last_is_send=0 的客户数
|
||||
|
||||
|
||||
class StaffChatStatsOut(BaseModel):
|
||||
owner_account_id: str
|
||||
start: int # ms-epoch
|
||||
end: int # ms-epoch
|
||||
summary: StaffChatStatsSummary
|
||||
customers: list[StaffChatCustomerOut]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for GET /api/staff-chat-stats — 微信员工 5 数字 + 客户明细(本地 message 聚合)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import Customer, Message
|
||||
|
||||
OWNER = "wx_owner_1"
|
||||
WIN_START = 1_700_000_000_000 # 固定 ms-epoch,确定性
|
||||
WIN_END = WIN_START + 3 * 86_400_000
|
||||
DAY = 86_400_000
|
||||
|
||||
|
||||
async def _seed(session: AsyncSession) -> None:
|
||||
session.add_all(
|
||||
[
|
||||
# 客户 A:跨 2 天双向 → effective + follow,员工最后发
|
||||
Message(msg_id="a1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START, type="text", type_raw=1, text="x"),
|
||||
Message(msg_id="a2", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=False, timestamp=WIN_START + DAY, type="text", type_raw=1, text="y"),
|
||||
Message(msg_id="a3", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + DAY + 1000, type="text", type_raw=1, text="z"),
|
||||
# 客户 B:仅接收 → 非 effective,客户最后发(未回复)
|
||||
Message(msg_id="b1", channel="wechat", owner_account_id=OWNER, talker_id="t_b",
|
||||
room_id=None, is_send=False, timestamp=WIN_START + 2000, type="text", type_raw=1, text="hi"),
|
||||
# 客户 C:单条员工发 → 员工最后发,1 天
|
||||
Message(msg_id="c1", channel="wechat", owner_account_id=OWNER, talker_id="t_c",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + 3000, type="text", type_raw=1, text="c"),
|
||||
# 群消息(room_id 非空)——必须被全部排除
|
||||
Message(msg_id="g1", channel="wechat", owner_account_id=OWNER, talker_id="t_grp_member",
|
||||
room_id="room123@chatroom", is_send=True, timestamp=WIN_START + 4000, type="text", type_raw=1, text="g"),
|
||||
# 窗口外私聊——必须排除(不污染 t_a 统计)
|
||||
Message(msg_id="o1", channel="wechat", owner_account_id=OWNER, talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_END + 1000, type="text", type_raw=1, text="future"),
|
||||
# 其他 owner——必须排除
|
||||
Message(msg_id="x1", channel="wechat", owner_account_id="wx_other", talker_id="t_a",
|
||||
room_id=None, is_send=True, timestamp=WIN_START + 5000, type="text", type_raw=1, text="other"),
|
||||
Customer(friend_id="t_a", channel="wechat", owner_account_id=OWNER,
|
||||
friend_nickname="Alice", friend_remark="Alice备注", real_name=None),
|
||||
Customer(friend_id="t_b", channel="wechat", owner_account_id=OWNER,
|
||||
friend_nickname="Bob", friend_remark=None, real_name=None),
|
||||
# t_c 无 customer 行 → 昵称 fallback 为 talker_id "t_c"
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def test_summary_five_numbers(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
s = r.json()["summary"]
|
||||
assert s == {
|
||||
"chat_count": 3, # t_a, t_b, t_c(群/窗外/他owner 全排除)
|
||||
"effective_count": 1, # 仅 t_a 双向
|
||||
"follow_count": 1, # 仅 t_a 跨 2 天
|
||||
"last_reply_count": 2, # t_a, t_c 员工最后发
|
||||
"no_reply_count": 1, # t_b 客户最后发
|
||||
}
|
||||
|
||||
|
||||
async def test_customers_fields_and_names(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
|
||||
assert set(by_id) == {"t_a", "t_b", "t_c"}
|
||||
# t_a: send=2 recv=1 days=2 last_is_send=1 last_time=a3
|
||||
assert by_id["t_a"]["friend_name"] == "Alice备注" # remark 优先
|
||||
assert by_id["t_a"]["send_count"] == 2
|
||||
assert by_id["t_a"]["receive_count"] == 1
|
||||
assert by_id["t_a"]["interaction_days"] == 2
|
||||
assert by_id["t_a"]["last_is_send"] == 1
|
||||
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000
|
||||
# t_b
|
||||
assert by_id["t_b"]["friend_name"] == "Bob"
|
||||
assert by_id["t_b"]["send_count"] == 0
|
||||
assert by_id["t_b"]["receive_count"] == 1
|
||||
assert by_id["t_b"]["interaction_days"] == 1
|
||||
assert by_id["t_b"]["last_is_send"] == 0
|
||||
# t_c 无 customer 行 → fallback
|
||||
assert by_id["t_c"]["friend_name"] == "t_c"
|
||||
assert by_id["t_c"]["last_is_send"] == 1
|
||||
|
||||
|
||||
async def test_group_messages_excluded(make_client):
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
ids = {c["talker_id"] for c in r.json()["customers"]}
|
||||
assert "t_grp_member" not in ids # 群消息被 room_id 过滤
|
||||
|
||||
|
||||
async def test_date_window_and_owner_filter(make_client):
|
||||
"""窗外消息与他 owner 消息不污染统计(t_a 未被 o1/x1 抬高)。"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
by_id = {c["talker_id"]: c for c in r.json()["customers"]}
|
||||
assert by_id["t_a"]["send_count"] == 2 # 非 4(a1+a3,排除 o1+x1)
|
||||
assert by_id["t_a"]["last_time"] == WIN_START + DAY + 1000 # 非 o1
|
||||
|
||||
|
||||
async def test_empty_when_no_messages(make_client):
|
||||
async def _empty(session: AsyncSession) -> None:
|
||||
await session.commit()
|
||||
|
||||
client = await make_client(_empty)
|
||||
r = await client.get(
|
||||
"/api/staff-chat-stats",
|
||||
params={"owner_account_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["customers"] == []
|
||||
assert body["summary"] == {
|
||||
"chat_count": 0, "effective_count": 0, "follow_count": 0,
|
||||
"last_reply_count": 0, "no_reply_count": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_default_window_returns_ok(make_client):
|
||||
"""省略 start/end 走默认近 3 天;2023 固定种子在默认窗外 → 空集,仅验结构。"""
|
||||
client = await make_client(_seed)
|
||||
r = await client.get("/api/staff-chat-stats", params={"owner_account_id": OWNER})
|
||||
assert r.status_code == 200
|
||||
assert "summary" in r.json() and "customers" in r.json()
|
||||
Reference in New Issue
Block a user