Skip to content

API Reference

Haisu Zuo edited this page Oct 9, 2026 · 4 revisions

API Reference — Crowntime WeCom Archive

Route catalog for the current FastAPI application.

Source of truth:

  • runtime routes: backend/app/main.py
  • router modules under backend/app/routers/
  • interactive OpenAPI UI at /docs

This document focuses on the stable HTTP surface and the auth model. For ORM details, see DATA_MODEL.md.


1. Auth Model

Public routes

Method Path Purpose
GET /health Readiness check (DB + schema revision) — see docs/DEPLOYMENT.md §7.5. Alias of /health/ready; use /health/live for a dependency-free liveness probe.
GET /health/live Liveness check (process only)
GET /health/ready Readiness check (DB + schema revision)
GET /admin/login Login page shell
POST /api/auth/password/login Password login when AUTH_MODE=password
GET /api/auth/wecom/login Start WeCom OAuth flow
GET /api/auth/wecom/callback Finish WeCom OAuth flow
GET /api/wecom/archive/events WeCom callback URL verification
POST /api/wecom/archive/events WeCom event signature validation

Session-protected routes

All routes below require a valid session_id cookie and derive authorization scope exclusively from admin_sessions.tenant_id.

tenant_id is never accepted from request params or headers.


2. Auth / Session APIs

Method Path Notes
GET /api/auth/me Always returns HTTP 200; payload includes authenticated
POST /api/auth/logout Revokes server-side session and clears cookie

3. Annual-plan purchase and payment providers

The HTML and /api/billing/* routes require a valid owner session. They accept both an admin-scope owner for an active tenant and the restricted provisioning-scope owner created by the self-service organization flow. Tenant identity always comes from that session.

Method Path Purpose
GET /admin/billing Owner purchase/status page
GET /api/billing/plan Server-authoritative annual plan and payment availability
GET /api/billing/subscription Current tenant plan, effective dates, entitlements and explicit customer-facing state
GET /api/billing/capacity Live tenant-scoped quota, downloaded-byte usage, remaining bytes and capacity state
GET /api/billing/orders/latest Latest tenant order or null
POST /api/billing/orders Create/idempotently replay a server-authoritative payment order; requires Idempotency-Key
GET /api/billing/orders/{order_id} Tenant-scoped order and activation state; does not expose checkout material
GET /api/billing/orders/{order_id}/qr Server-rendered PNG QR for a WeChat Native order; never exposes the raw provider URL
GET /api/billing/orders/{order_id}/checkout Authenticated 303 redirect to an Alipay PC cashier; no signed URL appears in JSON or HTML
POST /api/billing/orders/{order_id}/refresh Signed provider query and reconciliation
POST /api/billing/orders/{order_id}/close Close an unpaid order

The browser supplies only the seeded plan code. Price, currency, period and entitlements are loaded from the database; client-provided amounts are not accepted. Order states are creating, pending, paid_activation_pending, succeeded, closed and failed. A verified payment may temporarily remain paid_activation_pending; callback retries or owner refresh safely retry the same provider-neutral subscription activation.

POST /api/payments/wechat/notify is intentionally not session-protected. It authenticates WeChat Pay itself: the handler verifies the raw-body RSA signature, requires the configured PUB_KEY_ID_*, enforces timestamp freshness, decrypts the AES-GCM resource, verifies AppID/merchant/order/amount/ currency, and then applies idempotent activation. Invalid notifications return a generic failure without provider details.

POST /api/payments/alipay/notify is likewise public HTTPS-only. It verifies Alipay's RSA2 signature over the submitted form fields (excluding sign and sign_type), then checks AppID, seller ID, order number, transaction number, trade status and CNY total before it can apply idempotent activation. It returns Alipay's literal success acknowledgement only after that path; the browser return_url is presentation-only and never activates a subscription. The route must not have its form body rewritten by a proxy, and no other billing route bypasses normal session authentication.

WeChat Native and Alipay PC cashier are one-time annual purchases, not an automatic debit agreement. The payer may use only the methods shown by the provider's cashier. Tencent's Conversation Archive service fee remains separate.

Platform-controlled full refunds

Refunds are deliberately outside the tenant Owner surface. The control routes require an authenticated active platform administrator and derive the amount, currency, provider transaction, and tenant from the original succeeded Payment. They accept full refunds only.

Method Path Purpose
POST /api/platform/operations/tenants/{tenant_id}/refunds Submit/idempotently replay one reviewed full refund; requires Idempotency-Key, reason and exact tenant-slug confirmation; HTTP 202 means provider acceptance/processing, not success
POST /api/platform/operations/tenants/{tenant_id}/refunds/{refund_id}/query Actively query the signed WeChat result and reconcile a missed/delayed callback; requires a new Idempotency-Key, reason and tenant-slug confirmation per deliberate attempt
POST /api/refunds/wechat/notify Public WeChat refund notification receiver; authenticates the provider rather than a browser session

The notification route verifies the raw-body signature and timestamp, decrypts the AES-GCM resource, and matches merchant, original order/transaction, refund references, full amount, and currency before storing the fact. A successful refund application response changes the domain only to processing; only a trusted callback or signed query result with SUCCESS may reverse the exact subscription term grant. ABNORMAL and CLOSED remain explicit terminal operator-visible outcomes. Duplicate evidence is idempotent, changed replays and out-of-order terminal transitions fail closed, and raw bodies, keys, payer details, and provider error text are never returned.

Platform commercial operations

GET /platform/operations and every /api/platform/operations/* route require the independent platform-administrator credential domain. A tenant Owner session never satisfies this boundary.

Method Path Purpose
GET /api/platform/operations/dashboard Cross-tenant lifecycle, usage, provider-confirmed financial, manual-ledger and exception totals
GET /api/platform/operations/tenants Paginated/filterable tenant commercial state; filters cover service, effective subscription, refund, exception and expiry range; sorting covers creation, name and expiry
GET /api/platform/operations/tenants/{tenant_id} One safe detail projection with masked payment/refund references, separate manual rows, exceptions and append-only audit event IDs
PATCH /api/platform/operations/tenants/{tenant_id}/service Manual suspend/resume with Idempotency-Key, reason and exact tenant-slug confirmation
POST /api/platform/operations/tenants/{tenant_id}/payments/{order_id}/query Controlled payment/activation reconciliation with the same high-risk controls

Provider gross receipts count only provider-confirmed paid orders. Successful provider refunds count only authoritative refund rows in succeeded; a processing refund does not reduce revenue. Provider net revenue is therefore gross receipts minus successful provider refunds. Manual receipt/refund rows are returned in a separate ledger and never alter those totals or subscription entitlement. High-risk commands use the platform credential plus an explicit reason, exact tenant-slug confirmation and a hashed idempotency receipt in Audit. Exact completed query/control replays do not repeat provider I/O, while a failed refund submission may safely retry its already-reserved stable out_refund_no; changed replays fail. Responses never expose full provider references, payer accounts, CorpID, UserID, archive content, raw callbacks or keys.

Platform product analytics

POST /api/product-analytics/events accepts only the versioned, browser-safe allowlist in product-analytics-events-v1.md. It derives tenant and admin identity from the authenticated tenant session, responds 202 even when best-effort collection is unavailable, and never accepts content, query values, media identifiers or credentials.

Method Path Purpose
POST /api/product-analytics/events Tenant-session, asynchronous first-party product-use event collection
GET /api/platform/operations/product-analytics/overview PlatformAdmin-only active/inactive tenant, login and function-adoption aggregates
GET /api/platform/operations/product-analytics/tenants PlatformAdmin-only, paginated active/inactive tenant summaries
GET /api/platform/operations/product-analytics/tenants/{tenant_id} PlatformAdmin-only aggregate usage detail for one tenant

The three reads support bounded time ranges and an optional canonical event filter. They return no raw event payloads, archive content, search terms, contact identifiers, media information or authentication data.

GET /api/billing/capacity measures only this tenant's successfully downloaded media_files bytes and joins that fact to the effective subscription quota. States are normal, warning_80, warning_90, full, over_limit, and unavailable. Missing/inactive subscription or unavailable usage never grants write capacity. measured_at is the server measurement time; the response is not a browser estimate.

GET /api/billing/subscription has no tenant, status, quota, or entitlement input. It derives all fields from the authenticated owner's tenant and the RND-376 authority. Customer-facing states are trial, paid_active, expiring_soon (30-day server-time window), expired, canceled, and unavailable; inactive states include a machine-readable reason so the page can explain why access is unavailable and direct the owner to purchase/renew.


4. Review Console APIs

Monitored accounts

Method Path Query Response
GET /api/monitored-accounts include_conversation_count (default true) Array of monitored-account summary objects

Monitored accounts are derived at query time from archive participants plus authenticated admin identities; there is no separate seat-roster table. When include_conversation_count=false, the response preserves the same member data but returns conversation_count: null rather than performing the archive-wide count aggregation. This is intended for UIs that do not display the count.

Contacts

Method Path Query Response
GET /api/contacts none Array of contact summary objects

Controlled contact avatars

Method Path Query Response
GET `/api/admin/avatars/{internal external}/{avatar_id}` none

List/detail/timeline avatar fields expose only this application endpoint plus safe cache state. They never expose a WeCom source URL, a token, signed object URL, or storage key. The endpoint authenticates and scopes the numeric profile ID to the active tenant before reading private storage.

External contacts

Method Path Query Response
GET /api/admin/external-contacts company, tags, owner_wecom_userid, q, offset, limit Tenant-scoped external-contact page
GET /api/admin/external-contacts/{external_userid} none One external-contact identity, follow remarks, nickname history, and conversations

The RND-170 identity contract keeps three facts distinct: name is a legacy compatibility label, current_nickname is the customer-level current WeCom nickname, and follow_remarks contains employee-scoped remarks. List/detail display_name has no selected employee context and therefore uses the current nickname (or a safe fallback), never an arbitrary employee remark. A q search can match an active employee remark, current nickname, or former nickname; search_matches identifies the match type and, for a remark, the matching follow_userid. Each external identity appears once per result page.

Conversations

Method Path Query Response
GET /api/conversations mode=staff&staff_id=... or mode=contact&contact_id=...; optional include_participant_metadata Array of conversation summaries

Rules:

  • mode=staff requires staff_id
  • mode=contact requires contact_id
  • conversations are returned latest-activity-first
  • include_participant_metadata=false uses the compact staff-list path: direct conversation titles remain resolved, while group-card participant arrays are omitted because the console does not render them

Conversation timeline

Method Path Query Response
GET /api/conversations/{conversation_id}/messages limit, before Paginated message timeline

Important behavior:

  • results are tenant-scoped
  • returned in ascending message-time order within the page
  • before is an opaque cursor from the previous response's pagination.next_before
  • conversation IDs are either:
    • group: <roomid>
    • direct: direct__<uid_a>___<uid_b>

Media serving

Method Path Query Response
GET /api/conversations/{conversation_id}/messages/{msgid}/media none Image file bytes or HTTP 404
GET /api/conversations/{conversation_id}/messages/{msgid}/media/access none Unified media access descriptor (JSON) — see below

Both routes only serve already-downloaded image media and apply full tenant authorization and safe-path checks before returning anything.

Status (RND-187): implemented locally, developer re-acceptance pending — not yet deployed to production.

GET .../media/access — unified media access descriptor (RND-187)

Returns a small JSON descriptor telling the client how to load an image, instead of proxying the image bytes itself. The frontend calls this first for every image message, then loads the actual image from url — it never needs to know or reason about which storage backend served it, and it never constructs a Qiniu/CDN URL itself.

  • Method / path: GET /api/conversations/{conversation_id}/messages/{msgid}/media/access

  • Authentication: requires a valid session_id cookie, same as every other session-protected route (get_current_user). No cookie → 401.

  • Tenant requirement: tenant_id comes only from the session — never from a request param. The message must belong to conversation_id for the session's tenant, and the resolved media_files row must independently belong to that same tenant (defense-in-depth — a row whose tenant_id column diverged from its parent message's would still be denied). For Qiniu-backed rows there is one more check before a signed URL is ever minted: the object key's own tenants/{tenant_id}/ prefix must agree with the authenticated tenant — see Media Storage Operations.

  • Success response (200), shape shared by both storage backends:

    {
      "media_id": 207,
      "storage_backend": "qiniu_kodo",
      "access_type": "signed_url",
      "url": "https://media.example.com/<redacted-path>?e=<redacted>&token=<redacted>",
      "expires_at": "2026-07-12T10:15:00+00:00",
      "content_type": "image/jpeg",
      "size_bytes": 333287
    }

    (The url value above is illustrative only — this document never contains a real Signed URL. A real value is a single-object, time-boxed Qiniu URL, valid only until expires_at.)

    • Qiniu-backed media: access_type="signed_url". url is a short-lived signed URL (official Qiniu SDK signer) scoped to exactly one object; the browser fetches it directly from QINIU_DOMAIN (a CDN or, per RND-207, an origin domain) — the image bytes no longer round-trip through this backend. expires_at is an ISO-8601 timestamp. RND-207: the absolute expiry is snapped to a fixed window (MEDIA_SIGNED_URL_WINDOW_SECONDS, defaulting to the TTL MEDIA_SIGNED_URL_TTL_SECONDS, default 900s, bounded 60–3600s), so repeated requests for the same object within a window return a byte-identical url/expires_at and the browser reuses its HTTP cache.
    • Local-backed media: access_type="proxy". url is the existing .../media route, unchanged; expires_at is null (the URL carries no time-boxed credential of its own — the session cookie authorizes each request to it, exactly as before RND-187).
  • variant query param (RND-207/RND-258): ?variant=thumb returns a descriptor for the generated list thumbnail when one exists, else falls back to the original. ?variant=play returns a generated MP3/WAV playback derivative for a voice/audio_archive row when one exists; ordinary voice descriptor requests select that same derivative automatically. A failed or unsupported conversion falls back to the original AMR/SILK so it remains downloadable. size_bytes is null for either derived object. The same object-key tenant-prefix check applies to every derivative. The timeline (GET .../messages) additionally carries thumbnail_access_url (the ?variant=thumb URL, null when no thumbnail exists) and image_width/image_height (the original's intrinsic pixels, for layout-box reservation). Nested mixed/chatrecord media descriptors carry the same thumbnail fields.

  • Cache-Control: no-store on every response this endpoint can produce — success or error, any status code (200/401/404/500/502/503). This response is per-user and short-lived and must never be cached by a shared/CDN proxy.

  • Error responses: 401 (not authenticated), 404 (wrong tenant, wrong conversation, non-image message, missing/pending media row, unservable file, or a Qiniu object key whose tenant prefix doesn't match — all collapsed to the same generic 404 so a client cannot distinguish "doesn't exist" from "not yours"), 500 (misconfigured storage backend or TTL), 502 (confirmed signed-URL generation failure against a provider that did respond), 503 (storage provider outage — never reported as a plain 404, an outage must not look like missing media).

  • Never exposed: storage_ref (the raw Qiniu object key or local filesystem path), local_path, sdkfileid, Qiniu AK/SK, or any raw provider/SDK error text — in the response body or in any log line.


5. Data Export APIs

All export routes are tenant-scoped and require the owner role. The browser never supplies tenant_id. Every accepted export consumes a server-side quota and writes the existing export audit record; password confirmation is obtained through POST /api/admin/export/approve and is bound to the canonical request parameters.

Method Path Purpose
GET /admin/exports Shared export center used by review, search and customer-detail pages
GET /api/admin/exports/quota Current natural-month counters and Asia/Shanghai reset time
POST /api/admin/exports/text Generate a bounded PDF or Excel file synchronously
POST /api/admin/exports/media Accept one asynchronous tenant-wide original-media ZIP job
GET /api/admin/exports/jobs Latest 20 media export jobs for the authenticated tenant
GET /api/admin/exports/jobs/{job_id} One tenant-owned job state
GET /api/admin/exports/jobs/{job_id}/download Authenticated download or short-lived private-storage redirect

Text scope supports one or more of roomid (group room or derived direct__... conversation ID), participant_id, public WeCom message_ids, and a paired start_ms/end_ms. The synchronous limit is 50,000 rows. The monthly limits are 10 text exports and 1 full-media ZIP per tenant. The quota check and consumption run in the same transaction as text generation or media job acceptance; validation failures roll back, while an accepted asynchronous job keeps its consumed allowance even if later generation fails.

Media jobs move through queued, processing, ready, failed, and expired. A ready ZIP contains tenant-owned Qiniu originals plus manifest.csv; local, missing, cross-tenant, size-mismatched, or checksum-mismatched sources fail the whole job. The worker verifies source size and SHA-256 through Qiniu's server-side qhash/sha256 response, then Qiniu Dora builds the ZIP asynchronously in storage. The API redirects an authenticated Owner to a short-lived private Qiniu URL. The requesting Owner receives an email linking to /admin/exports?job=..., which is not a bearer credential and still requires an Owner session. The download route fails closed exactly seven days after successful generation; the maintenance worker then deletes the private object idempotently.


6. Search / Message APIs

These routes expose raw message-centric access in addition to the main conversation-oriented review console.

HTML message search pages

Method Path Purpose
GET /admin/messages Search/filter archive messages in HTML
GET /admin/messages/{msgid} Message detail page in HTML

JSON message search APIs

Method Path Query Response
GET /api/search/contacts q, limit Contact/global identity search results
GET /api/messages sender, q, msgtype, roomid, limit Array of messages
GET /api/messages/{msgid} none One message plus recipient list

/api/search/contacts includes matching external identities once. Its match_field is remark, current_nickname, or historical_nickname for an external result; match_context_userid is populated only for a remark match.

Search behavior:

  • q is a case-insensitive substring match on content_text
  • sender, msgtype, and roomid are exact-match filters
  • all queries are tenant-scoped

7. Diagnostics APIs

Method Path Query Response
GET /admin/diagnostics/reachability none HTML diagnostics shell
GET /api/admin/reachability-audit conversation_id, message_type, msgtime_from, msgtime_to, limit, offset, include_samples, sample_limit Aggregate reachability report

The HTML page is a thin UI over the JSON diagnostics endpoint. All classification happens server-side in app/reachability_audit.py.


8. WeCom Callback APIs

Method Path Notes
GET /api/wecom/archive/events Decrypts and verifies WeCom echostr
POST /api/wecom/archive/events Verifies signature, decrypts the event envelope, validates CorpID, persists a targeted external-contact refresh task for change_external_contact, and dispatches archive work asynchronously

The POST acknowledgement never waits for targeted refresh, archive work, or any outbound contact API request. Direct inbound archive messages from a WeCom external-user identifier persist the same coalesced task after decrypt commit; group messages and messages sent by an archive seat do not. A small dedicated worker drains persisted tasks, while the independent daily full reconciliation is the durable fallback for missed callbacks, unavailable customer relationships, and worker restarts.

These routes are intentionally not session-protected.


8. Verification Notes

This document was aligned against the current FastAPI route table and OpenAPI schema generated by the app itself. If you change routes, update this file in the same change.

Clone this wiki locally