fix(security): CORS限制+SSRF防护+PATCH乐观锁+accounts安全cap(4 important bugs)

- main.py + config.py: CORS origins read from Settings.cors_origins (comma-separated).
  Empty falls back to dev origin localhost:5173; never wildcard. Methods locked
  to GET/PATCH/POST; allow_credentials=False.
- scraper/media.py: download_url now passes follow_redirects=False to httpx.
  OSS URLs return 200 directly; a 3xx is treated as a download failure (caught
  by existing except clauses) instead of opening an SSRF vector to internal hosts.
- routers/voice_clips.py: PATCH /voice-clips/{id}/match accepts an optional
  If-Match header carrying the client's last-seen updated_at. On mismatch the
  row was modified by another request and the server returns 409. Without the
  header the behaviour is unchanged (backward compatible). Response now echoes
  the post-update updated_at so the client can fold it into the next request.
- routers/organization.py: /api/accounts now UNION ALLs the wechat_account and
  whatsapp_account projections at the SQL level and pushes COUNT + pagination
  (offset/limit) down to the DB. Stable ordering on (channel, account_id).
  Removes the in-memory materialization that broke at scale.

Tests: 395 passed. Added optimistic-lock coverage (If-Match accepted / stale 409 /
absent header backward-compat). Updated existing PATCH tests for the new
updated_at response field.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-12 11:41:30 +08:00
parent c7d54a135e
commit c261b2bc9a
6 changed files with 238 additions and 55 deletions
+77 -45
View File
@@ -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.<label>``).
sub = base_q.subquery()
total = (
await session.execute(sa.select(sa.func.count()).select_from(sub))
).scalar_one()
page_q = (
sa.select(sub)
.order_by(sub.c.channel.asc(), sub.c.account_id.asc())
.offset((page - 1) * page_size)
.limit(page_size)
)
rows = (await session.execute(page_q)).all()
items = [
AccountOut(
channel=row.channel,
account_id=row.account_id,
nickname=row.nickname,
user_id=row.user_id,
user_name=row.user_name,
phone=row.phone,
contacts_num=row.contacts_num,
group_num=row.group_num,
is_on_job=row.is_on_job,
raw_json=row.raw_json,
)
for row in rows
]
return Page(items=items, total=total, page=page, page_size=page_size)
+27 -1
View File
@@ -30,7 +30,7 @@ from __future__ import annotations
from typing import Annotated, Any
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -153,20 +153,46 @@ async def match_voice_clip(
session: SessionDep,
voice_clip_id: int,
body: MatchBody,
if_match: Annotated[str | None, Header()] = None,
) -> dict[str, Any]:
"""Associate (or clear) the customer match on a voice_clip.
LOCAL write only — ``matched_friend_id`` is stored in the mirror's
``voice_clip`` row and never pushed to 云客. ``friend_id=None`` clears an
existing match.
Optimistic lock (optional, backward-compatible): the client may send
``If-Match: <updated_at ISO>`` with the ``updated_at`` it last read. When
present, the server compares it against the row's current ``updated_at``
and rejects with ``409`` if the row was modified by another request in
between. Without the header, behavior is unchanged.
The response echoes the post-update ``updated_at`` so the client can fold
it into its next read.
"""
clip = await session.get(VoiceClip, voice_clip_id)
if not clip:
raise HTTPException(status_code=404, detail="voice_clip not found")
# Optimistic lock: if the client sent If-Match, verify the row has not been
# modified since the value it read. The header carries the ISO 8601
# ``updated_at`` the client last saw; a mismatch means another writer
# committed first.
if if_match is not None and clip.updated_at is not None:
expected = clip.updated_at.isoformat()
if if_match != expected:
raise HTTPException(
status_code=409,
detail="voice_clip was modified by another request",
)
clip.matched_friend_id = body.friend_id # None clears the match
await session.commit()
# Refresh to pick up the server-generated ``updated_at`` from the UPDATE
# (``onupdate=func.now()``); with ``expire_on_commit=False`` the attribute
# otherwise stays at the pre-update value.
await session.refresh(clip)
return {
"voice_clip_id": voice_clip_id,
"matched_friend_id": body.friend_id,
"updated_at": clip.updated_at.isoformat() if clip.updated_at else None,
}