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
+61 -4
View File
@@ -43,9 +43,10 @@ import logging
from collections.abc import AsyncIterator, Callable
from typing import Any
import sqlalchemy as sa
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.media import download_message_media
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(
session: AsyncSession, client: YunkeClient
session: AsyncSession,
client: YunkeClient,
*,
department_id: str | None = None,
) -> 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(
session,
entity_type=ENTITY_WECHAT_ACCOUNT,
channel=None,
scope_id=None,
scrape=lambda: scrape_wechat_accounts(client),
scrape=lambda: scrape_wechat_accounts(client, department_id=resolved),
upsert=upsert_wechat_account,
)