Commit Graph

4 Commits

Author SHA1 Message Date
浅念 ff7e9cb1d9 feat: backend read api for all entities (messages/groups/calls/sms/org/sync)
Adds the read-only API surface the upcoming Vue frontend needs to browse all
mirrored entities. Follows the existing customer-router pattern: Annotated
deps, Page[T] envelope, select+where+order_by, count() for total.

Endpoints (all GET):
* /api/departments          -> list[DepartmentOut] (path-ordered, plain list)
* /api/staff                -> Page[StaffOut] (department_id, is_on_job filters)
* /api/accounts             -> Page[AccountOut] (channel filter; unified
                             wechat+whatsapp projection built in memory)
* /api/messages             -> Page[MessageOut] (channel/owner/talker/room/
                             [start,end] filters; ordered timestamp asc)
* /api/groups               -> Page[GroupOut] (channel/owner filters)
* /api/groups/{id}/members  -> list[GroupMemberOut] (404 on unknown room)
* /api/calls                -> Page[PhoneCallOut] (channel/user/time window;
                             ordered start_call_time desc)
* /api/sms                  -> Page[SmsOut] (user/time window; create_time desc)
* /api/sync/status          -> {states, recent_runs} monitor payload

Schemas (Pydantic v2, from_attributes=True): MessageOut, GroupOut,
GroupMemberOut, PhoneCallOut, SmsOut, DepartmentOut, StaffOut, AccountOut
(unified across two tables), SyncStateOut, SyncRunOut, SyncStatusOut.
Only human-relevant fields per entity are exposed.

Tests: per-router TDD via httpx ASGITransport against an in-memory SQLite
override of get_session. A shared make_client factory in conftest.py builds
the seeded client, deduping the ~40-line fixture across the six new files.
41 new tests cover shape + every documented filter + pagination + ordering
(nulls-last verified on all desc/asc time columns).

Suite: 226 -> 267 passing. ruff check clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 16:15:40 +08:00
浅念 83b1aeb97c feat: simple shared-token api auth
Adds a shared-token auth dependency for the viewing API (Task 2.9). Trust
model: when API_TOKEN is empty (the default) auth is disabled — we trust the
network boundary (local/intranet tool). When set, every request must present
the token via `Authorization: Bearer <token>` or `?token=<token>`, else 401.
`/api/health` is exempt so liveness probes work without the token.

- app/config.py: add `api_token: str = ""` to Settings.
- app/api/auth.py: `require_auth` dependency (secrets.compare_digest, reads
  get_settings() per-request so token rotation via cache_clear() works).
- app/api/main.py: apply the dependency globally; document the trust model.
- .env.example: document API_TOKEN.
- tests/test_api_auth.py: 8 tests covering disabled/correct/missing/wrong/
  malformed-header/query-param/health-exempt paths.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:30:28 +08:00
浅念 7b49f94dcb feat: media downloader + serve api
M2 Task 2.6 (media downloader) + Task 2.8 (media serve API).

Downloader (app/scraper/media.py):
- download_url(client, url): fetches bytes via the YunkeClient's underlying
  httpx client (carries session cookies; respx-mockable; follows redirects).
- download_message_media(client, msg, store, session): URL-driven fetcher that
  picks the best http(s) URL (file_url -> file_hd_url -> file_th_url), dedups
  by md5 (skips the network when a Media row with local file already exists),
  falls back to sha256(bytes) when no md5, stores via ObjectStore.put_by_hash,
  and upserts a Media row. Skips type-10 system messages (non-media per
  phase0-findings §2) and type-3 opaque-id / text (no http url). Resilient:
  download errors are logged and return None (no raise).
- media_type_from_message / guess_mime_type helpers.

Repo (app/storage/repos.py): upsert_media — idempotent Core upsert into Media by
media_hash. Volatile download artifacts (local_path/size_bytes/mime_type) use
COALESCE(incoming, existing) on conflict so a re-scrape that did not re-download
does not clobber previously stored values. Exported from app.storage.

Serve API (app/api/routers/media.py): GET /api/media/{media_hash} streams the
local file via FileResponse (handles HTTP Range / 206). 404 when the row is
absent, has no local file, or the file is missing on disk. Content-type from the
row's mime_type, falling back to ext-derived guess.

Tests (+19, suite 130 -> 149, all green; ruff clean):
- test_media_downloader.py: image/video download + upsert; md5 dedup skips
  second network hit; sha256 fallback when no md5; type-3/text/system -> None;
  download failure -> None (no raise); upsert_media idempotency +
  null-local_path preservation + 3x-same-key-one-row.
- test_api_media.py: 200 + content-type + bytes; 404 unknown hash; 404 missing
  file on disk; Range -> 206 partial; ext-derived content-type when mime NULL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 14:13:50 +08:00
浅念 1e0467b40f feat: customer list API
Minimal read-only FastAPI surface over the mirrored DB:
- GET /api/customers with pagination (page/page_size) and optional
  channel / owner_account_id / department_id filters, ordered by
  last_chat_time desc (nulls last) then friend_id.
- GET /api/health liveness probe.
- Pydantic v2 CustomerOut + generic Page[T] envelope.
- CORS configured permissively for the local frontend.
- Tests override get_session to an in-memory async SQLite session
  (StaticPool) and drive the app via httpx ASGITransport.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:10:03 +08:00