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
+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,
}