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:
@@ -29,13 +29,16 @@ PageSizeParam = Annotated[int, Query(ge=1, le=100)]
|
|||||||
|
|
||||||
@router.get("/customers/declared-total")
|
@router.get("/customers/declared-total")
|
||||||
async def customers_declared_total(session: SessionDep) -> dict:
|
async def customers_declared_total(session: SessionDep) -> dict:
|
||||||
"""云客口径的微信客户合计 = Σ wechat_account.contacts_num。
|
"""云客口径的客户合计(分渠道)= Σ 员工账号表的 contacts_num 声明值。
|
||||||
|
|
||||||
云客列表的页脚数字含 ES 索引重复条目(实测 csf 员工声明 1999 vs 真实
|
云客列表的页脚数字含 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(
|
await session.execute(
|
||||||
sa.select(
|
sa.select(
|
||||||
sa.func.coalesce(sa.func.sum(WechatAccount.contacts_num), 0),
|
sa.func.coalesce(sa.func.sum(WechatAccount.contacts_num), 0),
|
||||||
@@ -43,9 +46,17 @@ async def customers_declared_total(session: SessionDep) -> dict:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
).one()
|
).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 {
|
return {
|
||||||
"declared_total": int(row[0]),
|
"wechat": {"declared_total": int(wechat[0]), "account_count": int(wechat[1])},
|
||||||
"account_count": int(row[1]),
|
"whatsapp": {"declared_total": int(whatsapp[0]), "account_count": int(whatsapp[1])},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
async def scrape_groups(cookie_str: str, max_employees: int = 100):
|
||||||
"""微信群:浏览器直发 friend_list/es + friendTypeYunke=2 部门级全量翻页
|
"""微信群:浏览器直发 friend_list/es,**逐员工**(wechatId 过滤)翻页。
|
||||||
(与好友共用端点,仅 body 开关不同;2026-08-20 起改 fetch)。"""
|
|
||||||
|
与好友共用端点,仅 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.scraper.wechat import _normalize_chat_group
|
||||||
from app.storage.repos import upsert_chat_group
|
from app.storage.repos import upsert_chat_group
|
||||||
|
|
||||||
dept = await _root_dept_id()
|
async with async_engine.connect() as conn:
|
||||||
base_body: dict = {"friendTypeYunke": "2"}
|
result = await conn.execute(sa_text(
|
||||||
if dept:
|
"SELECT wx_id FROM wechat_account WHERE wx_id IS NOT NULL AND wx_id != ''"
|
||||||
base_body["departmentId"] = dept
|
))
|
||||||
|
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:
|
async with PageScraper(cookie_str) as scraper:
|
||||||
page = await _open_fetch_page(scraper)
|
page = await _open_fetch_page(scraper)
|
||||||
rows = await _fetch_pages(
|
for i, wx_id in enumerate(employees):
|
||||||
page, INTERCEPT_PATHS["friend_list"],
|
base_body = {
|
||||||
base_body=base_body, parse_rows=_rows_basic_result,
|
"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 = []
|
records = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
rec = _normalize_chat_group(row)
|
rec = _normalize_chat_group(row)
|
||||||
if rec.get("room_id"):
|
if rec.get("room_id"):
|
||||||
records.append(rec)
|
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}")
|
logger.info(f"DONE: groups fetched={total_fetched}, upserted={total_upserted}")
|
||||||
return total_fetched, 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
|
data.hasLast (paginate_chats, same cursor strategy as the WA chatRecords
|
||||||
scraper).
|
scraper).
|
||||||
"""
|
"""
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
from app.db.base import async_session_maker
|
from app.db.base import async_session_maker
|
||||||
from app.storage.repos import upsert_messages
|
from app.storage.repos import upsert_messages
|
||||||
|
|
||||||
@@ -599,6 +640,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100):
|
|||||||
if records:
|
if records:
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
await upsert_messages(session, records)
|
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()
|
await session.commit()
|
||||||
total_upserted += len(records)
|
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):
|
async def scrape_wa_friends(cookie_str: str, max_pages: int = 2000):
|
||||||
"""WA 客户:浏览器直发 businessFriendList type=0(与 WA 群共用端点,
|
"""WA 客户:浏览器直发 businessFriendList type=0,**逐员工**(uid 过滤)翻页。
|
||||||
2026-08-20 起改 fetch;注意此端点页码字段是 ``page`` 非 ``pageNum``)。"""
|
|
||||||
|
全局查询有 ~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.scraper.whatsapp import _normalize_whatsapp_friend
|
||||||
from app.storage.repos import upsert_customer
|
from app.storage.repos import upsert_customer
|
||||||
|
|
||||||
dept = await _root_dept_id()
|
dept = await _root_dept_id()
|
||||||
base_body: dict = {
|
async with async_engine.connect() as conn:
|
||||||
"searchInfo": "",
|
result = await conn.execute(sa_text(
|
||||||
"friendSearchInfo": "",
|
"SELECT uid FROM whatsapp_account WHERE uid IS NOT NULL AND uid != ''"
|
||||||
"departmentId": dept,
|
))
|
||||||
"type": "0",
|
accounts = [row[0] for row in result.fetchall()]
|
||||||
}
|
|
||||||
|
|
||||||
|
total_fetched = 0
|
||||||
|
total_upserted = 0
|
||||||
async with PageScraper(cookie_str) as scraper:
|
async with PageScraper(cookie_str) as scraper:
|
||||||
page = await _open_fetch_page(scraper)
|
page = await _open_fetch_page(scraper)
|
||||||
rows = await _fetch_pages(
|
for i, wa_uid in enumerate(accounts):
|
||||||
page, INTERCEPT_PATHS["wa_friend"],
|
base_body = {
|
||||||
base_body=base_body, parse_rows=_rows_overviews,
|
"searchInfo": "",
|
||||||
page_key="page", max_pages=max_pages,
|
"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 = []
|
records = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
rec = _normalize_whatsapp_friend(row)
|
rec = _normalize_whatsapp_friend(row)
|
||||||
if rec.get("friend_id"):
|
if rec.get("friend_id"):
|
||||||
records.append(rec)
|
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}")
|
logger.info(f"DONE: wa-friends fetched={total_fetched}, upserted={total_upserted}")
|
||||||
return total_fetched, 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).
|
(same cursor strategy as the WeChat msg/chats scraper).
|
||||||
"""
|
"""
|
||||||
import time as _time
|
import time as _time
|
||||||
|
from sqlalchemy import text as sa_text
|
||||||
|
|
||||||
from app.db.base import async_session_maker
|
from app.db.base import async_session_maker
|
||||||
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
|
||||||
from app.storage.repos import upsert_messages
|
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 阶段首条即崩的根因)
|
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
|
||||||
async with async_session_maker() as session:
|
async with async_session_maker() as session:
|
||||||
await upsert_messages(session, records)
|
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()
|
await session.commit()
|
||||||
total_upserted += len(records)
|
total_upserted += len(records)
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,22 @@ async def _seed_accounts_for_declared(session):
|
|||||||
|
|
||||||
|
|
||||||
async def test_declared_total_sums_contacts_num(make_client):
|
async def test_declared_total_sums_contacts_num(make_client):
|
||||||
"""declared_total = Σ wechat_account.contacts_num(云客声明的含重复口径)。"""
|
"""分渠道 declared_total = Σ 员工账号表 contacts_num(云客含重复口径)。"""
|
||||||
client = await make_client(_seed_accounts_for_declared)
|
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")
|
r = await client.get("/api/customers/declared-total")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
assert body["declared_total"] == 123
|
assert body["wechat"]["declared_total"] == 123
|
||||||
assert body["account_count"] == 2 # 只计有值的员工
|
assert body["wechat"]["account_count"] == 2 # 只计有值的员工
|
||||||
|
assert body["whatsapp"]["declared_total"] == 15
|
||||||
|
assert body["whatsapp"]["account_count"] == 2
|
||||||
|
|||||||
@@ -48,8 +48,13 @@ export interface CustomerQuery {
|
|||||||
last_chat_since?: number
|
last_chat_since?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCustomerDeclaredTotal(): Promise<{ declared_total: number; account_count: number }> {
|
export interface DeclaredTotals {
|
||||||
return getJson<{ declared_total: number; account_count: number }>('/customers/declared-total')
|
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>> {
|
export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>> {
|
||||||
|
|||||||
@@ -39,15 +39,18 @@ function freshTabState(): TabState {
|
|||||||
return { items: [], total: 0, page: 1, pageSize: 10, loading: false, errorMsg: null, loaded: false }
|
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> {
|
async function loadDeclaredTotal(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const r = await getCustomerDeclaredTotal()
|
const r = await getCustomerDeclaredTotal()
|
||||||
declaredTotal.value = r.declared_total
|
declaredTotals.value = {
|
||||||
|
wechat: r.wechat.declared_total,
|
||||||
|
whatsapp: r.whatsapp.declared_total,
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
declaredTotal.value = null // 回退真实 total
|
declaredTotals.value = { wechat: null, whatsapp: null } // 回退真实 total
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +371,7 @@ onMounted(() => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
|
<h2 style="margin: 0 0 4px; font-size: 16px">{{ activeTab === 'wechat' ? '微信' : 'WhatsApp' }}客户列表</h2>
|
||||||
<span class="yk-customers__sub">
|
<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="departmentName"> · 部门:{{ departmentName }}</template>
|
||||||
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
|
<template v-if="ownerId"> · 员工账号:{{ ownerId }}</template>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user