fix(customers): WA客户破1万上限(逐员工uid过滤,8585→11931全量)+双渠道页脚云客口径+消息同步刷新最近沟通时间

- wa-friends 逐员工抓取:全局查询~1万行硬上限(云客11931只翻到10000),
  uid=员工WA号过滤(真机验证 declared 1145→1149),total-aware翻页
- declared-total 分渠道:Σ wechat/whatsapp_account.contacts_num(8590/11915)
- 消息增量同步顺带刷新 customer.last_chat_time(云客列表按最近沟通实时
  排序,快照每日一次导致顺序滞后;微信1:1+WA都加)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-20 14:04:06 +08:00
parent fd3cf0003b
commit edca9b4c77
5 changed files with 187 additions and 62 deletions
+17 -6
View File
@@ -29,13 +29,16 @@ PageSizeParam = Annotated[int, Query(ge=1, le=100)]
@router.get("/customers/declared-total")
async def customers_declared_total(session: SessionDep) -> dict:
"""云客口径的微信客户合计 = Σ wechat_account.contacts_num。
"""云客口径的客户合计(分渠道)= Σ 员工账号表的 contacts_num 声明值。
云客列表的页脚数字含 ES 索引重复条目(实测 csf 员工声明 1999 vs 真实
distinct 1449,连行 id 都相同的重复 doc,2026-08-20);本地表存去重真实
客户,此端点把「与云客页脚一字不差」的声明口径给前端展示。
distinct 1449,连行 id 都相同的重复 doc,2026-08-20;WA 同理 11931 vs
8674);本地表存去重真实客户,此端点把「与云客页脚一字不差」的声明口径
给前端展示。
"""
row = (
from app.db.models import WhatsappAccount
wechat = (
await session.execute(
sa.select(
sa.func.coalesce(sa.func.sum(WechatAccount.contacts_num), 0),
@@ -43,9 +46,17 @@ async def customers_declared_total(session: SessionDep) -> dict:
)
)
).one()
whatsapp = (
await session.execute(
sa.select(
sa.func.coalesce(sa.func.sum(WhatsappAccount.contacts_num), 0),
sa.func.count(WhatsappAccount.contacts_num),
)
)
).one()
return {
"declared_total": int(row[0]),
"account_count": int(row[1]),
"wechat": {"declared_total": int(wechat[0]), "account_count": int(wechat[1])},
"whatsapp": {"declared_total": int(whatsapp[0]), "account_count": int(whatsapp[1])},
}
+139 -45
View File
@@ -339,35 +339,74 @@ async def scrape_friends(cookie_str: str, max_employees: int = 100):
async def scrape_groups(cookie_str: str, max_employees: int = 100):
"""微信群:浏览器直发 friend_list/es + friendTypeYunke=2 部门级全量翻页
(与好友共用端点,仅 body 开关不同;2026-08-20 起改 fetch)。"""
"""微信群:浏览器直发 friend_list/es,**逐员工**(wechatId 过滤)翻页。
与好友共用端点,仅 friendTypeYunke=2;部门级分页不稳(见 scrape_friends
三连修,2026-08-20),同模式逐员工 + total-aware 翻页。"""
import datetime as _dt
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.scraper.wechat import _normalize_chat_group
from app.storage.repos import upsert_chat_group
dept = await _root_dept_id()
base_body: dict = {"friendTypeYunke": "2"}
if dept:
base_body["departmentId"] = dept
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT wx_id FROM wechat_account WHERE wx_id IS NOT NULL AND wx_id != ''"
))
employees = [row[0] for row in result.fetchall()][:max_employees]
today = _dt.date.today().isoformat()
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["friend_list"],
base_body=base_body, parse_rows=_rows_basic_result,
)
for i, wx_id in enumerate(employees):
base_body = {
"ymdStart": "0000-00-00",
"ymdEnd": today,
"wechatId": wx_id,
"salesname": "",
"friendTypeYunke": "2",
"isDel": 0,
}
rows: list[dict] = []
declared: int | None = None
empty_streak = 0
page_num = 1
while page_num <= 200:
resp = await _browser_fetch(
page, INTERCEPT_PATHS["friend_list"],
json_body={**base_body, "pageNum": page_num, "pageSize": 50},
)
basic = ((resp.get("data") or {}).get("basicResult") or {})
declared = basic.get("total") or declared
page_rows = basic.get("data") or []
if not page_rows:
empty_streak += 1
if empty_streak >= 2 or (declared and len(rows) >= declared):
break
else:
empty_streak = 0
rows.extend(page_rows)
if declared and len(rows) >= declared:
break
page_num += 1
await asyncio.sleep(1)
records = []
for row in rows:
rec = _normalize_chat_group(row)
if rec.get("room_id"):
records.append(rec)
records = []
for row in rows:
rec = _normalize_chat_group(row)
if rec.get("room_id"):
records.append(rec)
total_fetched += len(records)
if records:
await _upsert_all(upsert_chat_group, records)
total_upserted += len(records)
if (i + 1) % 10 == 0:
logger.info(f" [{i+1}/{len(employees)}] employees done, groups total: {total_fetched}")
await asyncio.sleep(2 + (i % 3))
total_upserted = 0
for i in range(0, len(records), 500):
await _upsert_all(upsert_chat_group, records[i:i + 500])
total_upserted += len(records[i:i + 500])
total_fetched = len(records)
logger.info(f"DONE: groups fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
@@ -481,6 +520,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
data.hasLast (paginate_chats, same cursor strategy as the WA chatRecords
scraper).
"""
from sqlalchemy import text as sa_text
from app.db.base import async_session_maker
from app.storage.repos import upsert_messages
@@ -599,6 +640,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
if records:
async with async_session_maker() as session:
await upsert_messages(session, records)
# 时效:用本对话最新消息时间刷新 customer.last_chat_time
# (云客列表按最近沟通实时排序;friends 快照每日一次,消息增量每4h)
max_ts = max((r.get("timestamp") or 0) for r in records)
if max_ts:
await session.execute(sa_text(
"UPDATE customer SET last_chat_time = :ts "
"WHERE channel='wechat' AND owner_account_id = :o AND friend_id = :f "
"AND (last_chat_time IS NULL OR last_chat_time < :ts)"
), {"ts": max_ts, "o": owner_id, "f": friend_id})
await session.commit()
total_upserted += len(records)
@@ -1017,39 +1067,73 @@ async def scrape_wa_accounts(cookie_str: str, max_pages: int = 100):
async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
"""WA 客户:浏览器直发 businessFriendList type=0(与 WA 群共用端点,
2026-08-20 起改 fetch;注意此端点页码字段是 ``page`` 非 ``pageNum``)。"""
"""WA 客户:浏览器直发 businessFriendList type=0,**逐员工**(uid 过滤)翻页。
全局查询有 ~1 万行硬上限(实测云客声明 11931 vs 全局翻页只到 10000,
2026-08-20);逐员工 ``uid`` 过滤(员工 WA 号)范围小无上限,页码字段是
``page``,total-aware 翻页(短页继续,满声明数或连续空页停)。"""
from app.db.base import async_engine
from sqlalchemy import text as sa_text
from app.scraper.whatsapp import _normalize_whatsapp_friend
from app.storage.repos import upsert_customer
dept = await _root_dept_id()
base_body: dict = {
"searchInfo": "",
"friendSearchInfo": "",
"departmentId": dept,
"type": "0",
}
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT uid FROM whatsapp_account WHERE uid IS NOT NULL AND uid != ''"
))
accounts = [row[0] for row in result.fetchall()]
total_fetched = 0
total_upserted = 0
async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper)
rows = await _fetch_pages(
page, INTERCEPT_PATHS["wa_friend"],
base_body=base_body, parse_rows=_rows_overviews,
page_key="page", max_pages=max_pages,
)
for i, wa_uid in enumerate(accounts):
base_body = {
"searchInfo": "",
"friendSearchInfo": "",
"departmentId": dept,
"type": "0",
"uid": wa_uid,
}
rows: list[dict] = []
declared: int | None = None
empty_streak = 0
page_num = 1
while page_num <= max_pages:
resp = await _browser_fetch(
page, INTERCEPT_PATHS["wa_friend"],
json_body={**base_body, "page": page_num, "pageSize": 50},
)
data = resp.get("data") or {}
declared = data.get("totalCount") or declared
page_rows = data.get("overviews") or []
if not page_rows:
empty_streak += 1
if empty_streak >= 2 or (declared and len(rows) >= declared):
break
else:
empty_streak = 0
rows.extend(page_rows)
if declared and len(rows) >= declared:
break
page_num += 1
await asyncio.sleep(1)
records = []
for row in rows:
rec = _normalize_whatsapp_friend(row)
if rec.get("friend_id"):
records.append(rec)
records = []
for row in rows:
rec = _normalize_whatsapp_friend(row)
if rec.get("friend_id"):
records.append(rec)
total_fetched += len(records)
if records:
await _upsert_all(upsert_customer, records)
total_upserted += len(records)
logger.info(
f" [{i+1}/{len(accounts)}] {wa_uid[:18]}..: declared={declared} rows={len(rows)}"
)
await asyncio.sleep(2 + (i % 3))
total_upserted = 0
for i in range(0, len(records), 500):
await _upsert_all(upsert_customer, records[i:i + 500])
total_upserted += len(records[i:i + 500])
total_fetched = len(records)
logger.info(f"DONE: wa-friends fetched={total_fetched}, upserted={total_upserted}")
return total_fetched, total_upserted
@@ -1347,6 +1431,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100):
(same cursor strategy as the WeChat msg/chats scraper).
"""
import time as _time
from sqlalchemy import text as sa_text
from app.db.base import async_session_maker
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages
@@ -1410,6 +1496,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100):
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
async with async_session_maker() as session:
await upsert_messages(session, records)
# 时效:刷新 customer.last_chat_time(同微信 1:1,云客按最近沟通实时排序)
max_ts = max((r.get("timestamp") or 0) for r in records)
if max_ts:
await session.execute(sa_text(
"UPDATE customer SET last_chat_time = :ts "
"WHERE channel='whatsapp' AND owner_account_id = :o AND friend_id = :f "
"AND (last_chat_time IS NULL OR last_chat_time < :ts)"
), {"ts": max_ts, "o": uid, "f": friend_uid})
await session.commit()
total_upserted += len(records)
+16 -4
View File
@@ -16,10 +16,22 @@ async def _seed_accounts_for_declared(session):
async def test_declared_total_sums_contacts_num(make_client):
"""declared_total = Σ wechat_account.contacts_num(云客声明的含重复口径)。"""
client = await make_client(_seed_accounts_for_declared)
"""分渠道 declared_total = Σ 员工账号表 contacts_num(云客含重复口径)。"""
from app.db.models import WhatsappAccount
async def seed_both(session):
await _seed_accounts_for_declared(session)
session.add_all([
WhatsappAccount(uid="a1@s.whatsapp.net", contacts_num=10),
WhatsappAccount(uid="a2@s.whatsapp.net", contacts_num=5),
])
await session.commit()
client = await make_client(seed_both)
r = await client.get("/api/customers/declared-total")
assert r.status_code == 200
body = r.json()
assert body["declared_total"] == 123
assert body["account_count"] == 2 # 只计有值的员工
assert body["wechat"]["declared_total"] == 123
assert body["wechat"]["account_count"] == 2 # 只计有值的员工
assert body["whatsapp"]["declared_total"] == 15
assert body["whatsapp"]["account_count"] == 2
+7 -2
View File
@@ -48,8 +48,13 @@ export interface CustomerQuery {
last_chat_since?: number
}
export function getCustomerDeclaredTotal(): Promise<{ declared_total: number; account_count: number }> {
return getJson<{ declared_total: number; account_count: number }>('/customers/declared-total')
export interface DeclaredTotals {
wechat: { declared_total: number; account_count: number }
whatsapp: { declared_total: number; account_count: number }
}
export function getCustomerDeclaredTotal(): Promise<DeclaredTotals> {
return getJson<DeclaredTotals>('/customers/declared-total')
}
export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>> {
+8 -5
View File
@@ -39,15 +39,18 @@ function freshTabState(): TabState {
return { items: [], total: 0, page: 1, pageSize: 10, loading: false, errorMsg: null, loaded: false }
}
/** 云客口径微信客户合计(Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */
const declaredTotal = ref<number | null>(null)
/** 云客口径客户合计(分渠道,Σ员工好友数声明值,含云客索引重复;页脚对齐云客)。 */
const declaredTotals = ref<{ wechat: number | null; whatsapp: number | null }>({ wechat: null, whatsapp: null })
async function loadDeclaredTotal(): Promise<void> {
try {
const r = await getCustomerDeclaredTotal()
declaredTotal.value = r.declared_total
declaredTotals.value = {
wechat: r.wechat.declared_total,
whatsapp: r.whatsapp.declared_total,
}
} catch {
declaredTotal.value = null // 回退真实 total
declaredTotals.value = { wechat: null, whatsapp: null } // 回退真实 total
}
}
@@ -368,7 +371,7 @@ onMounted(() => {
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
<span class="yk-customers__sub">
共 {{ activeTab === 'wechat' && declaredTotal !== null ? declaredTotal : tabState[activeTab].total }} 条
共 {{ declaredTotals[activeTab] !== null ? declaredTotals[activeTab] : tabState[activeTab].total }} 条
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
</span>