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
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
single ``fetch(cursor)`` coroutine that performs whatever request the caller
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.
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
# 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,
build_body: BuildBody,
rate_limiter: RateLimiter | None = None,
form: bool = False,
**extra: Any,
) -> AsyncIterator[dict[str, Any]]:
"""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
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
total: int | None = None
@@ -95,7 +122,14 @@ async def paginate_by_page(
await rate_limiter.acquire()
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
# envelopes keep it on every page, but reading it once is enough for