Compare commits

...

1 Commits

Author SHA1 Message Date
unknown 252a2fdc5d 功能:支持动态配置每日比价次数上限 2026-07-28 21:49:24 +08:00
6 changed files with 74 additions and 13 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ class RiskMonitorSummary(BaseModel):
class RiskRuleConfig(BaseModel): class RiskRuleConfig(BaseModel):
sms_hourly_threshold: int = Field(ge=1, le=5) sms_hourly_threshold: int = Field(ge=1, le=5)
oneclick_daily_threshold: int = Field(ge=1, le=100_000) 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): class RiskIncidentItem(BaseModel):
+7 -5
View File
@@ -38,13 +38,14 @@ router = APIRouter(prefix="/api/v1/compare", tags=["compare-record"])
@router.post( @router.post(
"/start", "/start",
response_model=CompareStartReserveOut, response_model=CompareStartReserveOut,
summary="预占一次当日比价发起次数(每人每天最多100次", summary="预占一次当日比价发起次数(上限由风控监控配置",
) )
def reserve_compare_start( def reserve_compare_start(
payload: CompareStartReserveIn, payload: CompareStartReserveIn,
user: CurrentUser, user: CurrentUser,
db: DbSession, db: DbSession,
) -> CompareStartReserveOut: ) -> CompareStartReserveOut:
daily_limit = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY)
if risk_repo.is_restricted( if risk_repo.is_restricted(
db, db,
subject_type="user", subject_type="user",
@@ -57,25 +58,26 @@ def reserve_compare_start(
db, db,
user_id=user.id, user_id=user.id,
trace_id=payload.trace_id, trace_id=payload.trace_id,
daily_limit=daily_limit,
business_type=payload.business_type, business_type=payload.business_type,
device_id=payload.device_id, device_id=payload.device_id,
) )
except crud_compare.DailyCompareStartLimitExceeded: except crud_compare.DailyCompareStartLimitExceeded:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS, status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="今日已比价超过100次,请明天再试", detail=f"今日已比价超过{daily_limit}次,请明天再试",
) from None ) from None
except crud_compare.ComparisonTraceOwnershipError: except crud_compare.ComparisonTraceOwnershipError:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="比价任务标识冲突,请重新发起", detail="比价任务标识冲突,请重新发起",
) from None ) from None
# 风控阈值由后台动态配置,不能再只在固定 100 次业务上限处同步 # 告警和业务限流共用同一动态阈值,避免后台已修改但用户侧仍固定 100 次。
risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at) risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at)
return CompareStartReserveOut( return CompareStartReserveOut(
limit=crud_compare.DAILY_COMPARE_START_LIMIT, limit=daily_limit,
used=used, used=used,
remaining=max(crud_compare.DAILY_COMPARE_START_LIMIT - used, 0), remaining=max(daily_limit - used, 0),
) )
+3 -3
View File
@@ -134,12 +134,12 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = {
}, },
RISK_COMPARE_DAILY_THRESHOLD_KEY: { RISK_COMPARE_DAILY_THRESHOLD_KEY: {
"default": 100, "default": 100,
"label": "比价账户每日告警阈值", "label": "比价账户每日上限与告警阈值",
"group": "风控", "group": "风控",
"type": "int", "type": "int",
"min": 1, "min": 1,
"max": 100, "max": 100_000,
"hidden": True, "hidden": True,
"help": "同一账户北京时间同一自然日发起比价达到该次数时告警。", "help": "同一账户北京时间自然日最多可发起比价次数;达到该次数时同步告警。",
}, },
} }
+3 -4
View File
@@ -17,8 +17,6 @@ from app.models.savings import SavingsRecord
from app.models.user import User from app.models.user import User
from app.schemas.compare_record import ComparisonRecordIn from app.schemas.compare_record import ComparisonRecordIn
DAILY_COMPARE_START_LIMIT = 100
class DailyCompareStartLimitExceeded(Exception): class DailyCompareStartLimitExceeded(Exception):
"""The authenticated user has consumed today's comparison-start quota.""" """The authenticated user has consumed today's comparison-start quota."""
@@ -363,11 +361,12 @@ def reserve_daily_start(
*, *,
user_id: int, user_id: int,
trace_id: str, trace_id: str,
daily_limit: int,
business_type: str = "food", business_type: str = "food",
device_id: str | None = None, device_id: str | None = None,
now: datetime | None = None, now: datetime | None = None,
) -> tuple[ComparisonRecord, int]: ) -> 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 ``trace_id`` makes client retries idempotent. Locking the user row serializes
concurrent starts for one account, so parallel requests cannot both consume concurrent starts for one account, so parallel requests cannot both consume
@@ -413,7 +412,7 @@ def reserve_daily_start(
ComparisonRecord.created_at < day_end, ComparisonRecord.created_at < day_end,
) )
) or 0 ) or 0
if used >= DAILY_COMPARE_START_LIMIT: if used >= daily_limit:
raise DailyCompareStartLimitExceeded raise DailyCompareStartLimitExceeded
rec = ComparisonRecord( rec = ComparisonRecord(
+48
View File
@@ -3,14 +3,32 @@ from __future__ import annotations
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
import pytest
from sqlalchemy import func, select 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.rewards import CN_TZ
from app.core.security import decode_token from app.core.security import decode_token
from app.db.session import SessionLocal from app.db.session import SessionLocal
from app.models.app_config import AppConfig
from app.models.comparison import ComparisonRecord 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]: def _login(client) -> tuple[str, int]:
phone = f"137{int(time.time() * 1000) % 100000000:08d}" phone = f"137{int(time.time() * 1000) % 100000000:08d}"
sent = client.post("/api/v1/auth/sms/send", json={"phone": phone}) 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 ComparisonRecord.trace_id == rejected_trace
) )
) == 0 ) == 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次,请明天再试"
+12
View File
@@ -529,6 +529,18 @@ def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None:
now + timedelta(seconds=2) now + timedelta(seconds=2)
).replace(tzinfo=None) ).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( invalid = client.patch(
"/admin/api/risk-monitor/rules", "/admin/api/risk-monitor/rules",
headers=headers, headers=headers,