diff --git a/yunke-server/app/api/main.py b/yunke-server/app/api/main.py index 3fee548..815ae8a 100644 --- a/yunke-server/app/api/main.py +++ b/yunke-server/app/api/main.py @@ -20,8 +20,10 @@ Includes: * ``GET /api/search`` (see :mod:`app.api.routers.search`). * ``GET /api/export/chat-html`` (see :mod:`app.api.routers.export`). -CORS is configured permissively (wildcard origin, no credentials) so the local -frontend can call the API during development. +CORS is configured to read explicit allowed origins from ``Settings.cors_origins`` +(comma-separated ``CORS_ORIGINS`` env var). Empty (default) falls back to the dev +origin ``http://localhost:5173``; set ``CORS_ORIGINS=https://app.example.com`` in +prod. Never wildcard — see the comment near ``add_middleware`` below. Auth (Task 2.9): a single shared-token dependency (:func:`app.api.auth.require_auth`) is applied globally on the app. Trust model: when ``Settings.api_token`` is @@ -52,14 +54,27 @@ from app.api.routers import ( sync, voice_clips, ) +from app.config import get_settings app = FastAPI(title="yunke data mirror", dependencies=[Depends(require_auth)]) +# CORS: read explicit allowed origins from Settings (``CORS_ORIGINS`` env var, +# comma-separated). Empty -> dev fallback (``http://localhost:5173``). Never +# ``*`` — wildcard CORS on an authenticated API is an open door even without +# credentials, since the middleware would reflect any attacker-controlled +# origin back in ``Access-Control-Allow-Origin``. +_settings = get_settings() +_cors = _settings.cors_origins.strip() +if _cors: + _origins = [o.strip() for o in _cors.split(",") if o.strip()] +else: + _origins = [] # empty = deny all cross-origin (safest default) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], + allow_origins=_origins or ["http://localhost:5173"], # dev fallback + allow_methods=["GET", "PATCH", "POST"], allow_headers=["*"], + allow_credentials=False, ) app.include_router(customer.router) diff --git a/yunke-server/app/api/routers/organization.py b/yunke-server/app/api/routers/organization.py index e283144..dec8b56 100644 --- a/yunke-server/app/api/routers/organization.py +++ b/yunke-server/app/api/routers/organization.py @@ -10,9 +10,10 @@ Read-only queries over the mirrored ``department``, ``staff``, * ``GET /api/accounts`` -> ``Page[AccountOut]`` projecting both staff-messaging account tables onto a unified shape (see :class:`AccountOut`). The optional ``channel`` filter restricts to one of ``wechat`` / ``whatsapp``; when - omitted, both tables are unioned in memory. Staff-account volumes are modest - (tens to low hundreds of rows), so the in-memory merge is cheap and keeps - the API contract simple. + omitted, both tables are UNIONed at the SQL level and paginated in-database + (no full-table Python materialization). Staff-account volumes are modest in + practice today, but the SQL-level pagination keeps the endpoint safe if a + tenant ever scales past thousands of rows. """ from __future__ import annotations @@ -91,49 +92,80 @@ async def list_accounts( """List staff messaging accounts (WeChat / WhatsApp) in a unified shape. ``channel`` optional; one of ``wechat`` / ``whatsapp``. When omitted, both - tables are merged in memory. Pagination is applied after the merge using a - stable ``(channel, account_id)`` ordering. + tables are UNIONed at the SQL level. Pagination and total count are pushed + down to the database (single ``COUNT(*)`` over the UNION subquery + a + paginated ``SELECT`` with ``OFFSET``/``LIMIT``) so we never materialize the + full cross-table row set in Python. Stable ordering is ``(channel, + account_id)``. + + ``AccountOut`` field mapping (identical shape from both source tables): + + * WeChat: account_id=wx_id, nickname=nick_name, user_id, user_name, phone, + contacts_num, group_num, is_on_job, raw_json (verbatim upstream record). + * WhatsApp: account_id=uid (+ same projection). """ - selected_channels = [channel] if channel else ["wechat", "whatsapp"] + wechat_q = sa.select( + WechatAccount.wx_id.label("account_id"), + sa.literal("wechat").label("channel"), + WechatAccount.nick_name.label("nickname"), + WechatAccount.user_id.label("user_id"), + WechatAccount.user_name.label("user_name"), + WechatAccount.phone.label("phone"), + WechatAccount.contacts_num.label("contacts_num"), + WechatAccount.group_num.label("group_num"), + WechatAccount.is_on_job.label("is_on_job"), + WechatAccount.raw_json.label("raw_json"), + ) + whatsapp_q = sa.select( + WhatsappAccount.uid.label("account_id"), + sa.literal("whatsapp").label("channel"), + WhatsappAccount.nick_name.label("nickname"), + WhatsappAccount.user_id.label("user_id"), + WhatsappAccount.user_name.label("user_name"), + WhatsappAccount.phone.label("phone"), + WhatsappAccount.contacts_num.label("contacts_num"), + WhatsappAccount.group_num.label("group_num"), + WhatsappAccount.is_on_job.label("is_on_job"), + WhatsappAccount.raw_json.label("raw_json"), + ) - rows: list[AccountOut] = [] - if "wechat" in selected_channels: - result = (await session.execute(sa.select(WechatAccount))).scalars().all() - for r in result: - rows.append( - AccountOut( - channel="wechat", - account_id=r.wx_id, - nickname=r.nick_name, - user_id=r.user_id, - user_name=r.user_name, - phone=r.phone, - contacts_num=r.contacts_num, - group_num=r.group_num, - is_on_job=r.is_on_job, - raw_json=r.raw_json, - ) - ) - if "whatsapp" in selected_channels: - result = (await session.execute(sa.select(WhatsappAccount))).scalars().all() - for r in result: - rows.append( - AccountOut( - channel="whatsapp", - account_id=r.uid, - nickname=r.nick_name, - user_id=r.user_id, - user_name=r.user_name, - phone=r.phone, - contacts_num=r.contacts_num, - group_num=r.group_num, - is_on_job=r.is_on_job, - raw_json=r.raw_json, - ) - ) + if channel == "wechat": + parts: list[sa.Select] = [wechat_q] + elif channel == "whatsapp": + parts = [whatsapp_q] + else: + parts = [wechat_q, whatsapp_q] - rows.sort(key=lambda a: (a.channel, a.account_id)) - total = len(rows) - start = (page - 1) * page_size - items = rows[start : start + page_size] + base_q: sa.Select = parts[0] if len(parts) == 1 else sa.union_all(*parts) + + # Wrap in a subquery so COUNT and paginated SELECT both have a stable + # column set (the labelled columns become ``sub.c.