From 252a2fdc5dc1dfdd9c4dbc4ea8cd5d8f09b484ba Mon Sep 17 00:00:00 2001 From: unknown <798648091@qq.com> Date: Tue, 28 Jul 2026 21:49:24 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=9A=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E9=85=8D=E7=BD=AE=E6=AF=8F=E6=97=A5=E6=AF=94?= =?UTF-8?q?=E4=BB=B7=E6=AC=A1=E6=95=B0=E4=B8=8A=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/schemas/risk_monitor.py | 2 +- app/api/v1/compare_record.py | 12 ++++---- app/core/config_schema.py | 6 ++-- app/repositories/comparison.py | 7 ++--- tests/test_compare_daily_limit.py | 48 +++++++++++++++++++++++++++++++ tests/test_risk_monitor.py | 12 ++++++++ 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/app/admin/schemas/risk_monitor.py b/app/admin/schemas/risk_monitor.py index 826393e..a98c67b 100644 --- a/app/admin/schemas/risk_monitor.py +++ b/app/admin/schemas/risk_monitor.py @@ -23,7 +23,7 @@ class RiskMonitorSummary(BaseModel): class RiskRuleConfig(BaseModel): sms_hourly_threshold: int = Field(ge=1, le=5) oneclick_daily_threshold: int = Field(ge=1, le=100_000) - compare_daily_threshold: int = Field(ge=1, le=100) + compare_daily_threshold: int = Field(ge=1, le=100_000) class RiskIncidentItem(BaseModel): diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 3d2b484..aa777ba 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -38,13 +38,14 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"]) @router.post( "/start", response_model=CompareStartReserveOut, - summary="预占一次当日比价发起次数(每人每天最多100次)", + summary="预占一次当日比价发起次数(上限由风控监控配置)", ) def reserve_compare_start( payload: CompareStartReserveIn, user: CurrentUser, db: DbSession, ) -> CompareStartReserveOut: + daily_limit = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY) if risk_repo.is_restricted( db, subject_type="user", @@ -57,25 +58,26 @@ def reserve_compare_start( db, user_id=user.id, trace_id=payload.trace_id, + daily_limit=daily_limit, business_type=payload.business_type, device_id=payload.device_id, ) except crud_compare.DailyCompareStartLimitExceeded: raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, - detail="今日已比价超过100次,请明天再试", + detail=f"今日已比价超过{daily_limit}次,请明天再试", ) from None except crud_compare.ComparisonTraceOwnershipError: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="比价任务标识冲突,请重新发起", ) from None - # 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。 + # 告警和业务限流共用同一动态阈值,避免后台已修改但用户侧仍固定为 100 次。 risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at) return CompareStartReserveOut( - limit=crud_compare.DAILY_COMPARE_START_LIMIT, + limit=daily_limit, used=used, - remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0), + remaining=max(daily_limit - used, 0), ) diff --git a/app/core/config_schema.py b/app/core/config_schema.py index 9a63720..5656512 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -134,12 +134,12 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { }, RISK_COMPARE_DAILY_THRESHOLD_KEY: { "default": 100, - "label": "比价账户每日告警阈值", + "label": "比价账户每日上限与告警阈值", "group": "风控", "type": "int", "min": 1, - "max": 100, + "max": 100_000, "hidden": True, - "help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。", + "help": "同一账户按北京时间自然日最多可发起的比价次数;达到该次数时同步告警。", }, } diff --git a/app/repositories/comparison.py b/app/repositories/comparison.py index 948ae77..74c0b23 100644 --- a/app/repositories/comparison.py +++ b/app/repositories/comparison.py @@ -17,8 +17,6 @@ from app.models.savings import SavingsRecord from app.models.user import User from app.schemas.compare_record import ComparisonRecordIn -DAILY_COMPARE_START_LIMIT = 100 - class DailyCompareStartLimitExceeded(Exception): """The authenticated user has consumed today's comparison-start quota.""" @@ -363,11 +361,12 @@ def reserve_daily_start( *, user_id: int, trace_id: str, + daily_limit: int, business_type: str = "food", device_id: str | None = None, now: datetime | None = None, ) -> tuple[ComparisonRecord, int]: - """Atomically reserve one of a user's 100 Beijing-day comparison starts. + """Atomically reserve one of a user's configured Beijing-day comparison starts. ``trace_id`` makes client retries idempotent. Locking the user row serializes concurrent starts for one account, so parallel requests cannot both consume @@ -413,7 +412,7 @@ def reserve_daily_start( ComparisonRecord.created_at < day_end, ) ) or 0 - if used >= DAILY_COMPARE_START_LIMIT: + if used >= daily_limit: raise DailyCompareStartLimitExceeded rec = ComparisonRecord( diff --git a/tests/test_compare_daily_limit.py b/tests/test_compare_daily_limit.py index a10afec..dfb791d 100644 --- a/tests/test_compare_daily_limit.py +++ b/tests/test_compare_daily_limit.py @@ -3,14 +3,32 @@ from __future__ import annotations import time from datetime import datetime, timedelta +import pytest from sqlalchemy import func, select +from app.core.config_schema import RISK_COMPARE_DAILY_THRESHOLD_KEY from app.core.rewards import CN_TZ from app.core.security import decode_token from app.db.session import SessionLocal +from app.models.app_config import AppConfig from app.models.comparison import ComparisonRecord +@pytest.fixture(autouse=True) +def _reset_compare_daily_limit(): + with SessionLocal() as db: + db.query(AppConfig).filter( + AppConfig.key == RISK_COMPARE_DAILY_THRESHOLD_KEY + ).delete() + db.commit() + yield + with SessionLocal() as db: + db.query(AppConfig).filter( + AppConfig.key == RISK_COMPARE_DAILY_THRESHOLD_KEY + ).delete() + db.commit() + + def _login(client) -> tuple[str, int]: phone = f"137{int(time.time() * 1000) % 100000000:08d}" sent = client.post("/api/v1/auth/sms/send", json={"phone": phone}) @@ -118,3 +136,33 @@ def test_compare_start_rejects_101st_beijing_day_attempt(client) -> None: ComparisonRecord.trace_id == rejected_trace ) ) == 0 + + +def test_compare_start_uses_dynamic_risk_monitor_limit(client) -> None: + with SessionLocal() as db: + db.add(AppConfig(key=RISK_COMPARE_DAILY_THRESHOLD_KEY, value=2)) + db.commit() + + token, user_id = _login(client) + first = client.post( + "/api/v1/compare/start", + json={"trace_id": f"quota-dynamic-{user_id}-1"}, + headers=_headers(token), + ) + second = client.post( + "/api/v1/compare/start", + json={"trace_id": f"quota-dynamic-{user_id}-2"}, + headers=_headers(token), + ) + rejected = client.post( + "/api/v1/compare/start", + json={"trace_id": f"quota-dynamic-{user_id}-3"}, + headers=_headers(token), + ) + + assert first.status_code == 200 + assert first.json() == {"limit": 2, "used": 1, "remaining": 1} + assert second.status_code == 200 + assert second.json() == {"limit": 2, "used": 2, "remaining": 0} + assert rejected.status_code == 429 + assert rejected.json()["detail"] == "今日已比价超过2次,请明天再试" diff --git a/tests/test_risk_monitor.py b/tests/test_risk_monitor.py index b358cd3..ce85ced 100644 --- a/tests/test_risk_monitor.py +++ b/tests/test_risk_monitor.py @@ -529,6 +529,18 @@ def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None: now + timedelta(seconds=2) ).replace(tzinfo=None) + compare_limit = client.patch( + "/admin/api/risk-monitor/rules", + headers=headers, + json={ + "sms_hourly_threshold": 3, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 120, + }, + ) + assert compare_limit.status_code == 200 + assert compare_limit.json()["compare_daily_threshold"] == 120 + invalid = client.patch( "/admin/api/risk-monitor/rules", headers=headers,