2026-08-25 16:22:44 +08:00
|
|
|
|
"""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
|
2026-09-09 13:45:42 +08:00
|
|
|
|
from app.db.models import (
|
|
|
|
|
|
ChatGroup,
|
|
|
|
|
|
Customer,
|
|
|
|
|
|
CustomerAccountLink,
|
|
|
|
|
|
Message,
|
|
|
|
|
|
PhoneCall,
|
|
|
|
|
|
WechatAccount,
|
|
|
|
|
|
)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
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>`` 兜底
|
2026-08-25 16:36:07 +08:00
|
|
|
|
# 金额可带千分位逗号(``¥1,000.00``)→ 捕获组为 [\d.,]+,float() 前剔逗号。
|
|
|
|
|
|
_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.,]+)")
|
2026-08-25 16:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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:
|
2026-08-25 16:36:07 +08:00
|
|
|
|
return float(m.group(1).replace(",", ""))
|
|
|
|
|
|
except ValueError: # pragma: no cover — 正则已限定 [\d.,]+,防御畸形串
|
2026-08-25 16:22:44 +08:00
|
|
|
|
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(
|
2026-08-25 16:36:07 +08:00
|
|
|
|
session: AsyncSession, wxid: str, start: int, end: int
|
2026-08-25 16:22:44 +08:00
|
|
|
|
) -> tuple[int, list[dict[str, Any]]]:
|
2026-08-25 16:36:07 +08:00
|
|
|
|
"""新客户:add_time 窗内;add_time IS NULL 用首条消息时间兜底。
|
|
|
|
|
|
|
2026-09-09 13:45:42 +08:00
|
|
|
|
读 ``customer_account_link``(2026-09-09):customer 档案全局单行、
|
|
|
|
|
|
归属 last-wins,共享客户在其他员工名下会漏计;link 每账号一行,与
|
|
|
|
|
|
云客按员工统计的口径一致(数字会比档案口径大)。
|
|
|
|
|
|
|
2026-08-25 16:36:07 +08:00
|
|
|
|
无 ``detail`` 分支——计数模式有意复用明细物化(窗内新增客户通常
|
|
|
|
|
|
几十行,``len()`` 即可;COUNT 需 direct+fallback 双查询,不值)。
|
|
|
|
|
|
"""
|
2026-09-09 13:45:42 +08:00
|
|
|
|
base = [
|
|
|
|
|
|
CustomerAccountLink.channel == "wechat",
|
|
|
|
|
|
CustomerAccountLink.owner_account_id == wxid,
|
|
|
|
|
|
]
|
2026-08-25 16:22:44 +08:00
|
|
|
|
# 兜底口径:每个 add_time 为空的客户,取其与该员工首条消息 min(timestamp)
|
|
|
|
|
|
# (LEFT JOIN 走 ix_message_channel_owner_talker_time,HAVING 在 SQL 侧过滤窗)。
|
|
|
|
|
|
fallback = (
|
|
|
|
|
|
sa.select(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
CustomerAccountLink.friend_id,
|
|
|
|
|
|
CustomerAccountLink.friend_nickname,
|
|
|
|
|
|
CustomerAccountLink.friend_remark,
|
|
|
|
|
|
CustomerAccountLink.head_image_url,
|
2026-08-25 16:22:44 +08:00
|
|
|
|
sa.func.min(Message.timestamp).label("first_ts"),
|
|
|
|
|
|
)
|
|
|
|
|
|
.outerjoin(
|
|
|
|
|
|
Message,
|
|
|
|
|
|
sa.and_(
|
|
|
|
|
|
Message.channel == "wechat",
|
2026-09-09 13:45:42 +08:00
|
|
|
|
Message.owner_account_id == CustomerAccountLink.owner_account_id,
|
|
|
|
|
|
Message.talker_id == CustomerAccountLink.friend_id,
|
2026-08-25 16:22:44 +08:00
|
|
|
|
Message.timestamp.is_not(None),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
2026-09-09 13:45:42 +08:00
|
|
|
|
.where(*base, CustomerAccountLink.add_time.is_(None))
|
|
|
|
|
|
# GROUP BY 显式列出全部裸选列:friend_id 是 link 复合 PK 的尾列,
|
|
|
|
|
|
# (owner,friend) 已由 WHERE 钉死,其余列函数依赖于它→分组结果不变;
|
|
|
|
|
|
# MySQL ONLY_FULL_GROUP_BY 部署安全(SQLite 同样兼容)。
|
2026-08-25 16:36:07 +08:00
|
|
|
|
.group_by(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
CustomerAccountLink.friend_id,
|
|
|
|
|
|
CustomerAccountLink.friend_nickname,
|
|
|
|
|
|
CustomerAccountLink.friend_remark,
|
|
|
|
|
|
CustomerAccountLink.head_image_url,
|
2026-08-25 16:36:07 +08:00
|
|
|
|
)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
.having(sa.func.min(Message.timestamp) >= start)
|
|
|
|
|
|
.having(sa.func.min(Message.timestamp) <= end)
|
|
|
|
|
|
)
|
|
|
|
|
|
# 直接口径:add_time 本身在窗内。
|
|
|
|
|
|
direct = sa.select(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
CustomerAccountLink.friend_id,
|
|
|
|
|
|
CustomerAccountLink.friend_nickname,
|
|
|
|
|
|
CustomerAccountLink.friend_remark,
|
|
|
|
|
|
CustomerAccountLink.head_image_url,
|
|
|
|
|
|
CustomerAccountLink.add_time,
|
|
|
|
|
|
).where(*base, CustomerAccountLink.add_time >= start, CustomerAccountLink.add_time <= end)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-09-09 13:45:42 +08:00
|
|
|
|
# 昵称优先 link per-owner 快照(共享好友档案行可能挂别的员工名下,
|
|
|
|
|
|
# 2026-09-09 同 sessions._meta),档案兜底
|
2026-08-25 16:22:44 +08:00
|
|
|
|
nick_map = {
|
|
|
|
|
|
fid: nick
|
|
|
|
|
|
for fid, nick in (
|
|
|
|
|
|
await session.execute(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
sa.select(
|
|
|
|
|
|
CustomerAccountLink.friend_id,
|
|
|
|
|
|
CustomerAccountLink.friend_nickname,
|
|
|
|
|
|
).where(
|
|
|
|
|
|
CustomerAccountLink.channel == "wechat",
|
|
|
|
|
|
CustomerAccountLink.owner_account_id == wxid,
|
|
|
|
|
|
CustomerAccountLink.friend_id.in_(talker_ids),
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
).all()
|
|
|
|
|
|
if nick
|
|
|
|
|
|
}
|
2026-09-09 13:45:42 +08:00
|
|
|
|
missing = [t for t in talker_ids if t not in nick_map]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
nick_map.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
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_(missing),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
).all()
|
|
|
|
|
|
if nick
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
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,
|
2026-09-09 13:45:42 +08:00
|
|
|
|
model: type,
|
2026-08-25 16:22:44 +08:00
|
|
|
|
) -> tuple[int, list[dict[str, Any]]]:
|
|
|
|
|
|
"""del_customers / blocked_customers 共用:现存量口径,忽略时间窗。
|
|
|
|
|
|
|
2026-09-09 13:45:42 +08:00
|
|
|
|
``model`` 由调用方决定(两者列名同形):del 走 ``CustomerAccountLink``
|
|
|
|
|
|
(delete_flag per-relation,共享好友不漏),blocked 走 ``Customer``
|
|
|
|
|
|
档案(is_block 未镜像进 link,2026-09-09)。
|
|
|
|
|
|
|
2026-08-25 16:22:44 +08:00
|
|
|
|
无时间字段→明细行 time 用 updated_at 毫秒(仅展示;update 触发即变)。
|
|
|
|
|
|
"""
|
2026-09-09 13:45:42 +08:00
|
|
|
|
conds = [model.channel == "wechat", model.owner_account_id == wxid, flag_cond]
|
2026-08-25 16:22:44 +08:00
|
|
|
|
count = (
|
|
|
|
|
|
await session.execute(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
sa.select(sa.func.count()).select_from(model).where(*conds)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
).scalar_one()
|
|
|
|
|
|
if not detail:
|
|
|
|
|
|
return count, []
|
|
|
|
|
|
rows = (
|
|
|
|
|
|
await session.execute(
|
|
|
|
|
|
sa.select(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
model.friend_id,
|
|
|
|
|
|
model.friend_nickname,
|
|
|
|
|
|
model.friend_remark,
|
|
|
|
|
|
model.updated_at,
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
.where(*conds)
|
2026-09-09 13:45:42 +08:00
|
|
|
|
.order_by(model.updated_at.desc(), model.friend_id.asc())
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
).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]]]:
|
2026-09-09 13:45:42 +08:00
|
|
|
|
"""删除客户:delete_flag=1 现存量(start/end 按口径忽略)。
|
|
|
|
|
|
|
|
|
|
|
|
读 link(per-relation delete_flag,共享好友不漏计,2026-09-09)。
|
|
|
|
|
|
"""
|
2026-08-25 16:22:44 +08:00
|
|
|
|
return await _flag_customers(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
session, wxid, CustomerAccountLink.delete_flag == 1,
|
|
|
|
|
|
detail=detail, model=CustomerAccountLink,
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def _blocked_customers(
|
|
|
|
|
|
session: AsyncSession, wxid: str, start: int, end: int, detail: bool
|
|
|
|
|
|
) -> tuple[int, list[dict[str, Any]]]:
|
2026-09-09 13:45:42 +08:00
|
|
|
|
"""流失(拉黑)客户:is_block=1 现存量(start/end 按口径忽略)。
|
|
|
|
|
|
|
|
|
|
|
|
保持档案口径:is_block 未镜像进 link 表,切了会漏;已知偏差=共享好友
|
|
|
|
|
|
在其他员工名下的拉黑不计入(待 link 补列后切换)。
|
|
|
|
|
|
"""
|
2026-08-25 16:22:44 +08:00
|
|
|
|
return await _flag_customers(
|
2026-09-09 13:45:42 +08:00
|
|
|
|
session, wxid, Customer.is_block.is_(True), detail=detail, model=Customer
|
2026-08-25 16:22:44 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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():
|
2026-08-25 16:36:07 +08:00
|
|
|
|
if kind_key == "new_customers": # 无 detail 参数(见其 docstring)
|
|
|
|
|
|
counts[kind_key], _ = await aggregate(session, wechat_id, start, end)
|
|
|
|
|
|
else:
|
|
|
|
|
|
counts[kind_key], _ = await aggregate(
|
|
|
|
|
|
session, wechat_id, start, end, detail=False
|
|
|
|
|
|
)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
counts["sensitive_words"] = 0 # 占位,spec 决策
|
|
|
|
|
|
return StaffStatsCountsOut(**counts)
|
|
|
|
|
|
|
2026-08-25 16:36:07 +08:00
|
|
|
|
if kind == "new_customers": # 同上:无 detail 参数
|
|
|
|
|
|
_, rows = await _AGGREGATORS[kind](session, wechat_id, start, end)
|
|
|
|
|
|
else:
|
|
|
|
|
|
_, rows = await _AGGREGATORS[kind](
|
|
|
|
|
|
session, wechat_id, start, end, detail=True
|
|
|
|
|
|
)
|
2026-08-25 16:22:44 +08:00
|
|
|
|
return StaffStatsDetailOut(kind=kind, rows=rows)
|