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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user