Repository navigation
API Reference
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.
| 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 |
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.
| 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 |
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.
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.
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.
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.
| 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.
| Method | Path | Query | Response |
|---|---|---|---|
GET |
/api/contacts |
none | Array of contact summary objects |
| 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.
| 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.
| 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=staffrequiresstaff_id -
mode=contactrequirescontact_id - conversations are returned latest-activity-first
-
include_participant_metadata=falseuses the compact staff-list path: direct conversation titles remain resolved, while group-card participant arrays are omitted because the console does not render them
| 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
-
beforeis an opaque cursor from the previous response'spagination.next_before - conversation IDs are either:
- group:
<roomid> - direct:
direct__<uid_a>___<uid_b>
- group:
| 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.
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_idcookie, same as every other session-protected route (get_current_user). No cookie →401. -
Tenant requirement:
tenant_idcomes only from the session — never from a request param. The message must belong toconversation_idfor the session's tenant, and the resolvedmedia_filesrow must independently belong to that same tenant (defense-in-depth — a row whosetenant_idcolumn 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 owntenants/{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
urlvalue 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 untilexpires_at.)-
Qiniu-backed media:
access_type="signed_url".urlis a short-lived signed URL (official Qiniu SDK signer) scoped to exactly one object; the browser fetches it directly fromQINIU_DOMAIN(a CDN or, per RND-207, an origin domain) — the image bytes no longer round-trip through this backend.expires_atis an ISO-8601 timestamp. RND-207: the absolute expiry is snapped to a fixed window (MEDIA_SIGNED_URL_WINDOW_SECONDS, defaulting to the TTLMEDIA_SIGNED_URL_TTL_SECONDS, default 900s, bounded 60–3600s), so repeated requests for the same object within a window return a byte-identicalurl/expires_atand the browser reuses its HTTP cache. -
Local-backed media:
access_type="proxy".urlis the existing.../mediaroute, unchanged;expires_atisnull(the URL carries no time-boxed credential of its own — the session cookie authorizes each request to it, exactly as before RND-187).
-
Qiniu-backed media:
-
variantquery param (RND-207/RND-258):?variant=thumbreturns a descriptor for the generated list thumbnail when one exists, else falls back to the original.?variant=playreturns a generated MP3/WAV playback derivative for avoice/audio_archiverow 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_bytesisnullfor either derived object. The same object-key tenant-prefix check applies to every derivative. The timeline (GET .../messages) additionally carriesthumbnail_access_url(the?variant=thumbURL,nullwhen no thumbnail exists) andimage_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-storeon 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 generic404so 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 plain404, an outage must not look like missing media). -
Never exposed:
storage_ref(the raw Qiniu object key or local filesystem path),local_path,sdkfileid, QiniuAK/SK, or any raw provider/SDK error text — in the response body or in any log line.
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.
These routes expose raw message-centric access in addition to the main conversation-oriented review console.
| Method | Path | Purpose |
|---|---|---|
GET |
/admin/messages |
Search/filter archive messages in HTML |
GET |
/admin/messages/{msgid} |
Message detail page in HTML |
| 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:
-
qis a case-insensitive substring match oncontent_text -
sender,msgtype, androomidare exact-match filters - all queries are tenant-scoped
| 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.
| 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.
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.