fix: wechat account scrape (form-data+dates) + frontend staff page

Backend (scrape_wechat_accounts):
- Send wxUser/list as form-urlencoded (not JSON) with the REQUIRED filters
  isOnJob / departmentId / startdate / enddate + empty userId/salesname/
  weChatNickName, matching the live request (empty fails with code -90000).
- scrape_wechat_accounts now takes department_id (the company-root dept id)
  plus optional start/end defaulting to a recent 7-day UTC window.
- paginate_by_page gains a form=True mode (data= + form content-type).
- Orchestrator sync_wechat_accounts resolves the root dept from the
  Department table (or accepts one) and skips with a warning when unknown.
- seed_accounts.py resolves the root via scrape_departments.

Frontend (yunkes):
- Add StaffList.vue (/staff) with is_on_job filter + pagination.
- Add /staff route and a 员工 nav item between 客户 and 群.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-01 17:11:17 +08:00
parent 4dcfc66741
commit 0df53c6b1a
10 changed files with 638 additions and 30 deletions
+36 -2
View File
@@ -25,6 +25,13 @@ over the response shape via three callables:
* ``build_body(page_num, page_size, **extra) -> dict`` — build the request * ``build_body(page_num, page_size, **extra) -> dict`` — build the request
body for a given page. body for a given page.
By default the body is sent as JSON. A few yunke endpoints
(e.g. ``msg/chats``, ``wxUser/list``) bind ``@RequestParam`` from
**form-urlencoded** bodies instead — pass ``form=True`` to switch the paginator
to ``data=<body>`` with content-type
``application/x-www-form-urlencoded`` (mirroring the form-encoded
``msg/chats`` request the message scraper already makes directly).
``paginate_by_cursor`` is decoupled from the client entirely: it takes a ``paginate_by_cursor`` is decoupled from the client entirely: it takes a
single ``fetch(cursor)`` coroutine that performs whatever request the caller single ``fetch(cursor)`` coroutine that performs whatever request the caller
needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's needs and returns ``(rows, next_cursor, has_more)``. The cursor-paginator's
@@ -45,7 +52,17 @@ if TYPE_CHECKING:
# :class:`RateLimiter` at runtime without forming an import cycle. # :class:`RateLimiter` at runtime without forming an import cycle.
from app.session.yunke_client import YunkeClient from app.session.yunke_client import YunkeClient
__all__ = ["RateLimiter", "paginate_by_cursor", "paginate_by_page"] __all__ = [
"FORM_CONTENT_TYPE",
"RateLimiter",
"paginate_by_cursor",
"paginate_by_page",
]
# Content type for form-encoded bodies. Several yunke endpoints (msg/chats,
# wxUser/list) bind @RequestParam from form data rather than JSON, so the
# paginator + the manual message fetcher both send this content-type.
FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"
# Informal type aliases for the response-handling callables. The yunke # Informal type aliases for the response-handling callables. The yunke
# response shapes are dict-shaped JSON and do not benefit from finer typing. # response shapes are dict-shaped JSON and do not benefit from finer typing.
@@ -65,6 +82,7 @@ async def paginate_by_page(
parse_rows: ParseRows, parse_rows: ParseRows,
build_body: BuildBody, build_body: BuildBody,
rate_limiter: RateLimiter | None = None, rate_limiter: RateLimiter | None = None,
form: bool = False,
**extra: Any, **extra: Any,
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
"""Walk a page-numbered list endpoint, yielding one dict per record. """Walk a page-numbered list endpoint, yielding one dict per record.
@@ -85,6 +103,15 @@ async def paginate_by_page(
``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is ``rate_limiter`` is an optional :class:`RateLimiter`; when supplied, it is
acquired before every upstream call. acquired before every upstream call.
``form`` selects the wire encoding of ``build_body``'s output. The default
(``False``) sends the body as JSON (``client.post(path, json=body)``) —
correct for most yunke list endpoints. Pass ``True`` for endpoints that
bind ``@RequestParam`` from a form-urlencoded body (``msg/chats``,
``wxUser/list``): the paginator then sends
``client.post(path, data=body, headers={"content-type": ...})`` and lets
httpx URL-encode the dict. Either way ``build_body`` returns the same
plain dict of field names → values.
""" """
page_num = 1 page_num = 1
total: int | None = None total: int | None = None
@@ -95,7 +122,14 @@ async def paginate_by_page(
await rate_limiter.acquire() await rate_limiter.acquire()
body = build_body(page_num, page_size, **extra) body = build_body(page_num, page_size, **extra)
resp = await client.post(path, json=body) if form:
resp = await client.post(
path,
data=body,
headers={"content-type": FORM_CONTENT_TYPE},
)
else:
resp = await client.post(path, json=body)
# Read the declared total once from the first response. The yunke # Read the declared total once from the first response. The yunke
# envelopes keep it on every page, but reading it once is enough for # envelopes keep it on every page, but reading it once is enough for
+88 -7
View File
@@ -13,8 +13,12 @@ Endpoints covered today:
``path`` is reconstructed from parent links (Task 2.0). ``path`` is reconstructed from parent links (Task 2.0).
* :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and * :func:`scrape_wechat_accounts` — wraps ``POST /wechatweb/wxUser/list`` and
yields normalized :class:`app.db.models.WechatAccount` dicts (plus the yields normalized :class:`app.db.models.WechatAccount` dicts (plus the
embedded ``userId``/``userName`` staff pair). Page-number pagination embedded ``userId``/``userName`` staff pair). The endpoint binds
(Task 2.1). ``@RequestParam`` from a **form-urlencoded** body (NOT JSON) and REQUIRES
``isOnJob`` / ``departmentId`` (the company-root dept id) / ``startdate`` /
``enddate`` or it answers ``code -90000`` "缺少..."; a recent ~7-day
window works (the date affects per-account stats, not which accounts are
returned). Page-number pagination (Task 2.1).
* :func:`scrape_wechat_groups` — wraps ``POST /wechatweb/chatroom/getChatroomList`` * :func:`scrape_wechat_groups` — wraps ``POST /wechatweb/chatroom/getChatroomList``
and yields normalized :class:`app.db.models.ChatGroup` dicts, parsing the and yields normalized :class:`app.db.models.ChatGroup` dicts, parsing the
semicolon-joined ``memberIds`` into a ``member_ids_list`` so the semicolon-joined ``memberIds`` into a ``member_ids_list`` so the
@@ -58,6 +62,7 @@ disambiguations where the source has two fields for one column:
from __future__ import annotations from __future__ import annotations
import datetime as dt
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import Any from typing import Any
@@ -660,13 +665,57 @@ def _parse_wxuser_rows(resp: Any) -> list[dict[str, Any]]:
return rows if isinstance(rows, list) else [] return rows if isinstance(rows, list) else []
def _build_wxuser_body(page_num: int, page_size: int) -> dict[str, Any]: def _build_wxuser_body(
"""Build the ``wxUser/list`` request body for a given page. page_num: int,
page_size: int,
*,
department_id: str,
is_on_job: str = "1",
start: str,
end: str,
) -> dict[str, Any]:
"""Build the form-urlencoded ``wxUser/list`` body for a given page.
Unlike ``friend_list/es``, the ``wxUser/list`` body carries no Phase-0 live probing established the real contract: the endpoint binds
staff/dept filter parameters in the live capture — just the page slice. Spring ``@RequestParam`` from a form body (NOT JSON) and REQUIRES the
filters below or it answers ``code -90000`` ("缺少..."). Empty
``userId`` / ``salesname`` / ``weChatNickName`` are sent explicitly because
the real client does, and the upstream treats missing keys differently
from empty strings for some filters.
""" """
return {"pageNum": page_num, "pageSize": page_size} return {
"pageNum": page_num,
"pageSize": page_size,
# REQUIRED — empty fails with code -90000. "1" => on-job accounts only
# (the 55 live rows). "0" would pull departed staff instead.
"isOnJob": is_on_job,
# REQUIRED — the company ROOT dept id (caller obtains it from
# scrape_departments). NOT hardcoded here.
"departmentId": department_id,
# REQUIRED — "缺少时间参数" if absent. YYYY-MM-DD strings. The window
# affects per-account stats, NOT which accounts are returned.
"startdate": start,
"enddate": end,
# Always-empty free-text filters (sent to mirror the real client).
"userId": "",
"salesname": "",
"weChatNickName": "",
}
def _default_date_window(now: dt.datetime | None = None) -> tuple[str, str]:
"""A recent 7-day window as ``(start, end)`` ``YYYY-MM-DD`` strings.
The ``wxUser/list`` stats window is cosmetic (it affects per-account
counters, not which accounts are returned), so any recent window works.
We default to the last 7 days measured from ``now`` (UTC by default to
stay deterministic and match the rest of the scraper layer's naive-UTC
convention). Both bounds are inclusive calendar days.
"""
now = now or dt.datetime.now(dt.UTC)
end_date = now.date()
start_date = end_date - dt.timedelta(days=7)
return start_date.isoformat(), end_date.isoformat()
def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]: def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
@@ -702,7 +751,11 @@ def _normalize_wxuser_account(record: dict[str, Any]) -> dict[str, Any]:
async def scrape_wechat_accounts( async def scrape_wechat_accounts(
client: YunkeClient, client: YunkeClient,
*, *,
department_id: str,
page_size: int = 50, page_size: int = 50,
is_on_job: str = "1",
start: str | None = None,
end: str | None = None,
) -> AsyncIterator[dict[str, Any]]: ) -> AsyncIterator[dict[str, Any]]:
"""Yield normalized WeChat staff-account records from ``wxUser/list``. """Yield normalized WeChat staff-account records from ``wxUser/list``.
@@ -711,6 +764,13 @@ async def scrape_wechat_accounts(
mapping each record to the ``WechatAccount`` column shape plus a verbatim mapping each record to the ``WechatAccount`` column shape plus a verbatim
``raw_json`` copy. ``raw_json`` copy.
The request is **form-urlencoded** (NOT JSON): the endpoint binds Spring
``@RequestParam`` from the form body and REQUIRES ``isOnJob`` /
``departmentId`` / ``startdate`` / ``enddate``. ``department_id`` is the
company-root dept id (the caller resolves it from
:func:`scrape_departments`); it is NOT hardcoded here so this scraper
stays account-agnostic.
The envelope differs from ``friend_list/es``: there is **no** The envelope differs from ``friend_list/es``: there is **no**
``basicResult`` wrapper. The pagination fields sit directly under ``data`` ``basicResult`` wrapper. The pagination fields sit directly under ``data``
(``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``). (``data.data`` / ``data.total`` / ``data.pageNum`` / ``data.pageSize``).
@@ -720,10 +780,26 @@ async def scrape_wechat_accounts(
client: client:
Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are Authenticated :class:`YunkeClient`. Auth headers / 401 re-login are
handled by the client. handled by the client.
department_id:
Company-root department id (REQUIRED upstream filter). The caller
obtains it from :func:`scrape_departments` (the root node whose
``parent_id`` is falsy).
page_size: page_size:
Page size to request. Defaults to 50 (matches the live capture's Page size to request. Defaults to 50 (matches the live capture's
observed page size for this endpoint). observed page size for this endpoint).
is_on_job:
``"1"`` (default) for on-job staff, ``"0"`` for departed. Required
upstream filter — empty fails with ``code -90000``.
start, end:
Optional ``YYYY-MM-DD`` bounds for the per-account stats window. The
date affects per-account counters, NOT which accounts are returned
(live total is 55 regardless), so any recent window works. Defaults
to a recent 7-day window computed from ``datetime.now(UTC)``.
""" """
default_start, default_end = _default_date_window()
window_start = start or default_start
window_end = end or default_end
async for record in paginate_by_page( async for record in paginate_by_page(
client, client,
WXUSER_LIST_PATH, WXUSER_LIST_PATH,
@@ -731,6 +807,11 @@ async def scrape_wechat_accounts(
parse_total=_parse_wxuser_total, parse_total=_parse_wxuser_total,
parse_rows=_parse_wxuser_rows, parse_rows=_parse_wxuser_rows,
build_body=_build_wxuser_body, build_body=_build_wxuser_body,
form=True,
department_id=department_id,
is_on_job=is_on_job,
start=window_start,
end=window_end,
): ):
yield _normalize_wxuser_account(record) yield _normalize_wxuser_account(record)
+61 -4
View File
@@ -43,9 +43,10 @@ import logging
from collections.abc import AsyncIterator, Callable from collections.abc import AsyncIterator, Callable
from typing import Any from typing import Any
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import SyncRun from app.db.models import Department, SyncRun
from app.scraper.call import scrape_phone_calls, scrape_sms from app.scraper.call import scrape_phone_calls, scrape_sms
from app.scraper.media import download_message_media from app.scraper.media import download_message_media
from app.scraper.wechat import ( from app.scraper.wechat import (
@@ -385,16 +386,72 @@ async def sync_departments(
) )
async def _find_root_department_id(
session: AsyncSession,
) -> str | None:
"""Resolve the company-root department id from the mirrored ``Department`` table.
The ``wxUser/list`` form body REQUIRES ``departmentId`` (the company-root
dept id) — empty fails with ``code -90000``. The root is a node whose
``parent_id`` is falsy (``None`` / ``""`` / ``"0"`` — the yunke tree
convention). If several roots exist, the first by ``dept_id`` is returned
(with a warning) so a multi-root org still scrapes. Returns ``None`` when
no root is present so the caller can skip gracefully rather than send a
known-bad empty filter.
"""
stmt = (
sa.select(Department)
.where(
sa.or_(
Department.parent_id.is_(None),
Department.parent_id == "",
Department.parent_id == "0",
)
)
.order_by(Department.dept_id.asc())
.limit(1)
)
row = (await session.execute(stmt)).scalars().first()
return row.dept_id if row is not None else None
async def sync_wechat_accounts( async def sync_wechat_accounts(
session: AsyncSession, client: YunkeClient session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> SyncResult: ) -> SyncResult:
"""Full re-scrape of all WeChat staff accounts (+ embedded staff rows).""" """Full re-scrape of all WeChat staff accounts (+ embedded staff rows).
``department_id`` is the company-root dept id required by the
``wxUser/list`` form body. When omitted, the orchestrator resolves it from
the mirrored ``Department`` table (a root node whose ``parent_id`` is
falsy) — which is why :func:`sync_departments` must run first. If no root
can be resolved, the scrape is SKIPPED with a logged warning (returns a
``status='skipped'`` :class:`SyncResult` and records no ``SyncRun``) so a
missing org tree does not abort a multi-entity batch.
"""
resolved = department_id
if resolved is None:
resolved = await _find_root_department_id(session)
if not resolved:
logger.warning(
"sync_wechat_accounts: no root department_id resolved"
" (sync departments first?); skipping wxUser/list scrape"
)
return SyncResult(
entity=ENTITY_WECHAT_ACCOUNT,
status="skipped",
fetched=0,
upserted=0,
error="no root department_id available",
)
return await _sync_full_rescrape( return await _sync_full_rescrape(
session, session,
entity_type=ENTITY_WECHAT_ACCOUNT, entity_type=ENTITY_WECHAT_ACCOUNT,
channel=None, channel=None,
scope_id=None, scope_id=None,
scrape=lambda: scrape_wechat_accounts(client), scrape=lambda: scrape_wechat_accounts(client, department_id=resolved),
upsert=upsert_wechat_account, upsert=upsert_wechat_account,
) )
+53
View File
@@ -0,0 +1,53 @@
"""Seed WeChat + WhatsApp accounts (and their embedded staff) into smoke.db."""
import asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db import models # noqa: F401
from app.scraper.wechat import scrape_departments, scrape_wechat_accounts
from app.scraper.whatsapp import scrape_whatsapp_accounts
from app.storage.repos import upsert_wechat_account, upsert_whatsapp_account
from scripts.smoke_wechat import _push_settings_into_environ, build_client_from_env
async def _root_department_id(client) -> str | None:
"""First root department (parent_id falsy) from the live dept tree.
``wxUser/list`` REQUIRES the company-root ``departmentId`` filter; we pull
it from ``scrape_departments`` rather than hardcoding the live id.
"""
async for d in scrape_departments(client):
if not d.get("parent_id"):
return d.get("dept_id")
return None
async def main() -> None:
_push_settings_into_environ()
client = build_client_from_env()
engine = create_async_engine("sqlite+aiosqlite:///./smoke.db")
sm = async_sessionmaker(engine, expire_on_commit=False)
root_dept = await _root_department_id(client)
if not root_dept:
print("WARNING: no root department found; skipping wxUser/list scrape")
wx_n = wa_n = 0
async with sm() as session:
if root_dept:
async for rec in scrape_wechat_accounts(
client, department_id=root_dept, page_size=50
):
await upsert_wechat_account(session, rec)
wx_n += 1
async for rec in scrape_whatsapp_accounts(client, page_size=50):
await upsert_whatsapp_account(session, rec)
wa_n += 1
await session.commit()
print(f"upserted {wx_n} wechat accounts, {wa_n} whatsapp accounts (+ their staff)")
await client.aclose()
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())
@@ -5,8 +5,15 @@ data dictionary §2: ``wechatId, nickName, alias, phone, userId,
userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob, userName(员工真名), contactsNum, groupNum, sendNum/receiveNum, isOnJob,
dbSize, 在线状态, lastUpdate``; total=55). dbSize, 在线状态, lastUpdate``; total=55).
The envelope differs from ``wxFriends/friend_list/es``: there is **no** The REQUEST is **form-urlencoded** (NOT JSON): the endpoint binds Spring
``basicResult`` wrapper. The pagination fields sit directly under ``data``:: ``@RequestParam`` from the form body and REQUIRES ``isOnJob`` /
``departmentId`` (the company-root dept id) / ``startdate`` / ``enddate`` or
it answers ``code -90000`` ("缺少..."). ``userId`` / ``salesname`` /
``weChatNickName`` are sent empty to mirror the real client.
The RESPONSE envelope differs from ``wxFriends/friend_list/es``: there is
**no** ``basicResult`` wrapper. The pagination fields sit directly under
``data``::
{"code":"10000","data":{"data":[...], "total":.., "pageNum":.., "pageSize":..}} {"code":"10000","data":{"data":[...], "total":.., "pageNum":.., "pageSize":..}}
+93 -2
View File
@@ -29,7 +29,7 @@ import sqlalchemy as sa
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.db.base import Base from app.db.base import Base
from app.db.models import SyncRun, SyncState from app.db.models import Department, SyncRun, SyncState
from app.sync import orchestrator from app.sync import orchestrator
from app.sync.watermarks import get_last_timestamp from app.sync.watermarks import get_last_timestamp
@@ -477,7 +477,7 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
try: try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False) SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def empty_async_gen(_client=None): async def empty_async_gen(_client=None, **_kwargs):
if False: # pragma: no cover if False: # pragma: no cover
yield {} yield {}
@@ -496,6 +496,11 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
# orchestrator namespace) are never called. No need to patch them. # orchestrator namespace) are never called. No need to patch them.
async with SessionLocal() as session: async with SessionLocal() as session:
# sync_wechat_accounts resolves the company-root dept id required
# by the wxUser/list form body from the Department table, so seed
# a root (parent_id=None) before running the coordinator.
session.add(Department(dept_id="ROOT_DEPT", parent_id=None, name="Root"))
await session.flush()
results = await orchestrator.sync_all_wechat( results = await orchestrator.sync_all_wechat(
session, session,
client=object(), client=object(),
@@ -520,6 +525,92 @@ async def test_sync_all_wechat_runs_each_entity_then_messages(monkeypatch):
await engine.dispose() await engine.dispose()
async def test_sync_wechat_accounts_skips_when_no_root_department(monkeypatch):
"""``wxUser/list`` REQUIRES a ``departmentId`` filter. When the org tree is
empty (no root resolvable from the Department table) the orchestrator must
NOT send a known-bad empty filter; it skips with a logged warning and
returns a ``status='skipped'`` result without ever calling the scraper."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
called = False
async def fake_scrape_accounts(client, *, department_id, **_kwargs):
nonlocal called
called = True
if False: # pragma: no cover
yield {}
monkeypatch.setattr(
orchestrator, "scrape_wechat_accounts", fake_scrape_accounts
)
async with SessionLocal() as session:
result = await orchestrator.sync_wechat_accounts(
session, client=object()
)
await session.commit()
assert result.status == "skipped"
assert result.fetched == 0
assert result.upserted == 0
assert not called
# No SyncRun recorded for a skip (it is not a success or a failure).
async with SessionLocal() as session:
rows = (
await session.execute(
sa.select(SyncRun).where(
SyncRun.entity_type == "wechat_account"
)
)
).scalars().all()
assert rows == []
finally:
await engine.dispose()
async def test_sync_wechat_accounts_passes_resolved_root_to_scraper(monkeypatch):
"""When a root department exists in the DB, the orchestrator resolves its
id and passes it as ``department_id`` to the scraper."""
engine = await _make_engine()
try:
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
captured: dict = {}
async def fake_scrape_accounts(client, *, department_id, **_kwargs):
captured["department_id"] = department_id
if False: # pragma: no cover
yield {}
async def fake_upsert(session, rec):
return None
monkeypatch.setattr(
orchestrator, "scrape_wechat_accounts", fake_scrape_accounts
)
monkeypatch.setattr(orchestrator, "upsert_wechat_account", fake_upsert)
async with SessionLocal() as session:
session.add(
Department(
dept_id="ROOT_DEPT", parent_id=None, name="Root"
)
)
await session.flush()
result = await orchestrator.sync_wechat_accounts(
session, client=object()
)
await session.commit()
assert result.status == "success"
assert captured["department_id"] == "ROOT_DEPT"
finally:
await engine.dispose()
# =========================================================================== # ===========================================================================
# Phone calls + SMS — full re-scrape entities (M4) # Phone calls + SMS — full re-scrape entities (M4)
# =========================================================================== # ===========================================================================
@@ -16,15 +16,20 @@ snake_case columns + ``raw_json``), make exactly 3 calls, and stop without a
4th. The empty-page stop is the primary signal; ``total=4`` is only a 4th. The empty-page stop is the primary signal; ``total=4`` is only a
defensive over-fetch ceiling that must NOT trigger early (we yield exactly defensive over-fetch ceiling that must NOT trigger early (we yield exactly
4 == total, so the strict-``>`` ceiling never fires here). 4 == total, so the strict-``>`` ceiling never fires here).
The REQUEST body is form-urlencoded (NOT JSON) and must carry the required
``isOnJob`` / ``departmentId`` / ``startdate`` / ``enddate`` filters; the
"send pagination body" test asserts those keys land on the wire.
""" """
from __future__ import annotations from __future__ import annotations
import json as _json from urllib.parse import parse_qs
import httpx import httpx
import respx import respx
from app.scraper.base import FORM_CONTENT_TYPE
from app.scraper.wechat import scrape_wechat_accounts from app.scraper.wechat import scrape_wechat_accounts
from app.session.yunke_client import YunkeClient from app.session.yunke_client import YunkeClient
from tests.cassettes.wxUser_fixture import ( from tests.cassettes.wxUser_fixture import (
@@ -40,12 +45,30 @@ WXUSER_LIST_URL = f"{BASE}/wechatweb/wxUser/list"
USERID = "a" * 32 USERID = "a" * 32
COMPANY = "8m0atd" COMPANY = "8m0atd"
# The company-root department id used as the required ``departmentId`` filter.
# Mirrors the shape of the real root dept id (32 hex chars) without hardcoding
# the live value in the scraper itself.
ROOT_DEPARTMENT_ID = "0E9C4B803E134C7687CBA824886319DA"
def _primed_client() -> YunkeClient: def _primed_client() -> YunkeClient:
"""A YunkeClient already in a logged-in state (login flow skipped).""" """A YunkeClient already in a logged-in state (login flow skipped)."""
return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY) return YunkeClient(base_url=BASE, userid=USERID, company=COMPANY)
def _parse_form(request: httpx.Request) -> dict[str, str]:
"""Decode a form-urlencoded request body into a flat ``{key: value}`` dict.
``parse_qs`` returns lists per key; the wxUser body has no repeated keys,
so we collapse each to its single value for ergonomic assertions.
``keep_blank_values=True`` so the always-empty ``userId`` / ``salesname``
filters (sent as ``userId=``) survive decoding and can be asserted on.
"""
body = request.content.decode()
parsed = parse_qs(body, keep_blank_values=True)
return {k: v[0] for k, v in parsed.items()}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# End-to-end: pagination + field mapping + raw_json # End-to-end: pagination + field mapping + raw_json
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -77,7 +100,9 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
results = [ results = [
r r
async for r in scrape_wechat_accounts( async for r in scrape_wechat_accounts(
client, page_size=DEFAULT_PAGE_SIZE client,
department_id=ROOT_DEPARTMENT_ID,
page_size=DEFAULT_PAGE_SIZE,
) )
] ]
finally: finally:
@@ -86,6 +111,16 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
# Pagination: exactly 3 calls (page1, page2, empty-page stop). # Pagination: exactly 3 calls (page1, page2, empty-page stop).
assert route.call_count == 3 assert route.call_count == 3
# The required form filters ride on every request (the live endpoint
# answers code -90000 without them).
for call in route.calls:
body = _parse_form(call.request)
assert body["departmentId"] == ROOT_DEPARTMENT_ID
assert body["isOnJob"] == "1"
assert body["startdate"]
assert body["enddate"]
assert call.request.headers["content-type"] == FORM_CONTENT_TYPE
# Exactly the 4 fixture records (not duplicated, not dropped). # Exactly the 4 fixture records (not duplicated, not dropped).
assert len(results) == 4 assert len(results) == 4
assert {r["wx_id"] for r in results} == { assert {r["wx_id"] for r in results} == {
@@ -132,7 +167,8 @@ async def test_scrape_wechat_accounts_yields_records_and_paginates():
@respx.mock @respx.mock
async def test_scrape_wechat_accounts_sends_pagination_body(): async def test_scrape_wechat_accounts_sends_pagination_body():
"""Outgoing request body carries pageNum/pageSize; advances per page.""" """Outgoing form body carries pageNum/pageSize + the required filters; the
page number advances per request."""
page1 = make_wxuser_list_response( page1 = make_wxuser_list_response(
WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1 WXUSER_RECORDS[:1], total=TOTAL, page_num=1, page_size=1
) )
@@ -149,7 +185,14 @@ async def test_scrape_wechat_accounts_sends_pagination_body():
client = _primed_client() client = _primed_client()
try: try:
results = [ results = [
r async for r in scrape_wechat_accounts(client, page_size=1) r
async for r in scrape_wechat_accounts(
client,
department_id=ROOT_DEPARTMENT_ID,
page_size=1,
start="2026-07-01",
end="2026-07-08",
)
] ]
finally: finally:
await client.aclose() await client.aclose()
@@ -158,15 +201,28 @@ async def test_scrape_wechat_accounts_sends_pagination_body():
assert len(results) == 1 assert len(results) == 1
assert route.call_count == 2 assert route.call_count == 2
# First request: pageNum=1. # Every request is form-urlencoded (NOT JSON) and carries the required
body1 = _json.loads(route.calls[0].request.content.decode()) # filters the live endpoint demands (isOnJob / departmentId / startdate /
assert body1["pageNum"] == 1 # enddate), plus the always-empty free-text filters from the real client.
assert body1["pageSize"] == 1 for call in route.calls:
assert call.request.headers["content-type"] == FORM_CONTENT_TYPE
body = _parse_form(call.request)
assert body["isOnJob"] == "1"
assert body["departmentId"] == ROOT_DEPARTMENT_ID
assert body["startdate"] == "2026-07-01"
assert body["enddate"] == "2026-07-08"
assert body["userId"] == ""
assert body["salesname"] == ""
assert body["weChatNickName"] == ""
# Second request: pageNum advanced to 2. # pageNum/pageSize advance correctly across the form body.
body2 = _json.loads(route.calls[1].request.content.decode()) body1 = _parse_form(route.calls[0].request)
assert body2["pageNum"] == 2 assert body1["pageNum"] == "1"
assert body2["pageSize"] == 1 assert body1["pageSize"] == "1"
body2 = _parse_form(route.calls[1].request)
assert body2["pageNum"] == "2"
assert body2["pageSize"] == "1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -184,7 +240,12 @@ async def test_scrape_wechat_accounts_stops_on_empty_first_page():
client = _primed_client() client = _primed_client()
try: try:
results = [r async for r in scrape_wechat_accounts(client)] results = [
r
async for r in scrape_wechat_accounts(
client, department_id=ROOT_DEPARTMENT_ID
)
]
finally: finally:
await client.aclose() await client.aclose()
+4
View File
@@ -119,6 +119,10 @@ onMounted(async () => {
<el-icon><ChatLineSquare /></el-icon> <el-icon><ChatLineSquare /></el-icon>
<span>客户</span> <span>客户</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/staff">
<el-icon><Avatar /></el-icon>
<span>员工</span>
</el-menu-item>
<el-menu-item index="/groups"> <el-menu-item index="/groups">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span>群</span> <span>群</span>
+6
View File
@@ -15,6 +15,12 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/CustomerList.vue'), component: () => import('@/views/CustomerList.vue'),
meta: { title: '客户列表' }, meta: { title: '客户列表' },
}, },
{
path: '/staff',
name: 'staff',
component: () => import('@/views/StaffList.vue'),
meta: { title: '员工列表' },
},
{ {
path: '/groups', path: '/groups',
name: 'groups', name: 'groups',
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getStaff } from '@/api'
import type { Staff } from '@/api/types'
import { useOrgStore } from '@/stores/org'
const route = useRoute()
const org = useOrgStore()
const staff = ref<Staff[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
// On-job filter: 'all' (no filter) / 'on' (is_on_job=true) / 'off' (false).
const onJobFilter = ref<'all' | 'on' | 'off'>('all')
const departmentId = computed<string | undefined>(() =>
route.query.department_id ? String(route.query.department_id) : undefined,
)
const onJobOptions: { value: 'all' | 'on' | 'off'; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'on', label: '在职' },
{ value: 'off', label: '离职' },
]
function onJobTagType(onJob?: boolean | null): 'success' | 'info' | '' {
if (onJob === true) return 'success'
if (onJob === false) return 'info'
return ''
}
function onJobLabel(onJob?: boolean | null): string {
if (onJob === true) return '在职'
if (onJob === false) return '离职'
return '--'
}
function deptName(deptId?: string | null): string | null {
if (!deptId) return null
return org.findNode(deptId)?.label ?? deptId
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getStaff({
department_id: departmentId.value,
is_on_job:
onJobFilter.value === 'all' ? undefined : onJobFilter.value === 'on',
page: page.value,
page_size: pageSize.value,
})
staff.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`员工列表加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// React to the on-job filter + any dept query param.
watch(
() => [onJobFilter.value, departmentId.value] as const,
() => {
page.value = 1
load()
},
)
onMounted(async () => {
// Dept names come from the org tree; best-effort load so the 部门 column
// can render names instead of bare ids. A load failure is non-fatal — the
// column falls back to the raw dept_id.
try {
await org.ensureLoaded()
} catch {
/* surfaced in the aside placeholder */
}
await load()
})
</script>
<template>
<div class="yk-staff">
<div class="yk-staff__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">员工列表</h2>
<span class="yk-staff__sub">共 {{ total }} 条</span>
</div>
<div class="yk-staff__filters">
<el-radio-group v-model="onJobFilter" size="small">
<el-radio-button
v-for="opt in onJobOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="staff"
stripe
border
row-key="user_id"
style="width: 100%"
empty-text="暂无员工数据"
>
<el-table-column label="姓名" min-width="120">
<template #default="{ row }">
<span v-if="row.name">{{ row.name }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="手机" min-width="130">
<template #default="{ row }">{{ row.phone || '--' }}</template>
</el-table-column>
<el-table-column label="sale_phone" min-width="130">
<template #default="{ row }">{{ row.sale_phone || '--' }}</template>
</el-table-column>
<el-table-column label="部门" min-width="140">
<template #default="{ row }">
<span v-if="deptName(row.dept_id)">{{ deptName(row.dept_id) }}</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="在职" width="100" align="center">
<template #default="{ row }">
<el-tag
:type="onJobTagType(row.is_on_job)"
size="small"
effect="plain"
>
{{ onJobLabel(row.is_on_job) }}
</el-tag>
</template>
</el-table-column>
</el-table>
<div class="yk-staff__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
</div>
</template>
<style scoped>
.yk-staff__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-staff__sub {
color: #909399;
font-size: 13px;
}
.yk-staff__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-staff__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>