Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 252a2fdc5d |
@@ -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):
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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": "同一账户按北京时间自然日最多可发起的比价次数;达到该次数时同步告警。",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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次,请明天再试"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user