feat(api): GET /api/staff-stats 7类计数+分型明细(voice走user_id join,新客户add_time兜底,红包金额提取)
计数模式(无kind)返回 new_customers/new_groups/red_transfer/del_customers/
blocked_customers/voice_calls/sensitive_words(恒0,spec占位);明细模式
(?kind=)返回{kind,rows},每kind字段不同,前端按kind配列。start/end
缺省近7天。
口径(spec §4.5,全本地聚合):
- new_customers: add_time窗内;IS NULL行用该对话首条消息min(timestamp)兜底
- new_groups: chat_group.create_time窗内(复合PK按owner过滤)
- red_transfer: wechat type IN (red_packet,transfer)窗内;金额三路正则
(红包XML feedesc/转账纯文本/totalamount),实测真库转账399/399全提取
- del/blocked_customers: 现存量口径忽略窗口,明细time=updated_at毫秒
- voice_calls: phone_call.user_id存员工userId(32HEX)非wxId——先经
wechat_account.user_id换算,账号无uid返回空防跨员工串数
- voice明细字段语义对齐CallList.vue(connected=call_status==1,
media_url本地/api/media/{hash}优先OSS兜底)
真机smoke(yunke-mysql):voice 61条/新客67/删除29/拉黑1均通过;
转账金额100%提取。真库chat_group列与message.type回填滞后(运维项,另报)。
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ Includes:
|
||||
* ``GET /api/sync/status`` (see :mod:`app.api.routers.sync`).
|
||||
* ``GET /api/search`` (see :mod:`app.api.routers.search`).
|
||||
* ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`).
|
||||
* ``GET /api/staff-stats`` (see :mod:`app.api.routers.staff_stats`).
|
||||
|
||||
CORS is configured to read explicit allowed origins from ``Settings.cors_origins``
|
||||
(comma-separated ``CORS_ORIGINS`` env var). Empty (default) falls back to the dev
|
||||
@@ -53,6 +54,7 @@ from app.api.routers import (
|
||||
sessions,
|
||||
sms,
|
||||
staff_chat_stats,
|
||||
staff_stats,
|
||||
sync,
|
||||
voice_clips,
|
||||
)
|
||||
@@ -88,6 +90,7 @@ 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(staff_stats.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(sessions.router)
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""GET /api/staff-stats — 员工聊天窗口 7 类计数 + 分型明细(本地聚合,spec §4.5)。
|
||||
|
||||
对齐云客员工聊天窗口顶部 7 个统计数字与其点击弹出的分型明细列表:
|
||||
* 计数模式(无 ``kind``)→ ``{new_customers, new_groups, red_transfer,
|
||||
del_customers, blocked_customers, voice_calls, sensitive_words}``
|
||||
(``sensitive_words`` 恒 0——敏感词占位,spec 决策)。
|
||||
* 明细模式(``?kind=``)→ ``{kind, rows}``,每 kind 字段不同(统一
|
||||
``list[dict]``,前端按 kind 配置列)。
|
||||
|
||||
统计口径(spec §4.5,全部本地聚合,逐条实测验证):
|
||||
* new_customers ``customer.add_time`` 窗内;``add_time IS NULL`` 的行用
|
||||
「该对话首条消息时间」兜底(message 同 owner+talker 的
|
||||
``min(timestamp)`` 在窗内即算,~38% 行靠兜底)。
|
||||
* new_groups ``chat_group.create_time`` 窗内(Task 3 新列,
|
||||
raw_json createTime 回填);无值行不计。
|
||||
* red_transfer wechat 消息 type IN (red_packet, transfer) 且
|
||||
timestamp 窗内(Task 4 已回填类型);金额从 text
|
||||
红包 XML ``<feedesc>`` / 转账纯文本「发送转账:¥N」提取。
|
||||
* del_customers ``delete_flag=1`` 现存量(无时间字段→忽略窗口;
|
||||
明细行 time 用 updated_at 毫秒)。
|
||||
* blocked_customers ``is_block=1`` 现存量(同上)。
|
||||
* voice_calls ``phone_call.user_id`` 存的是员工 userId(32位HEX)而非
|
||||
wxId(实测坑!)——必须先经 ``wechat_account.user_id``
|
||||
换算再查,直查 wxId 恒 0。
|
||||
* sensitive_words 恒 0(占位)。
|
||||
|
||||
start/end 缺省 = 近 7 天(对齐云客统计默认窗)。结构镜像
|
||||
:mod:`app.api.routers.staff_chat_stats`(SessionDep + 每类一个私有
|
||||
聚合函数 + 主端点分派)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
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 StaffStatsCountsOut, StaffStatsDetailOut
|
||||
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["staff-stats"])
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
_MS_PER_DAY = 86_400_000
|
||||
|
||||
KindParam = Literal[
|
||||
"new_customers",
|
||||
"new_groups",
|
||||
"red_transfer",
|
||||
"del_customers",
|
||||
"blocked_customers",
|
||||
"voice_calls",
|
||||
]
|
||||
|
||||
# 红包/转账金额提取(spec §4.5 red_transfer):
|
||||
# 1) 红包 XML:``<wcpayinfo><feedesc>¥88.00</feedesc>``(feedesc 内货币符号非数字)
|
||||
# 2) 云客转账纯文本:``发送转账:¥100.00``
|
||||
# 3) 转账 XML ``<totalamount>100.00</totalamount>`` 兜底
|
||||
_AMOUNT_FEEDESC_RE = re.compile(r"<feedesc>[^0-9]*([\d.]+)")
|
||||
_AMOUNT_PLAIN_RE = re.compile(r"转账[::]?\s*[¥¥]\s*([\d.]+)")
|
||||
_AMOUNT_TOTAL_RE = re.compile(r"<totalamount>\s*([\d.]+)")
|
||||
|
||||
|
||||
def _extract_amount(text: str | None) -> float | None:
|
||||
"""从消息 text 提取金额(元);双格式,失败 None。"""
|
||||
if not text:
|
||||
return None
|
||||
m = (
|
||||
_AMOUNT_FEEDESC_RE.search(text)
|
||||
or _AMOUNT_PLAIN_RE.search(text)
|
||||
or _AMOUNT_TOTAL_RE.search(text)
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return float(m.group(1))
|
||||
except ValueError: # pragma: no cover — 正则已限定 [\d.]+,防御畸形串
|
||||
return None
|
||||
|
||||
|
||||
def _dt_ms(value: Any) -> int | None:
|
||||
"""DateTime 列 → ms epoch(naive,按本地时区解释;仅作展示兜底)。"""
|
||||
if value is None:
|
||||
return None
|
||||
return int(value.timestamp() * 1000)
|
||||
|
||||
|
||||
async def _new_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。"""
|
||||
base = [Customer.channel == "wechat", Customer.owner_account_id == wxid]
|
||||
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
||||
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
||||
fallback = (
|
||||
sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
sa.func.min(Message.timestamp).label("first_ts"),
|
||||
)
|
||||
.outerjoin(
|
||||
Message,
|
||||
sa.and_(
|
||||
Message.channel == "wechat",
|
||||
Message.owner_account_id == Customer.owner_account_id,
|
||||
Message.talker_id == Customer.friend_id,
|
||||
Message.timestamp.is_not(None),
|
||||
),
|
||||
)
|
||||
.where(*base, Customer.add_time.is_(None))
|
||||
.group_by(Customer.friend_id)
|
||||
.having(sa.func.min(Message.timestamp) >= start)
|
||||
.having(sa.func.min(Message.timestamp) <= end)
|
||||
)
|
||||
# 直接口径:add_time 本身在窗内。
|
||||
direct = sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.head_image_url,
|
||||
Customer.add_time,
|
||||
).where(*base, Customer.add_time >= start, Customer.add_time <= end)
|
||||
|
||||
rows: list[dict[str, Any]] = [
|
||||
{
|
||||
"talker_id": fid,
|
||||
"nickname": nick,
|
||||
"avatar": head or None,
|
||||
"remark": remark,
|
||||
"time": add_time,
|
||||
}
|
||||
for fid, nick, remark, head, add_time in (
|
||||
await session.execute(direct)
|
||||
).all()
|
||||
]
|
||||
rows += [
|
||||
{
|
||||
"talker_id": fid,
|
||||
"nickname": nick,
|
||||
"avatar": head or None,
|
||||
"remark": remark,
|
||||
"time": first_ts,
|
||||
}
|
||||
for fid, nick, remark, head, first_ts in (
|
||||
await session.execute(fallback)
|
||||
).all()
|
||||
]
|
||||
rows.sort(key=lambda r: r["time"] or 0, reverse=True)
|
||||
return len(rows), rows
|
||||
|
||||
|
||||
async def _new_groups(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""新群:chat_group.create_time 窗内(每员工一行,owner 过滤)。"""
|
||||
conds = [
|
||||
ChatGroup.channel == "wechat",
|
||||
ChatGroup.owner_account_id == wxid,
|
||||
ChatGroup.create_time >= start,
|
||||
ChatGroup.create_time <= end,
|
||||
]
|
||||
count = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(ChatGroup).where(*conds)
|
||||
)
|
||||
).scalar_one()
|
||||
if not detail:
|
||||
return count, []
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(ChatGroup.room_id, ChatGroup.room_name, ChatGroup.create_time)
|
||||
.where(*conds)
|
||||
.order_by(ChatGroup.create_time.desc(), ChatGroup.room_id.asc())
|
||||
)
|
||||
).all()
|
||||
return count, [
|
||||
{"room_id": rid, "room_name": name, "time": ts} for rid, name, ts in rows
|
||||
]
|
||||
|
||||
|
||||
async def _red_transfer(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""红包+转账:wechat 消息 type IN (red_packet, transfer) 且窗内。"""
|
||||
conds = [
|
||||
Message.channel == "wechat",
|
||||
Message.owner_account_id == wxid,
|
||||
Message.type.in_(["red_packet", "transfer"]),
|
||||
Message.timestamp >= start,
|
||||
Message.timestamp <= end,
|
||||
]
|
||||
count = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(Message).where(*conds)
|
||||
)
|
||||
).scalar_one()
|
||||
if not detail:
|
||||
return count, []
|
||||
msgs = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
Message.msg_id,
|
||||
Message.talker_id,
|
||||
Message.room_id,
|
||||
Message.type,
|
||||
Message.text,
|
||||
Message.timestamp,
|
||||
Message.sender_name,
|
||||
Message.raw_json,
|
||||
)
|
||||
.where(*conds)
|
||||
.order_by(Message.timestamp.desc(), Message.msg_id.asc())
|
||||
)
|
||||
).all()
|
||||
# nickname 优先 customer 昵称,回退 message raw_json.name(sender_name 即其
|
||||
# 结构化列);群红包 talker 是发红包的成员,同口径查 customer。
|
||||
talker_ids = [m.talker_id for m in msgs if m.talker_id]
|
||||
nick_map: dict[str, str] = {}
|
||||
if talker_ids:
|
||||
nick_map = {
|
||||
fid: nick
|
||||
for fid, nick in (
|
||||
await session.execute(
|
||||
sa.select(Customer.friend_id, Customer.friend_nickname).where(
|
||||
Customer.channel == "wechat",
|
||||
Customer.owner_account_id == wxid,
|
||||
Customer.friend_id.in_(talker_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
if nick
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for m in msgs:
|
||||
raw_name = (m.raw_json or {}).get("name") if m.raw_json else None
|
||||
rows.append(
|
||||
{
|
||||
"op_type": "红包" if m.type == "red_packet" else "转账",
|
||||
"talker_id": m.talker_id,
|
||||
"room_id": m.room_id or None,
|
||||
"nickname": nick_map.get(m.talker_id or "") or m.sender_name or raw_name,
|
||||
"amount": _extract_amount(m.text),
|
||||
"time": m.timestamp,
|
||||
"msg_id": m.msg_id,
|
||||
}
|
||||
)
|
||||
return count, rows
|
||||
|
||||
|
||||
async def _flag_customers(
|
||||
session: AsyncSession,
|
||||
wxid: str,
|
||||
flag_cond: sa.ColumnElement,
|
||||
*,
|
||||
detail: bool,
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
|
||||
|
||||
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
|
||||
"""
|
||||
conds = [Customer.channel == "wechat", Customer.owner_account_id == wxid, flag_cond]
|
||||
count = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(Customer).where(*conds)
|
||||
)
|
||||
).scalar_one()
|
||||
if not detail:
|
||||
return count, []
|
||||
rows = (
|
||||
await session.execute(
|
||||
sa.select(
|
||||
Customer.friend_id,
|
||||
Customer.friend_nickname,
|
||||
Customer.friend_remark,
|
||||
Customer.updated_at,
|
||||
)
|
||||
.where(*conds)
|
||||
.order_by(Customer.updated_at.desc(), Customer.friend_id.asc())
|
||||
)
|
||||
).all()
|
||||
return count, [
|
||||
{
|
||||
"talker_id": fid,
|
||||
"nickname": nick or remark,
|
||||
"time": _dt_ms(updated),
|
||||
}
|
||||
for fid, nick, remark, updated in rows
|
||||
]
|
||||
|
||||
|
||||
async def _del_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。"""
|
||||
return await _flag_customers(
|
||||
session, wxid, Customer.delete_flag == 1, detail=detail
|
||||
)
|
||||
|
||||
|
||||
async def _blocked_customers(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。"""
|
||||
return await _flag_customers(
|
||||
session, wxid, Customer.is_block.is_(True), detail=detail
|
||||
)
|
||||
|
||||
|
||||
async def _voice_calls(
|
||||
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
||||
) -> tuple[int, list[dict[str, Any]]]:
|
||||
"""语音通话:先 wechat_account.user_id 换算(防直查 wxId 恒 0)再查。
|
||||
|
||||
明细字段语义对齐 CallList.vue / PhoneCallOut:
|
||||
``connected`` 按 voice_call 极性 call_status==1(与前端 callStatusLabel
|
||||
同判据:1=已接通,0/2=未接通);``media_url`` 本地 /api/media/{hash}
|
||||
优先、OSS record_file 兜底(与 playRecord 同顺序)。账号无 user_id
|
||||
(未同步/未知员工)→ 无法归属,返回空而非全量(防跨员工串数)。
|
||||
"""
|
||||
uid = (
|
||||
await session.execute(
|
||||
sa.select(WechatAccount.user_id).where(WechatAccount.wx_id == wxid)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not uid:
|
||||
return 0, []
|
||||
conds = [
|
||||
PhoneCall.channel == "voice_call",
|
||||
PhoneCall.user_id == uid,
|
||||
PhoneCall.start_call_time >= start,
|
||||
PhoneCall.start_call_time <= end,
|
||||
]
|
||||
count = (
|
||||
await session.execute(
|
||||
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
|
||||
)
|
||||
).scalar_one()
|
||||
if not detail:
|
||||
return count, []
|
||||
calls = (
|
||||
await session.execute(
|
||||
sa.select(PhoneCall)
|
||||
.where(*conds)
|
||||
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.asc())
|
||||
)
|
||||
).scalars().all()
|
||||
rows = [
|
||||
{
|
||||
"id": c.call_id,
|
||||
"peer": c.call_number,
|
||||
"incoming": c.incoming_call,
|
||||
"connected": c.call_status == 1,
|
||||
"duration": c.call_duration,
|
||||
"start_time": c.start_call_time,
|
||||
"media_url": (
|
||||
f"/api/media/{c.record_media_hash}" if c.record_media_hash else c.record_file
|
||||
),
|
||||
}
|
||||
for c in calls
|
||||
]
|
||||
return count, rows
|
||||
|
||||
|
||||
_AGGREGATORS = {
|
||||
"new_customers": _new_customers,
|
||||
"new_groups": _new_groups,
|
||||
"red_transfer": _red_transfer,
|
||||
"del_customers": _del_customers,
|
||||
"blocked_customers": _blocked_customers,
|
||||
"voice_calls": _voice_calls,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/staff-stats")
|
||||
async def get_staff_stats(
|
||||
session: SessionDep,
|
||||
wechat_id: Annotated[str, Query(min_length=1)],
|
||||
start: Annotated[int | None, Query(ge=0)] = None,
|
||||
end: Annotated[int | None, Query(ge=0)] = None,
|
||||
kind: Annotated[KindParam | None, Query()] = None,
|
||||
) -> StaffStatsCountsOut | StaffStatsDetailOut:
|
||||
"""员工 7 类计数(无 kind)或某一类的明细列表(?kind=)。
|
||||
|
||||
窗口 [start, end] 均为 ms epoch 闭区间;缺省 = 近 7 天(云客统计默认窗)。
|
||||
del_customers / blocked_customers 是现存量口径,窗口仅约束其余 4 类。
|
||||
"""
|
||||
if end is None:
|
||||
end = int(time.time() * 1000)
|
||||
if start is None:
|
||||
start = end - 7 * _MS_PER_DAY
|
||||
|
||||
if kind is None:
|
||||
counts: dict[str, int] = {}
|
||||
for kind_key, aggregate in _AGGREGATORS.items():
|
||||
counts[kind_key], _ = await aggregate(session, wechat_id, start, end, detail=False)
|
||||
counts["sensitive_words"] = 0 # 占位,spec 决策
|
||||
return StaffStatsCountsOut(**counts)
|
||||
|
||||
_, rows = await _AGGREGATORS[kind](session, wechat_id, start, end, detail=True)
|
||||
return StaffStatsDetailOut(kind=kind, rows=rows)
|
||||
@@ -403,6 +403,39 @@ class StaffChatStatsOut(BaseModel):
|
||||
customers: list[StaffChatCustomerOut]
|
||||
|
||||
|
||||
class StaffStatsCountsOut(BaseModel):
|
||||
"""GET /api/staff-stats 计数模式(无 kind)——云客员工聊天窗口 7 类汇总数字。
|
||||
|
||||
口径见 :mod:`app.api.routers.staff_stats`(spec §4.5):new_customers 含
|
||||
「首条消息时间」兜底;del/blocked 是现存量(不受窗口约束);voice_calls
|
||||
走 wechat_account.user_id 换算。``sensitive_words`` 为敏感词占位,恒 0。
|
||||
"""
|
||||
|
||||
new_customers: int
|
||||
new_groups: int
|
||||
red_transfer: int
|
||||
del_customers: int
|
||||
blocked_customers: int
|
||||
voice_calls: int
|
||||
sensitive_words: int = 0
|
||||
|
||||
|
||||
class StaffStatsDetailOut(BaseModel):
|
||||
"""GET /api/staff-stats 明细模式(``?kind=``)——rows 字段随 kind 不同。
|
||||
|
||||
统一 ``list[dict]``,前端按 kind 配置列(字段清单见 router docstring):
|
||||
new_customers {talker_id, nickname, avatar, remark, time} /
|
||||
new_groups {room_id, room_name, time} /
|
||||
red_transfer {op_type, talker_id, room_id, nickname, amount, time, msg_id} /
|
||||
del|blocked_customers {talker_id, nickname, time} /
|
||||
voice_calls {id, peer, incoming, connected, duration, start_time, media_url}
|
||||
(语义对齐 CallList.vue / PhoneCallOut)。
|
||||
"""
|
||||
|
||||
kind: str
|
||||
rows: list[dict[str, Any]]
|
||||
|
||||
|
||||
class SessionOut(BaseModel):
|
||||
"""账号会话列表的一行(好友或群)。"""
|
||||
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Tests for GET /api/staff-stats — 员工 7 类计数 + 分型明细(本地聚合,spec §4.5)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models import ChatGroup, Customer, Message, PhoneCall, WechatAccount
|
||||
|
||||
OWNER = "wxid_o"
|
||||
UID = "U32HEX" # 员工 userId(32位HEX)——phone_call.user_id 存的是它,不是 wxId!
|
||||
WIN_START = 1_700_000_000_000
|
||||
WIN_END = 1_900_000_000_000
|
||||
IN = 1_800_000_000_000 # 窗内锚点
|
||||
DAY = 86_400_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 计数模式(无 kind)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _seed_counts(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
# 新客户: add_time 窗内 1 + 兜底首消息窗内 1 + 窗外 1(不计)
|
||||
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER, add_time=IN))
|
||||
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None))
|
||||
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
||||
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
||||
timestamp=IN + 1))
|
||||
# 红包+转账(窗内各 1)
|
||||
s.add(Message(msg_id="m2", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
||||
timestamp=IN + 2, type="red_packet"))
|
||||
s.add(Message(msg_id="m3", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
||||
timestamp=IN + 3, type="transfer"))
|
||||
# 新群: 窗内 1 + 窗外 1(不计)
|
||||
s.add(ChatGroup(room_id="r1@chatroom", channel="wechat", owner_account_id=OWNER, create_time=IN))
|
||||
s.add(ChatGroup(room_id="r2@chatroom", channel="wechat", owner_account_id=OWNER, create_time=1_000))
|
||||
# 删除/拉黑(现存量口径,忽略窗口)
|
||||
s.add(Customer(friend_id="c9", channel="wechat", owner_account_id=OWNER, delete_flag=1))
|
||||
s.add(Customer(friend_id="c8", channel="wechat", owner_account_id=OWNER, is_block=True))
|
||||
# 语音通话: user_id 是 32HEX 非 wxId!另 seed 一条直存 wxId 的(不计,锁死 join 口径)
|
||||
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
||||
start_call_time=IN + 4, call_number="c1"))
|
||||
s.add(PhoneCall(call_id="pc_x", channel="voice_call", user_id=OWNER,
|
||||
start_call_time=IN + 5, call_number="c1"))
|
||||
await s.commit()
|
||||
|
||||
|
||||
async def test_counts_all_seven_kinds(make_client):
|
||||
"""7 类计数一次返回;sensitive_words 恒 0;voice_calls 走 user_id 换算。"""
|
||||
client = await make_client(_seed_counts)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {
|
||||
"new_customers", "new_groups", "red_transfer",
|
||||
"del_customers", "blocked_customers", "voice_calls", "sensitive_words",
|
||||
}
|
||||
assert body["new_customers"] == 2 # c1(add_time) + c2(首消息兜底); c3 窗外
|
||||
assert body["new_groups"] == 1
|
||||
assert body["red_transfer"] == 2
|
||||
assert body["del_customers"] == 1 # 现存量
|
||||
assert body["blocked_customers"] == 1 # 现存量
|
||||
assert body["voice_calls"] == 1 # 直查 wxid 会得 0 —— join user_id
|
||||
assert body["sensitive_words"] == 0 # 占位,spec 决策
|
||||
|
||||
|
||||
async def test_counts_default_window_last_7_days(make_client):
|
||||
"""缺省 start/end → 近 7 天:窗内计、8 天前不计。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
now = int(time.time() * 1000)
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(Customer(friend_id="c_now", channel="wechat", owner_account_id=OWNER, add_time=now))
|
||||
s.add(Customer(friend_id="c_old", channel="wechat", owner_account_id=OWNER,
|
||||
add_time=now - 8 * DAY))
|
||||
s.add(PhoneCall(call_id="pc_now", channel="voice_call", user_id=UID,
|
||||
start_call_time=now, call_number="c_now"))
|
||||
s.add(PhoneCall(call_id="pc_old", channel="voice_call", user_id=UID,
|
||||
start_call_time=now - 8 * DAY))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/staff-stats", params={"wechat_id": OWNER})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["new_customers"] == 1 # 只计窗内
|
||||
assert body["voice_calls"] == 1
|
||||
|
||||
|
||||
async def test_counts_unknown_staff_all_zero(make_client):
|
||||
"""未知员工:全 0(不 404,前端弹窗兜底)。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": "wxid_ghost", "start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {
|
||||
"new_customers": 0, "new_groups": 0, "red_transfer": 0,
|
||||
"del_customers": 0, "blocked_customers": 0, "voice_calls": 0,
|
||||
"sensitive_words": 0,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 明细模式(?kind=) — 每 kind 字段各一条
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_detail_new_customers_fields(make_client):
|
||||
"""new_customers 行 {talker_id, nickname, avatar, remark, time};兜底行 time=首消息 ms。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER, add_time=IN,
|
||||
friend_nickname="牛牛", friend_remark="大客户",
|
||||
head_image_url="http://a/1.png"))
|
||||
s.add(Customer(friend_id="c2", channel="wechat", owner_account_id=OWNER, add_time=None,
|
||||
friend_nickname="兜底客"))
|
||||
s.add(Customer(friend_id="c3", channel="wechat", owner_account_id=OWNER, add_time=1_000))
|
||||
s.add(Message(msg_id="m1", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
||||
timestamp=IN - 5))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "new_customers",
|
||||
"start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["kind"] == "new_customers"
|
||||
by = {row["talker_id"]: row for row in body["rows"]}
|
||||
assert set(by) == {"c1", "c2"} # c3 窗外
|
||||
assert by["c1"]["nickname"] == "牛牛"
|
||||
assert by["c1"]["remark"] == "大客户"
|
||||
assert by["c1"]["avatar"] == "http://a/1.png"
|
||||
assert by["c1"]["time"] == IN
|
||||
assert by["c2"]["time"] == IN - 5 # add_time NULL → 首条消息兜底
|
||||
# 排序:time desc(新→旧)
|
||||
assert [row["talker_id"] for row in body["rows"]] == ["c1", "c2"]
|
||||
|
||||
|
||||
async def test_detail_new_groups_fields(make_client):
|
||||
"""new_groups 行 {room_id, room_name, time};只计本 owner 窗内。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(ChatGroup(room_id="r1@chatroom", channel="wechat", owner_account_id=OWNER,
|
||||
room_name="老客户群", create_time=IN))
|
||||
s.add(ChatGroup(room_id="r2@chatroom", channel="wechat", owner_account_id=OWNER,
|
||||
room_name="窗外群", create_time=1_000))
|
||||
s.add(ChatGroup(room_id="r3@chatroom", channel="wechat", owner_account_id="wxid_other",
|
||||
room_name="别人的群", create_time=IN))
|
||||
s.add(ChatGroup(room_id="r4@chatroom", channel="wechat", owner_account_id=OWNER,
|
||||
room_name="无建群时间", create_time=None))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "new_groups",
|
||||
"start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["rows"]
|
||||
assert rows == [{"room_id": "r1@chatroom", "room_name": "老客户群", "time": IN}]
|
||||
|
||||
|
||||
async def test_detail_red_transfer_fields(make_client):
|
||||
"""red_transfer 行 {op_type, talker_id, room_id, nickname, amount, time, msg_id};
|
||||
金额双格式提取(红包 XML feedesc / 转账纯文本),失败 None;nickname 客户优先。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(Customer(friend_id="c1", channel="wechat", owner_account_id=OWNER,
|
||||
friend_nickname="客户昵称"))
|
||||
# 红包: XML <feedesc>¥88.00</feedesc>
|
||||
s.add(Message(msg_id="m2", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
||||
timestamp=IN, type="red_packet",
|
||||
text="<msg><appmsg><type>2001</type>"
|
||||
"<wcpayinfo><feedesc>¥88.00</feedesc></wcpayinfo>"
|
||||
"</appmsg></msg>"))
|
||||
# 转账: 云客纯文本「发送转账:¥100.00」;c2 无 customer 行 → raw_json.name 兜底
|
||||
s.add(Message(msg_id="m3", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
||||
timestamp=IN + 1, type="transfer", text="发送转账:¥100.00",
|
||||
raw_json={"name": "裸数据客"}))
|
||||
# 转账无金额可提 → amount None
|
||||
s.add(Message(msg_id="m4", channel="wechat", owner_account_id=OWNER, talker_id="c2",
|
||||
timestamp=IN + 2, type="transfer", text=None))
|
||||
# 普通消息不计
|
||||
s.add(Message(msg_id="m_text", channel="wechat", owner_account_id=OWNER, talker_id="c1",
|
||||
timestamp=IN + 3, type="text", text="你好"))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "red_transfer",
|
||||
"start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["rows"]
|
||||
assert [row["msg_id"] for row in rows] == ["m4", "m3", "m2"] # time desc
|
||||
by = {row["msg_id"]: row for row in rows}
|
||||
assert by["m2"]["op_type"] == "红包"
|
||||
assert by["m2"]["amount"] == 88.0
|
||||
assert by["m2"]["talker_id"] == "c1"
|
||||
assert by["m2"]["room_id"] is None
|
||||
assert by["m2"]["nickname"] == "客户昵称"
|
||||
assert by["m2"]["time"] == IN
|
||||
assert by["m3"]["op_type"] == "转账"
|
||||
assert by["m3"]["amount"] == 100.0
|
||||
assert by["m3"]["nickname"] == "裸数据客" # customer 无行 → raw_json.name
|
||||
assert by["m4"]["amount"] is None
|
||||
|
||||
|
||||
async def test_detail_del_and_blocked_fields(make_client):
|
||||
"""del/blocked 行 {talker_id, nickname, time};time=updated_at 毫秒;只计本 owner。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(Customer(friend_id="c_del", channel="wechat", owner_account_id=OWNER,
|
||||
delete_flag=1, friend_nickname="已删客"))
|
||||
s.add(Customer(friend_id="c_blk", channel="wechat", owner_account_id=OWNER,
|
||||
is_block=True, friend_nickname=None, friend_remark="被拉黑"))
|
||||
# 干扰项: 正常客户 / 别人的删除客户 / delete_flag=0
|
||||
s.add(Customer(friend_id="c_ok", channel="wechat", owner_account_id=OWNER))
|
||||
s.add(Customer(friend_id="c_other", channel="wechat", owner_account_id="wxid_other",
|
||||
delete_flag=1))
|
||||
s.add(Customer(friend_id="c_fine", channel="wechat", owner_account_id=OWNER,
|
||||
delete_flag=0, is_block=False))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r_del = await client.get("/api/staff-stats", params={"wechat_id": OWNER, "kind": "del_customers"})
|
||||
r_blk = await client.get("/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "blocked_customers"})
|
||||
assert r_del.status_code == r_blk.status_code == 200
|
||||
rows_del = r_del.json()["rows"]
|
||||
assert len(rows_del) == 1
|
||||
d = rows_del[0]
|
||||
assert d["talker_id"] == "c_del" and d["nickname"] == "已删客"
|
||||
assert isinstance(d["time"], int) and d["time"] > WIN_START # updated_at ms
|
||||
rows_blk = r_blk.json()["rows"]
|
||||
assert len(rows_blk) == 1
|
||||
b = rows_blk[0]
|
||||
assert b["talker_id"] == "c_blk"
|
||||
assert b["nickname"] == "被拉黑" # nickname 空时回退备注
|
||||
assert isinstance(b["time"], int) and b["time"] > WIN_START
|
||||
|
||||
|
||||
async def test_detail_voice_calls_fields(make_client):
|
||||
"""voice_calls 行 {id, peer, incoming, connected, duration, start_time, media_url};
|
||||
语义对齐 CallList.vue:connected=voice_call 口径 call_status==1,
|
||||
media_url 本地 /api/media/{hash} 优先、OSS record_file 兜底。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
s.add(WechatAccount(wx_id=OWNER, user_id=UID))
|
||||
s.add(PhoneCall(call_id="pc1", channel="voice_call", user_id=UID,
|
||||
start_call_time=IN, call_number="c1", incoming_call=1,
|
||||
call_status=1, call_duration=62, record_media_hash="abc123"))
|
||||
s.add(PhoneCall(call_id="pc2", channel="voice_call", user_id=UID,
|
||||
start_call_time=IN + 1, call_number="room@chatroom",
|
||||
incoming_call=0, call_status=2, call_duration=0,
|
||||
record_file="http://oss/x.mp3"))
|
||||
# 干扰项: 他人语音 / 电话通话(同 uid 也不计)
|
||||
s.add(PhoneCall(call_id="pc3", channel="voice_call", user_id="other_uid",
|
||||
start_call_time=IN))
|
||||
s.add(PhoneCall(call_id="pc4", channel="phone_call", user_id=UID,
|
||||
start_call_time=IN))
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get(
|
||||
"/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "voice_calls",
|
||||
"start": WIN_START, "end": WIN_END},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
rows = r.json()["rows"]
|
||||
assert [row["id"] for row in rows] == ["pc2", "pc1"] # start_time desc
|
||||
by = {row["id"]: row for row in rows}
|
||||
assert by["pc1"]["peer"] == "c1"
|
||||
assert by["pc1"]["incoming"] == 1
|
||||
assert by["pc1"]["connected"] is True # voice_call: call_status==1 接通
|
||||
assert by["pc1"]["duration"] == 62
|
||||
assert by["pc1"]["start_time"] == IN
|
||||
assert by["pc1"]["media_url"] == "/api/media/abc123" # 本地优先
|
||||
assert by["pc2"]["peer"] == "room@chatroom"
|
||||
assert by["pc2"]["incoming"] == 0
|
||||
assert by["pc2"]["connected"] is False # voice_call: call_status==2 未接通
|
||||
assert by["pc2"]["media_url"] == "http://oss/x.mp3" # OSS 兜底
|
||||
|
||||
|
||||
async def test_detail_unknown_kind_422(make_client):
|
||||
"""非法 kind → 422(Literal 校验)。"""
|
||||
|
||||
async def seed(s: AsyncSession) -> None:
|
||||
await s.commit()
|
||||
|
||||
client = await make_client(seed)
|
||||
r = await client.get("/api/staff-stats",
|
||||
params={"wechat_id": OWNER, "kind": "bogus"})
|
||||
assert r.status_code == 422
|
||||
Reference in New Issue
Block a user