Commit Graph

8 Commits

Author SHA1 Message Date
浅念 854efd3119 feat: idempotent upsert repos + object store
Adds the storage layer that bridges scrapers (which yield normalized dicts)
to the DB and the local media store.

app/storage/repos.py
  * upsert_customer / upsert_customers / upsert_messages / upsert_wechat_account
    / upsert_staff_from_account, all taking an AsyncSession + scraper-shaped dicts.
  * Dialect-aware Core upsert: SQLite on_conflict_do_update for tests,
    MySQL on_duplicate_key_update for prod; dialect detected via session.bind.name.
    _build_upsert_stmt(model, values, dialect) is the reusable helper.
  * Type coercion: _coerce_bool / _coerce_int (module-level, unit-tested) map
    is_send/is_del/is_block/is_on_job int-as-bool to bool and gender/type
    numeric strings to int; None preserved; raw_json kept verbatim.
  * For M1 the Message model maps to a single physical `message` table;
    per-month sharding (message_table_name helper) is a LATER task (YAGNI).

app/storage/object_store.py
  * Local-filesystem ObjectStore with put_bytes / put_stream / put_by_hash /
    exists / get_path, all dispatched via asyncio.to_thread so disk I/O is
    genuinely async.
  * put_by_hash computes sha256 and writes only if absent (content-addressed
    dedup); layout is base_dir/<first 2 hash chars>/<hash><ext>.
  * Default base dir is Settings.media_dir; the interface is the seam a
    future MinIO/S3 backend will swap in behind.

tests: 24 new tests (in-memory SQLite for repos; tmp_path for object_store)
covering idempotency (same PK twice -> 1 row, updated), type coercion, batch
dedup (3 messages / 1 dup msg_id -> 2 rows), and hash dedup (corrupt-file
probe proves second put_by_hash does not rewrite). ruff clean; full suite
67/67 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:55:50 +08:00
浅念 903286f379 feat: wechat message scraper
Adds Task 1.6: a backward-cursor paginator in base.py, a message-type
normalizer in parsers.py, and the WeChat chat-message scraper in wechat.py.

- app/scraper/parsers.py: normalize_wechat_type() maps 1 -> "text",
  -2 -> "divider", everything else -> "unknown". Total (never raises);
  unknown codes pass through so no real message is silently dropped.
- app/scraper/base.py: paginate_by_cursor() generic async generator. Takes
  a fetch(cursor) -> (rows, next_cursor, has_more) coroutine; advances the
  cursor and stops on has_more=False OR an empty page (defensive). Same
  RateLimiter integration as paginate_by_page.
- app/scraper/wechat.py: scrape_wechat_messages() wraps
  POST /wechatweb/msg/chats. Body is form-encoded (NOT JSON) — content-type
  is overridden per request to application/x-www-form-urlencoded via the
  YunkeClient.post(headers=...) override hook. Pages backward by setting
  end = data.end - 1 each batch, repeats until hasLast=false. Drops any
  record whose type/messageType is -2 (date divider UI chrome). Yields
  normalized dicts (snake_case Message columns + channel="wechat" +
  raw_json); the raw type code is preserved on raw_json for the
  persistence layer (Task 1.7).

Tests: 13 new (10 parser parametrized + 3 scraper). respx-mocked
msg/chats with two backward pages — asserts [m1, m2, m3] yielded (the -2
divider dropped), full field mapping vs the live-capture names, the
content-type is form-encoded (not JSON), and the 2nd request body's
end == E1 - 1 (proving the backward cursor advance).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:39:55 +08:00
浅念 4a47e824c7 feat: wechat friend scraper
Add the first list scraper for the yunke data-mirror system, establishing
the reusable pagination pattern all later list scrapers will build on.

- app/scraper/base.py: generic `paginate_by_page` async generator that walks
  page-numbered endpoints until a page comes back empty (the yunke CRM's
  primary "no more rows" signal), with a defensive over-fetch ceiling keyed
  off the declared total (strict `>` so an honest source yields exactly
  `total`). Plus a tiny `RateLimiter` enforcing a min interval between calls.
- app/scraper/wechat.py: `scrape_wechat_friends` wraps
  `POST /wechatweb/wxFriends/friend_list/es`, maps each record to the
  `Customer` column shape (snake_case) with `channel="wechat"` and a verbatim
  `raw_json` copy. Optional `department_id`/`user_id` filters pass through to
  the request body.
- tests/cassettes/wxFriends_fixture.py: two realistic, capture-shaped friend
  records + a response-envelope builder, reused by the scraper tests.
- tests/test_scrape_wechat_friends.py: drives the real pagination +
  normalization logic through respx (page1 returns both records, page2 empty
  -> stop). Asserts exact field mappings, `raw_json` fidelity, and exactly 2
  upstream calls.
- tests/test_scrape_base.py: smoke tests for the RateLimiter spacing.

Persistence (upsert into `Customer`) is deliberately deferred to Task 1.7;
scrapers remain pure producers of normalized dicts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:31:28 +08:00
浅念 8243e9c6d2 feat: session login + authed client
Task 1.4 — 会话层. Adds the authenticated HTTP client that every yunke
scraper will use to call the CRM API.

- app/session/login.py: get_secure_key + pc_login wrappers, LoginResult
  dataclass, and a clearly-marked Phase-0 swappable encrypt_password
  placeholder (real RSA per getSecureKey is a TODO; drop-in contract
  documented in the docstring).
- app/session/yunke_client.py: YunkeClient wrapping httpx.AsyncClient.
  Constructor takes either (username+password) for auto-login or
  (userid+company[+cookies]) for an existing session. Injects the 5
  observed auth headers (userid/company/source=yunkepc/channel=4/
  content-type=application/json) on every request, auto-logs-in on first
  request, and on HTTP 401 (or body code 401) re-logs-in once and
  retries — a persistent 401 raises after the single retry so there is
  no infinite loop.
- Tests (respx): login populates LoginResult + httpx cookie jar; every
  request carries all 5 auth headers + cookie; 401 triggers exactly one
  re-login + retry and returns 200; persistent 401 raises after one
  retry; first request with no session auto-logs-in.

Session is held in-memory only for now; persistence to encrypted
YkAccount.session_blob_enc is left as a later concern (TODO in
yunke_client.py).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:22:47 +08:00
浅念 9cf1c9ab11 feat: core ORM models + initial migration
Implements the 14 core ORM models for the yunke data-mirror system using
SQLAlchemy 2.0 Mapped/mapped_column style, driven by the field lists in
docs/superpowers/specs/2026-08-01-yunke-data-dictionary.md:

  Department, Staff, WechatAccount, WhatsappAccount, Customer, ChatGroup,
  GroupMember, Message, Media, PhoneCall, Sms, SyncState, YkAccount, SyncRun.

Conventions:
- Natural source PKs where they exist (friend_id, wx_id, msg_id, room_id,
  media_hash, call_id, sms_id, dept_id, user_id); surrogate Integer PKs
  otherwise (SyncState, YkAccount, SyncRun). GroupMember uses a composite
  (channel, room_id, member_id) PK.
- Common audit columns version/raw_json/fetched_at/updated_at on every
  source-mirrored table.
- channel only where meaningful: Customer/Message/Media/ChatGroup/
  GroupMember/Sms use messaging channels; PhoneCall.channel is call-type;
  SyncState.channel scopes the cursor. Omitted on Staff/Department/
  WechatAccount/WhatsappAccount/YkAccount/SyncRun.
- Message composite index on (channel, owner_account_id, talker_id,
  timestamp) for the hot per-talker time-order query path.
- message_table_name(channel, timestamp) helper documents the future
  message_<channel>_<YYYYMM> sharding convention; the Message model still
  maps to a single physical table for now (sharding is a later task).

All types are portable generic SQLAlchemy types so tests run on in-memory
SQLite while prod targets MySQL. Tests verify Customer round-trip,
raw_json JSON round-trip, Message natural PK, GroupMember composite PK,
the Message composite index, and full table creation on SQLite.

Alembic wired via migrations/env.py to Base.metadata + get_settings().db_url
with an async->sync driver swap (mysql+asyncmy -> mysql+pymysql,
sqlite+aiosqlite -> sqlite). Initial migration autogenerated against an
empty SQLite DB; server defaults normalized to sa.func.now() so the file
is MySQL-portable. upgrade head is intentionally not run (no MySQL yet).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:15:34 +08:00
浅念 b9fcf17ae4 feat: db base + config tests
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 11:04:44 +08:00
浅念 2031607beb chore: scaffold yunke-server
Create pyproject.toml (FastAPI + SQLAlchemy + Playwright + APScheduler +
Redis + cryptography stack), .env.example, app/__init__.py and a
pydantic-settings Settings singleton (get_settings), and a root
.gitignore covering Python, Node, env, IDE and the local media dir.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 10:59:37 +08:00
浅念 cd8d36fd00 docs: initial design spec, data dictionary, implementation plan
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 10:53:28 +08:00