diff --git a/alembic/versions/risk_monitor_generic_tables.py b/alembic/versions/risk_monitor_generic_tables.py new file mode 100644 index 0000000..b4d4c3e --- /dev/null +++ b/alembic/versions/risk_monitor_generic_tables.py @@ -0,0 +1,210 @@ +"""add generic behavior, risk incident and subject restriction tables + +Revision ID: risk_monitor_generic +Revises: drop_withdraw_active_uniq_idx +""" +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +from alembic import op + +revision: str = "risk_monitor_generic" +down_revision: str | None = "drop_withdraw_active_uniq_idx" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_JSON = sa.JSON().with_variant(postgresql.JSONB(), "postgresql") +_RISK_MONITOR_PAGE = "risk-monitor" +_DEFAULT_RISK_MONITOR_ROLES = ("operator", "tech") + + +def _role_table() -> sa.TableClause: + return sa.table( + "admin_role", + sa.column("name", sa.String), + sa.column("pages", _JSON), + ) + + +def _add_default_role_permissions() -> None: + """Grant the new page without replacing any existing role customisation.""" + role = _role_table() + conn = op.get_bind() + rows = conn.execute( + sa.select(role.c.name, role.c.pages).where( + role.c.name.in_(_DEFAULT_RISK_MONITOR_ROLES) + ) + ).all() + for name, pages in rows: + current_pages = list(pages or []) + if _RISK_MONITOR_PAGE not in current_pages: + conn.execute( + role.update() + .where(role.c.name == name) + .values(pages=[*current_pages, _RISK_MONITOR_PAGE]) + ) + + +def _remove_default_role_permissions() -> None: + role = _role_table() + conn = op.get_bind() + rows = conn.execute( + sa.select(role.c.name, role.c.pages).where( + role.c.name.in_(_DEFAULT_RISK_MONITOR_ROLES) + ) + ).all() + for name, pages in rows: + current_pages = list(pages or []) + if _RISK_MONITOR_PAGE in current_pages: + conn.execute( + role.update() + .where(role.c.name == name) + .values( + pages=[ + page + for page in current_pages + if page != _RISK_MONITOR_PAGE + ] + ) + ) + + +def upgrade() -> None: + op.create_table( + "behavior_event", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("subject_type", sa.String(length=32), nullable=False), + sa.Column("subject_id", sa.String(length=128), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("device_id", sa.String(length=128), nullable=True), + sa.Column("device_model", sa.String(length=128), nullable=True), + sa.Column("phone", sa.String(length=20), nullable=True), + sa.Column("client_ip", sa.String(length=64), nullable=True), + sa.Column("outcome", sa.String(length=24), nullable=False), + sa.Column("reason", sa.String(length=256), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_behavior_event_occurred_at", "behavior_event", ["occurred_at"]) + op.create_index( + "ix_behavior_event_type_time", "behavior_event", ["event_type", "occurred_at"] + ) + op.create_index( + "ix_behavior_event_subject_time", + "behavior_event", + ["subject_type", "subject_id", "occurred_at"], + ) + op.create_index( + "ix_behavior_event_user_time", "behavior_event", ["user_id", "occurred_at"] + ) + + op.create_table( + "risk_incident", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("rule_code", sa.String(length=64), nullable=False), + sa.Column("event_type", sa.String(length=64), nullable=False), + sa.Column("subject_type", sa.String(length=32), nullable=False), + sa.Column("subject_id", sa.String(length=128), nullable=False), + sa.Column("window_key", sa.String(length=64), nullable=False), + sa.Column("window_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("window_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("first_event_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("triggered_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_event_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("event_count", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("action_reason", sa.String(length=256), nullable=True), + sa.Column("handled_by", sa.Integer(), nullable=True), + sa.Column("handled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "rule_code", + "subject_type", + "subject_id", + "window_key", + name="uq_risk_incident_rule_subject_window", + ), + ) + op.create_index( + "ix_risk_incident_rule_status", + "risk_incident", + ["rule_code", "status", "triggered_at"], + ) + op.create_index( + "ix_risk_incident_subject", + "risk_incident", + ["subject_type", "subject_id", "triggered_at"], + ) + + op.create_table( + "subject_restriction", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("subject_type", sa.String(length=32), nullable=False), + sa.Column("subject_id", sa.String(length=128), nullable=False), + sa.Column("scope", sa.String(length=32), nullable=False), + sa.Column("active", sa.Boolean(), server_default=sa.true(), nullable=False), + sa.Column("reason", sa.String(length=256), nullable=True), + sa.Column("incident_id", sa.Integer(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("(CURRENT_TIMESTAMP)"), + nullable=False, + ), + sa.Column("revoked_by", sa.Integer(), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "subject_type", + "subject_id", + "scope", + name="uq_subject_restriction_subject_scope", + ), + ) + op.create_index( + "ix_subject_restriction_lookup", + "subject_restriction", + ["subject_type", "subject_id", "scope", "active"], + ) + _add_default_role_permissions() + + +def downgrade() -> None: + _remove_default_role_permissions() + op.drop_index("ix_subject_restriction_lookup", table_name="subject_restriction") + op.drop_table("subject_restriction") + op.drop_index("ix_risk_incident_subject", table_name="risk_incident") + op.drop_index("ix_risk_incident_rule_status", table_name="risk_incident") + op.drop_table("risk_incident") + op.drop_index("ix_behavior_event_user_time", table_name="behavior_event") + op.drop_index("ix_behavior_event_subject_time", table_name="behavior_event") + op.drop_index("ix_behavior_event_type_time", table_name="behavior_event") + op.drop_index("ix_behavior_event_occurred_at", table_name="behavior_event") + op.drop_table("behavior_event") diff --git a/app/admin/main.py b/app/admin/main.py index 918b238..ec64c34 100644 --- a/app/admin/main.py +++ b/app/admin/main.py @@ -17,6 +17,7 @@ from app.admin.routers.ad_audit import router as ad_audit_router from app.admin.routers.ad_config import router as ad_config_router from app.admin.routers.ad_revenue import router as ad_revenue_router from app.admin.routers.admins import router as admins_router +from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.audit import router as audit_router from app.admin.routers.auth import router as auth_router from app.admin.routers.comparison import router as comparison_router @@ -25,8 +26,6 @@ from app.admin.routers.coupon_data import router as coupon_data_router from app.admin.routers.cps import router as cps_router from app.admin.routers.dashboard import router as dashboard_router from app.admin.routers.device_liveness import router as device_liveness_router -from app.admin.routers.ops_stat_config import router as ops_stat_config_router -from app.admin.routers.analytics_health import router as analytics_health_router from app.admin.routers.event_logs import router as event_logs_router from app.admin.routers.feedback import router as feedback_router from app.admin.routers.feedback_qr import router as feedback_qr_router @@ -34,7 +33,9 @@ from app.admin.routers.guide_video import router as guide_video_router from app.admin.routers.huawei_review import router as huawei_review_router from app.admin.routers.onboarding import router as onboarding_router from app.admin.routers.ops_marquee_seed import router as ops_marquee_seed_router +from app.admin.routers.ops_stat_config import router as ops_stat_config_router from app.admin.routers.price_report import router as price_report_router +from app.admin.routers.risk_monitor import router as risk_monitor_router from app.admin.routers.roles import router as roles_router from app.admin.routers.users import router as users_router from app.admin.routers.wallet import router as wallet_router @@ -101,6 +102,7 @@ admin_app.include_router(onboarding_router) admin_app.include_router(wallet_router) admin_app.include_router(withdraw_router) admin_app.include_router(price_report_router) +admin_app.include_router(risk_monitor_router) admin_app.include_router(feedback_router) admin_app.include_router(event_logs_router) admin_app.include_router(analytics_health_router) diff --git a/app/admin/permissions.py b/app/admin/permissions.py index 837e4be..e8158c1 100644 --- a/app/admin/permissions.py +++ b/app/admin/permissions.py @@ -34,6 +34,7 @@ PERMISSION_CATALOG: list[dict] = [ {"key": "users", "label": "用户管理"}, ]}, {"group": "监控审计", "pages": [ + {"key": "risk-monitor", "label": "风控监控"}, {"key": "device-liveness", "label": "设备存活"}, {"key": "analytics-health", "label": "埋点成功率"}, {"key": "event-logs", "label": "埋点日志"}, @@ -55,13 +56,13 @@ BUILTIN_ROLES: list[dict] = [ {"name": SUPER_ADMIN_ROLE, "label": "管理员", "pages": []}, {"name": "operator", "label": "运营", "pages": [ "dashboard", "coupon-data", "ad-revenue-report", "comparison-records", - "cps", "device-liveness", "price-reports", "feedbacks", "huawei-review", + "cps", "risk-monitor", "device-liveness", "price-reports", "feedbacks", "huawei-review", ]}, {"name": "finance", "label": "财务", "pages": [ "dashboard", "ad-revenue-report", "cps", "withdraws", ]}, {"name": "tech", "label": "技术", "pages": [ - "dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", + "dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", "event-logs", "audit-logs", ]}, ] diff --git a/app/admin/repositories/risk_monitor.py b/app/admin/repositories/risk_monitor.py new file mode 100644 index 0000000..8ae482d --- /dev/null +++ b/app/admin/repositories/risk_monitor.py @@ -0,0 +1,541 @@ +"""风控监控读模型:聚合通用行为流水、风险事件与比价记录。""" +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from sqlalchemy import desc, func, select +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.models.comparison import ComparisonRecord +from app.models.device import DeviceLiveness +from app.models.risk import BehaviorEvent, RiskIncident +from app.models.user import User +from app.repositories import risk as risk_repo + +KIND_TO_RULE = { + "sms": risk_repo.RULE_SMS_HOURLY, + "oneclick": risk_repo.RULE_ONECLICK_DAILY, + "compare": risk_repo.RULE_COMPARE_DAILY, +} +RULE_TO_KIND = {rule: kind for kind, rule in KIND_TO_RULE.items()} + + +def _day_bounds(now: datetime | None = None) -> tuple[str, datetime, datetime]: + current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ) + start = current.replace(hour=0, minute=0, second=0, microsecond=0) + end = start + timedelta(days=1) + return ( + start.strftime("%Y-%m-%d"), + start.astimezone(UTC), + end.astimezone(UTC), + ) + + +def _compare_day_bounds(now: datetime | None = None) -> tuple[datetime, datetime]: + current = (now or datetime.now(UTC)).astimezone(risk_repo.CN_TZ).replace( + tzinfo=None + ) + start = current.replace(hour=0, minute=0, second=0, microsecond=0) + return start, start + timedelta(days=1) + + +def sync_today_compare_incidents(db: Session, now: datetime | None = None) -> None: + risk_repo.reconcile_compare_rule(db, at=now) + + +def reset_open_alerts( + db: Session, *, admin_id: int, reset_at: datetime +) -> dict[str, int]: + """将三类待处理报警归零,并从 reset_at 开始重新累计。""" + rule_codes = tuple(KIND_TO_RULE.values()) + incidents = list( + db.scalars( + select(RiskIncident).where( + RiskIncident.rule_code.in_(rule_codes), + RiskIncident.status == "open", + ) + ).all() + ) + counts = {kind: 0 for kind in KIND_TO_RULE} + for incident in incidents: + incident.status = "resolved" + incident.action_reason = risk_repo.MANUAL_RESET_REASON + incident.handled_by = admin_id + incident.handled_at = reset_at + counts[RULE_TO_KIND[incident.rule_code]] += 1 + risk_repo.reset_rule_baselines( + db, + rule_codes=rule_codes, + reset_at=reset_at, + admin_id=admin_id, + ) + return counts + + +def summary(db: Session, now: datetime | None = None) -> dict: + date_key, start, end = _day_bounds(now) + compare_start, compare_end = _compare_day_bounds(now) + current = now or datetime.now(UTC) + risk_repo.reconcile_behavior_rule( + db, rule_code=risk_repo.RULE_SMS_HOURLY, at=current, commit=False + ) + risk_repo.reconcile_behavior_rule( + db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=current, commit=False + ) + risk_repo.reconcile_compare_rule(db, at=current, commit=False) + db.commit() + sms_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_SMS_HOURLY) + oneclick_threshold = risk_repo.get_rule_threshold( + db, risk_repo.RULE_ONECLICK_DAILY + ) + compare_threshold = risk_repo.get_rule_threshold(db, risk_repo.RULE_COMPARE_DAILY) + + def _behavior_total(event_type: str, outcomes: tuple[str, ...]) -> int: + return int( + db.scalar( + select(func.count(BehaviorEvent.id)).where( + BehaviorEvent.event_type == event_type, + BehaviorEvent.outcome.in_(outcomes), + BehaviorEvent.occurred_at >= start, + BehaviorEvent.occurred_at < end, + ) + ) + or 0 + ) + + compare_total = int( + db.scalar( + select(func.count(ComparisonRecord.id)).where( + ComparisonRecord.created_at >= compare_start, + ComparisonRecord.created_at < compare_end, + ) + ) + or 0 + ) + + def _open_count(rule_code: str) -> int: + return int( + db.scalar( + select(func.count(RiskIncident.id)).where( + RiskIncident.rule_code == rule_code, + RiskIncident.status == "open", + RiskIncident.window_key.like(f"{date_key}%"), + ) + ) + or 0 + ) + + return { + "date": date_key, + "updated_at": now or datetime.now(UTC), + "cards": [ + { + "kind": "sms", + "alert_subject_count": _open_count(risk_repo.RULE_SMS_HOURLY), + "today_total": _behavior_total(risk_repo.EVENT_SMS_SEND, ("success",)), + "threshold": sms_threshold, + "rule_text": f"单设备 1 小时 ≥ {sms_threshold} 次", + }, + { + "kind": "oneclick", + "alert_subject_count": _open_count(risk_repo.RULE_ONECLICK_DAILY), + "today_total": _behavior_total( + risk_repo.EVENT_ONECLICK_LOGIN, ("success", "failed") + ), + "threshold": oneclick_threshold, + "rule_text": f"单设备当日 ≥ {oneclick_threshold} 次", + }, + { + "kind": "compare", + "alert_subject_count": _open_count(risk_repo.RULE_COMPARE_DAILY), + "today_total": compare_total, + "threshold": compare_threshold, + "rule_text": f"单账户当日 ≥ {compare_threshold} 次", + }, + ], + } + + +def _latest_device_model(db: Session, subject_id: str) -> str | None: + return db.execute( + select(BehaviorEvent.device_model) + .where( + BehaviorEvent.subject_type == "device", + BehaviorEvent.subject_id == subject_id, + BehaviorEvent.device_model.is_not(None), + BehaviorEvent.device_model != "", + ) + .order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc()) + .limit(1) + ).scalar_one_or_none() + + +def _first_used_at(db: Session, subject_id: str) -> datetime | None: + behavior_first = db.scalar( + select(func.min(BehaviorEvent.occurred_at)).where( + BehaviorEvent.subject_type == "device", + BehaviorEvent.subject_id == subject_id, + ) + ) + registered_first = db.scalar( + select(func.min(DeviceLiveness.created_at)).where( + DeviceLiveness.device_id == subject_id + ) + ) + observed = [value for value in (behavior_first, registered_first) if value is not None] + if not observed: + return None + + def _utc(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + return min(observed, key=_utc) + + +def _common_device(db: Session, user_id: int, start: datetime, end: datetime) -> str | None: + return db.execute( + select(ComparisonRecord.device_id) + .where( + ComparisonRecord.user_id == user_id, + ComparisonRecord.device_id.is_not(None), + ComparisonRecord.created_at >= start, + ComparisonRecord.created_at < end, + ) + .group_by(ComparisonRecord.device_id) + .order_by(func.count(ComparisonRecord.id).desc(), ComparisonRecord.device_id.asc()) + .limit(1) + ).scalar_one_or_none() + + +def list_incidents( + db: Session, + *, + kind: str, + status: str = "open", + limit: int = 20, + cursor: int = 0, +) -> tuple[list[dict], int | None, int]: + rule_code = KIND_TO_RULE[kind] + stmt = select(RiskIncident).where(RiskIncident.rule_code == rule_code) + count_stmt = select(func.count(RiskIncident.id)).where( + RiskIncident.rule_code == rule_code + ) + if status != "all": + stmt = stmt.where(RiskIncident.status == status) + count_stmt = count_stmt.where(RiskIncident.status == status) + total = int(db.scalar(count_stmt) or 0) + incidents = list( + db.scalars( + stmt.order_by(desc(RiskIncident.triggered_at), desc(RiskIncident.id)) + .offset(cursor) + .limit(limit) + ).all() + ) + + user_ids = [int(i.subject_id) for i in incidents if i.subject_type == "user"] + users = { + u.id: u + for u in db.scalars(select(User).where(User.id.in_(user_ids))).all() + } if user_ids else {} + + items: list[dict] = [] + for incident in incidents: + item = { + "incident_id": incident.id, + "kind": kind, + "subject_type": incident.subject_type, + "subject_id": incident.subject_id, + "window_start": incident.window_start, + "window_end": incident.window_end, + "first_event_at": incident.first_event_at, + "triggered_at": incident.triggered_at, + "last_event_at": incident.last_event_at, + "event_count": incident.event_count, + "status": incident.status, + } + if incident.subject_type == "device": + item["device_model"] = _latest_device_model(db, incident.subject_id) + item["first_used_at"] = _first_used_at(db, incident.subject_id) + item["restricted"] = risk_repo.is_restricted( + db, + subject_type="device", + subject_id=incident.subject_id, + scope=risk_repo.SCOPE_AUTH_DEVICE, + ) + else: + uid = int(incident.subject_id) + user = users.get(uid) + item.update( + { + "user_id": uid, + "phone": user.phone if user else None, + "registered_at": user.created_at if user else None, + "common_device_id": _common_device( + db, uid, incident.window_start, incident.window_end + ), + "restricted": risk_repo.is_restricted( + db, + subject_type="user", + subject_id=incident.subject_id, + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ), + } + ) + items.append(item) + next_cursor = cursor + len(items) if cursor + len(items) < total else None + return items, next_cursor, total + + +def _page_events( + db: Session, + incident: RiskIncident, + *, + event_type: str, + outcomes: tuple[str, ...] | None, + limit: int, + cursor: int, +) -> tuple[list[BehaviorEvent], int]: + filters = [ + BehaviorEvent.event_type == event_type, + BehaviorEvent.subject_type == incident.subject_type, + BehaviorEvent.subject_id == incident.subject_id, + BehaviorEvent.occurred_at >= incident.window_start, + BehaviorEvent.occurred_at < incident.window_end, + ] + if outcomes: + filters.append(BehaviorEvent.outcome.in_(outcomes)) + total = int(db.scalar(select(func.count(BehaviorEvent.id)).where(*filters)) or 0) + rows = list( + db.scalars( + select(BehaviorEvent) + .where(*filters) + .order_by(BehaviorEvent.occurred_at.desc(), BehaviorEvent.id.desc()) + .offset(cursor) + .limit(limit) + ).all() + ) + return rows, total + + +def _user_map(db: Session, user_ids: set[int]) -> dict[int, User]: + if not user_ids: + return {} + return {u.id: u for u in db.scalars(select(User).where(User.id.in_(user_ids))).all()} + + +def _distinct_event_users( + db: Session, + incident: RiskIncident, + *, + event_type: str, + outcomes: tuple[str, ...], +) -> int: + return int( + db.scalar( + select(func.count(func.distinct(BehaviorEvent.user_id))).where( + BehaviorEvent.event_type == event_type, + BehaviorEvent.subject_type == incident.subject_type, + BehaviorEvent.subject_id == incident.subject_id, + BehaviorEvent.outcome.in_(outcomes), + BehaviorEvent.user_id.is_not(None), + BehaviorEvent.occurred_at >= incident.window_start, + BehaviorEvent.occurred_at < incident.window_end, + ) + ) + or 0 + ) + + +def _sms_distinct_accounts(db: Session, incident: RiskIncident) -> int: + """统计整个告警窗口内,确实完成本设备短信验证的不同账号数。""" + send_rows = db.execute( + select(BehaviorEvent.phone, BehaviorEvent.occurred_at).where( + BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND, + BehaviorEvent.subject_type == incident.subject_type, + BehaviorEvent.subject_id == incident.subject_id, + BehaviorEvent.outcome == "success", + BehaviorEvent.phone.is_not(None), + BehaviorEvent.occurred_at >= incident.window_start, + BehaviorEvent.occurred_at < incident.window_end, + ) + ).all() + phones = {phone for phone, _ in send_rows} + if not phones: + return 0 + login_rows = db.execute( + select( + BehaviorEvent.user_id, + BehaviorEvent.phone, + BehaviorEvent.occurred_at, + ) + .where( + BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN, + BehaviorEvent.subject_id == incident.subject_id, + BehaviorEvent.phone.in_(phones), + BehaviorEvent.user_id.is_not(None), + BehaviorEvent.outcome == "success", + BehaviorEvent.occurred_at >= incident.window_start, + BehaviorEvent.occurred_at + < incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC), + ) + .order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc()) + ).all() + matched_users = { + user_id + for user_id, phone, login_at in login_rows + if any( + sent_phone == phone + and sent_at <= login_at + and login_at + <= sent_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC) + for sent_phone, sent_at in send_rows + ) + } + return len(matched_users) + + +def incident_details( + db: Session, + *, + incident: RiskIncident, + kind: str, + limit: int, + cursor: int, +) -> dict: + if kind == "sms": + rows, total = _page_events( + db, + incident, + event_type=risk_repo.EVENT_SMS_SEND, + outcomes=("success",), + limit=limit, + cursor=cursor, + ) + phone_values = {r.phone for r in rows if r.phone} + login_rows = list( + db.scalars( + select(BehaviorEvent) + .where( + BehaviorEvent.event_type == risk_repo.EVENT_SMS_LOGIN, + BehaviorEvent.subject_id == incident.subject_id, + BehaviorEvent.phone.in_(phone_values), + BehaviorEvent.outcome == "success", + BehaviorEvent.occurred_at >= incident.window_start, + BehaviorEvent.occurred_at + < incident.window_end + timedelta(seconds=settings.SMS_CODE_TTL_SEC), + ) + .order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc()) + ).all() + ) if phone_values else [] + users = _user_map(db, {r.user_id for r in login_rows if r.user_id is not None}) + items = [] + for row in rows: + match = next( + ( + login + for login in login_rows + if login.phone == row.phone + and login.occurred_at >= row.occurred_at + and login.occurred_at + <= row.occurred_at + timedelta(seconds=settings.SMS_CODE_TTL_SEC) + ), + None, + ) + user = users.get(match.user_id) if match and match.user_id else None + items.append( + { + "id": row.id, + "occurred_at": row.occurred_at, + "outcome": row.outcome, + "phone": row.phone, + "user_id": user.id if user else None, + "username": user.username if user else None, + "account_registered_at": user.created_at if user else None, + "verified": match is not None, + } + ) + distinct_accounts = _sms_distinct_accounts(db, incident) + elif kind == "oneclick": + rows, total = _page_events( + db, + incident, + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + outcomes=("success", "failed"), + limit=limit, + cursor=cursor, + ) + users = _user_map(db, {r.user_id for r in rows if r.user_id is not None}) + items = [] + for row in rows: + user = users.get(row.user_id) if row.user_id else None + items.append( + { + "id": row.id, + "occurred_at": row.occurred_at, + "outcome": row.outcome, + "phone": row.phone, + "user_id": row.user_id, + "username": user.username if user else None, + "account_registered_at": user.created_at if user else None, + "reason": row.reason, + } + ) + distinct_accounts = _distinct_event_users( + db, + incident, + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + outcomes=("success", "failed"), + ) + else: + user_id = int(incident.subject_id) + filters = ( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= incident.window_start, + ComparisonRecord.created_at < incident.window_end, + ) + total = int(db.scalar(select(func.count(ComparisonRecord.id)).where(*filters)) or 0) + rows = list( + db.scalars( + select(ComparisonRecord) + .where(*filters) + .order_by(ComparisonRecord.created_at.desc(), ComparisonRecord.id.desc()) + .offset(cursor) + .limit(limit) + ).all() + ) + items = [] + for row in rows: + prices = { + str(result.get("platform_name") or result.get("platform_id") or "未知平台"): ( + float(result["price"]) if result.get("price") is not None else None + ) + for result in (row.comparison_results or []) + } + items.append( + { + "id": row.id, + "occurred_at": row.created_at, + "outcome": row.status, + "store_name": row.store_name, + "product_names": row.product_names, + "prices": prices, + "saved_amount_yuan": ( + row.saved_amount_cents / 100 + if row.saved_amount_cents is not None + else None + ), + "status": row.status, + } + ) + distinct_accounts = None + next_cursor = cursor + len(items) if cursor + len(items) < total else None + return { + "kind": kind, + "incident_id": incident.id, + "event_count": incident.event_count, + "distinct_accounts": distinct_accounts, + "items": items, + "next_cursor": next_cursor, + "total": total, + } diff --git a/app/admin/routers/config.py b/app/admin/routers/config.py index 6701c03..270b1b2 100644 --- a/app/admin/routers/config.py +++ b/app/admin/routers/config.py @@ -30,6 +30,12 @@ def _validate(key: str, value: Any) -> None: if t == "int": if not isinstance(value, int) or isinstance(value, bool) or value < 0: raise ValueError("需为非负整数") + minimum = CONFIG_DEFS[key].get("min") + maximum = CONFIG_DEFS[key].get("max") + if minimum is not None and value < minimum: + raise ValueError(f"不能小于 {minimum}") + if maximum is not None and value > maximum: + raise ValueError(f"不能大于 {maximum}") elif t == "int_list": if not isinstance(value, list) or not value: raise ValueError("需为非空整数列表") diff --git a/app/admin/routers/risk_monitor.py b/app/admin/routers/risk_monitor.py new file mode 100644 index 0000000..3c37d64 --- /dev/null +++ b/app/admin/routers/risk_monitor.py @@ -0,0 +1,331 @@ +"""风控监控:统计、风险事件列表/详情及忽略、封禁、解封。""" +from __future__ import annotations + +from typing import Annotated, Literal + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status + +from app.admin.audit import write_audit +from app.admin.deps import ( + AdminDb, + get_client_ip, + require_page, + require_role, +) +from app.admin.repositories import risk_monitor as repo +from app.admin.schemas.risk_monitor import ( + RestrictionRevokeResponse, + RiskActionRequest, + RiskActionResponse, + RiskDetailPage, + RiskIncidentPage, + RiskMonitorSummary, + RiskResetResponse, + RiskRuleConfig, +) +from app.core.config_schema import ( + RISK_COMPARE_DAILY_THRESHOLD_KEY, + RISK_ONECLICK_DAILY_THRESHOLD_KEY, + RISK_SMS_HOURLY_THRESHOLD_KEY, +) +from app.models.risk import RiskIncident, SubjectRestriction +from app.repositories import app_config +from app.repositories import risk as risk_repo + +router = APIRouter( + prefix="/admin/api/risk-monitor", + tags=["admin-risk-monitor"], + dependencies=[Depends(require_page("risk-monitor"))], +) + +RiskKind = Literal["sms", "oneclick", "compare"] + + +def _rule_config(db) -> RiskRuleConfig: + return RiskRuleConfig( + sms_hourly_threshold=risk_repo.get_rule_threshold( + db, risk_repo.RULE_SMS_HOURLY + ), + oneclick_daily_threshold=risk_repo.get_rule_threshold( + db, risk_repo.RULE_ONECLICK_DAILY + ), + compare_daily_threshold=risk_repo.get_rule_threshold( + db, risk_repo.RULE_COMPARE_DAILY + ), + ) + + +def _incident_or_404(db, incident_id: int, kind: RiskKind | None = None) -> RiskIncident: + incident = db.get(RiskIncident, incident_id) + if incident is None: + raise HTTPException(status_code=404, detail="风险事件不存在") + if kind and incident.rule_code != repo.KIND_TO_RULE[kind]: + raise HTTPException(status_code=404, detail="风险事件类型不匹配") + return incident + + +@router.get("/summary", response_model=RiskMonitorSummary, summary="风控监控顶部卡片") +def get_summary(db: AdminDb) -> RiskMonitorSummary: + return RiskMonitorSummary(**repo.summary(db)) + + +@router.get("/rules", response_model=RiskRuleConfig, summary="读取风控规则阈值") +def get_rules(db: AdminDb) -> RiskRuleConfig: + return _rule_config(db) + + +@router.patch("/rules", response_model=RiskRuleConfig, summary="修改风控规则并即时重算") +def update_rules( + body: RiskRuleConfig, + request: Request, + admin: Annotated[object, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> RiskRuleConfig: + before = _rule_config(db).model_dump() + after = body.model_dump() + values = ( + (RISK_SMS_HOURLY_THRESHOLD_KEY, body.sms_hourly_threshold), + (RISK_ONECLICK_DAILY_THRESHOLD_KEY, body.oneclick_daily_threshold), + (RISK_COMPARE_DAILY_THRESHOLD_KEY, body.compare_daily_threshold), + ) + for key, value in values: + app_config.set_value( + db, key, value, admin_id=admin.id, commit=False + ) + + now = risk_repo.utcnow() + risk_repo.reconcile_behavior_rule( + db, rule_code=risk_repo.RULE_SMS_HOURLY, at=now, commit=False + ) + risk_repo.reconcile_behavior_rule( + db, rule_code=risk_repo.RULE_ONECLICK_DAILY, at=now, commit=False + ) + risk_repo.reconcile_compare_rule(db, at=now, commit=False) + write_audit( + db, + admin, + action="risk.rules.update", + target_type="risk_rule", + target_id="all", + detail={"before": before, "after": after}, + ip=get_client_ip(request), + commit=False, + ) + db.commit() + return _rule_config(db) + + +@router.post( + "/reset", + response_model=RiskResetResponse, + summary="重置三类待处理报警并从零重新累计", +) +def reset_alerts( + request: Request, + admin: Annotated[object, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> RiskResetResponse: + reset_at = risk_repo.utcnow() + counts = repo.reset_open_alerts(db, admin_id=admin.id, reset_at=reset_at) + total = sum(counts.values()) + write_audit( + db, + admin, + action="risk.alerts.reset", + target_type="risk_monitor", + target_id="all", + detail={ + "reset_at": reset_at.isoformat(), + "reset_incident_count": total, + "reset_counts": counts, + "restrictions_changed": False, + }, + ip=get_client_ip(request), + commit=False, + ) + db.commit() + return RiskResetResponse( + reset_at=reset_at, + reset_incident_count=total, + reset_counts=counts, + ) + + +@router.get( + "/incidents/{kind}", + response_model=RiskIncidentPage, + summary="按短信/一键登录/比价读取风险事件", +) +def get_incidents( + kind: RiskKind, + db: AdminDb, + incident_status: Annotated[ + Literal["open", "ignored", "blocked", "resolved", "all"], Query(alias="status") + ] = "open", + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int, Query(ge=0)] = 0, +) -> RiskIncidentPage: + if kind == "compare": + repo.sync_today_compare_incidents(db) + items, next_cursor, total = repo.list_incidents( + db, kind=kind, status=incident_status, limit=limit, cursor=cursor + ) + return RiskIncidentPage(items=items, next_cursor=next_cursor, total=total) + + +@router.get( + "/incidents/{kind}/{incident_id}/details", + response_model=RiskDetailPage, + summary="风险事件当期事实明细", +) +def get_incident_details( + kind: RiskKind, + incident_id: int, + db: AdminDb, + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: Annotated[int, Query(ge=0)] = 0, +) -> RiskDetailPage: + incident = _incident_or_404(db, incident_id, kind) + return RiskDetailPage( + **repo.incident_details( + db, incident=incident, kind=kind, limit=limit, cursor=cursor + ) + ) + + +@router.post( + "/incidents/{incident_id}/ignore", + response_model=RiskActionResponse, + summary="忽略风险事件", +) +def ignore_incident( + incident_id: int, + body: RiskActionRequest, + request: Request, + admin: Annotated[object, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> RiskActionResponse: + incident = _incident_or_404(db, incident_id) + if incident.status != "open": + raise HTTPException(status_code=409, detail="该风险事件已处理") + incident.status = "ignored" + incident.action_reason = body.reason.strip() or "管理员确认忽略" + incident.handled_by = admin.id + incident.handled_at = risk_repo.utcnow() + write_audit( + db, + admin, + action="risk.incident.ignore", + target_type="risk_incident", + target_id=incident.id, + detail={ + "rule_code": incident.rule_code, + "subject_type": incident.subject_type, + "subject_id": incident.subject_id, + "reason": incident.action_reason, + }, + ip=get_client_ip(request), + commit=False, + ) + db.commit() + return RiskActionResponse(incident_id=incident.id, status=incident.status) + + +@router.post( + "/incidents/{incident_id}/block", + response_model=RiskActionResponse, + summary="封禁风险主体", +) +def block_incident( + incident_id: int, + body: RiskActionRequest, + request: Request, + admin: Annotated[object, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> RiskActionResponse: + incident = _incident_or_404(db, incident_id) + if incident.status not in ("open", "blocked"): + raise HTTPException(status_code=409, detail="该风险事件已处理") + if incident.subject_type == "device": + if incident.subject_id.startswith("legacy-ip:"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="旧客户端未上报设备ID,不能执行设备封禁", + ) + scope = risk_repo.SCOPE_AUTH_DEVICE + elif incident.subject_type == "user": + scope = risk_repo.SCOPE_ECONOMIC_ACCOUNT + else: # pragma: no cover - 当前三条规则只会生成 device/user + raise HTTPException(status_code=400, detail="该主体类型暂不支持封禁") + + reason = body.reason.strip() or "风控规则命中" + restriction = risk_repo.activate_restriction( + db, + subject_type=incident.subject_type, + subject_id=incident.subject_id, + scope=scope, + reason=reason, + incident_id=incident.id, + admin_id=admin.id, + ) + incident.status = "blocked" + incident.action_reason = reason + incident.handled_by = admin.id + incident.handled_at = risk_repo.utcnow() + write_audit( + db, + admin, + action="risk.subject.block", + target_type=incident.subject_type, + target_id=incident.subject_id, + detail={ + "incident_id": incident.id, + "rule_code": incident.rule_code, + "scope": scope, + "reason": reason, + }, + ip=get_client_ip(request), + commit=False, + ) + db.commit() + return RiskActionResponse( + incident_id=incident.id, + status=incident.status, + restriction_id=restriction.id, + ) + + +@router.post( + "/restrictions/{restriction_id}/revoke", + response_model=RestrictionRevokeResponse, + summary="解除主体限制", +) +def revoke_restriction( + restriction_id: int, + request: Request, + admin: Annotated[object, Depends(require_role("operator", "tech"))], + db: AdminDb, +) -> RestrictionRevokeResponse: + restriction = db.get(SubjectRestriction, restriction_id) + if restriction is None: + raise HTTPException(status_code=404, detail="限制记录不存在") + if restriction.active: + risk_repo.revoke_restriction(db, restriction=restriction, admin_id=admin.id) + if restriction.incident_id: + incident = db.get(RiskIncident, restriction.incident_id) + if incident and incident.status == "blocked": + incident.status = "resolved" + incident.handled_by = admin.id + incident.handled_at = risk_repo.utcnow() + write_audit( + db, + admin, + action="risk.subject.unblock", + target_type=restriction.subject_type, + target_id=restriction.subject_id, + detail={"restriction_id": restriction.id, "scope": restriction.scope}, + ip=get_client_ip(request), + commit=False, + ) + db.commit() + return RestrictionRevokeResponse(restriction_id=restriction.id) diff --git a/app/admin/schemas/risk_monitor.py b/app/admin/schemas/risk_monitor.py new file mode 100644 index 0000000..5a921ee --- /dev/null +++ b/app/admin/schemas/risk_monitor.py @@ -0,0 +1,103 @@ +"""风控监控后台接口 schema。""" +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, Field + + +class RiskSummaryCard(BaseModel): + kind: str + alert_subject_count: int + today_total: int + threshold: int + rule_text: str + + +class RiskMonitorSummary(BaseModel): + date: str + updated_at: datetime + cards: list[RiskSummaryCard] + + +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) + + +class RiskIncidentItem(BaseModel): + incident_id: int + kind: str + subject_type: str + subject_id: str + device_model: str | None = None + first_used_at: datetime | None = None + phone: str | None = None + user_id: int | None = None + registered_at: datetime | None = None + common_device_id: str | None = None + window_start: datetime + window_end: datetime + first_event_at: datetime + triggered_at: datetime + last_event_at: datetime + event_count: int + status: str + restricted: bool = False + + +class RiskIncidentPage(BaseModel): + items: list[RiskIncidentItem] + next_cursor: int | None = None + total: int + + +class RiskDetailItem(BaseModel): + id: int + occurred_at: datetime + outcome: str + phone: str | None = None + user_id: int | None = None + username: str | None = None + account_registered_at: datetime | None = None + verified: bool | None = None + reason: str | None = None + store_name: str | None = None + product_names: str | None = None + prices: dict[str, float | None] | None = None + saved_amount_yuan: float | None = None + status: str | None = None + + +class RiskDetailPage(BaseModel): + kind: str + incident_id: int + event_count: int + distinct_accounts: int | None = None + items: list[RiskDetailItem] + next_cursor: int | None = None + total: int + + +class RiskActionRequest(BaseModel): + reason: str = Field("", max_length=256) + + +class RiskActionResponse(BaseModel): + ok: bool = True + incident_id: int + status: str + restriction_id: int | None = None + + +class RiskResetResponse(BaseModel): + ok: bool = True + reset_at: datetime + reset_incident_count: int + reset_counts: dict[str, int] + + +class RestrictionRevokeResponse(BaseModel): + ok: bool = True + restriction_id: int diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index e3728be..a58020c 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -37,6 +37,7 @@ from app.integrations.jiguang import JiguangError, mask_phone, verify_and_get_ph from app.integrations.sms import SmsError, send_code, verify_code from app.repositories import onboarding as onboarding_repo from app.repositories import phone_rebind as rebind_repo +from app.repositories import risk as risk_repo from app.repositories import user as user_repo from app.schemas.auth import ( JverifyLoginRequest, @@ -70,6 +71,18 @@ SMS_SEND_MAX_PER_HOUR_PER_DEVICE = 5 # 每小时上限 SMS_SEND_MAX_PER_DAY_PER_DEVICE = 20 # 每天上限(再叠一层日封顶,挡低频长时间轰炸) +def _client_ip(request: Request) -> str: + xff = request.headers.get("x-forwarded-for") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "" + + +def _device_subject(device_id: str, request: Request) -> str: + # 兼容旧客户端空 device_id;新客户端均上传稳定硬件标识。 + return device_id or f"legacy-ip:{_client_ip(request)}" + + def _login_response( user, *, onboarding_completed: bool, force_onboarding: bool = False ) -> TokenWithUser: @@ -87,7 +100,15 @@ def _login_response( # ===================== 极光一键登录 ===================== @router.post("/jverify-login", response_model=TokenWithUser, summary="极光一键登录") -def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: +def jverify_login(req: JverifyLoginRequest, request: Request, db: DbSession) -> TokenWithUser: + subject_id = _device_subject(req.device_id, request) + if req.device_id and risk_repo.is_restricted( + db, + subject_type="device", + subject_id=req.device_id, + scope=risk_repo.SCOPE_AUTH_DEVICE, + ): + raise HTTPException(status_code=403, detail="当前设备环境异常,暂无法登录") logger.info( "jverify_login operator=%s token_len=%d", req.operator or "-", @@ -97,10 +118,37 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: try: phone = verify_and_get_phone(req.login_token) except JiguangError as e: + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + subject_type="device", + subject_id=subject_id, + device_id=req.device_id or None, + device_model=req.device_model or None, + client_ip=_client_ip(request), + outcome="failed", + reason=str(e), + details={"operator": req.operator or None}, + evaluate_rule=risk_repo.RULE_ONECLICK_DAILY, + ) logger.error("[JG] verify+decrypt failed: %s", e, exc_info=True) raise HTTPException(status_code=502, detail=f"jiguang verify failed: {e}") from e user = user_repo.upsert_user_for_login(db, phone=phone, register_channel="jverify") + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + subject_type="device", + subject_id=subject_id, + user_id=user.id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=phone, + client_ip=_client_ip(request), + outcome="success", + details={"operator": req.operator or None}, + evaluate_rule=risk_repo.RULE_ONECLICK_DAILY, + ) if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") @@ -116,12 +164,34 @@ def jverify_login(req: JverifyLoginRequest, db: DbSession) -> TokenWithUser: response_model=SmsSendResponse, summary="发送短信验证码", ) -def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: +def sms_send(req: SmsSendRequest, request: Request, db: DbSession) -> SmsSendResponse: + subject_id = _device_subject(req.device_id, request) + if req.device_id and risk_repo.is_restricted( + db, + subject_type="device", + subject_id=req.device_id, + scope=risk_repo.SCOPE_AUTH_DEVICE, + ): + raise HTTPException(status_code=403, detail="当前设备环境异常,暂无法发送验证码") # 测试账号:不真发短信(号码非真实手机号,真发会失败/浪费),直接放行让客户端进入填码界面。 # 真正的"免验证码"在 /sms/login 跳过校验;每日上限也在 login 处算(send 不耗额度)。 # (也不受下面设备发码限流约束:QA 联调要反复发码。) if test_account.is_test_account(req.phone): logger.info("test_account sms_send short-circuit (不真发)") + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=subject_id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + # 测试号不真发短信,也豁免正式频控;保留流水供排查,但不能污染 + # “今日短信下发”与短信风控报警。 + outcome="test", + details={"mock": True, "test_account": True}, + ) return SmsSendResponse(sent=True, mock=True, cooldown_sec=0) # 发码防刷:同一设备(device_id) + 同一 IP,每小时 / 每天两道闸,**均只按成功发码计数**。 @@ -139,6 +209,18 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: try: cooldown = send_code(req.phone) except SmsError as e: + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=subject_id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + outcome="failed", + reason=str(e), + ) raise HTTPException(status_code=e.status_code, detail=str(e)) from e # 发码成功 → 两道闸各 +1(被单号冷却挡下的重发走不到这里,故不占额度) @@ -146,6 +228,20 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: from app.core.config import settings # 局部 import 避免循环 + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=subject_id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + outcome="success", + details={"mock": settings.SMS_MOCK}, + evaluate_rule=risk_repo.RULE_SMS_HOURLY, + ) + return SmsSendResponse(sent=True, mock=settings.SMS_MOCK, cooldown_sec=cooldown) @@ -155,6 +251,7 @@ def sms_send(req: SmsSendRequest, request: Request) -> SmsSendResponse: summary="手机号+验证码登录", ) def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWithUser: + subject_id = _device_subject(req.device_id, request) # 测试账号:免验证码登录 + 每日上限 + 每次都走新手引导(详见 app/core/test_account.py)。 # 放在最前面:命中即不校验验证码;先扣当日额度,超限直接拒,挡住有人猜到号后脚本刷。 # 测试账号走自己的每日额度、不受下面 (设备+IP) 每小时限流约束(QA 需在一小时内反复登录联调)。 @@ -162,6 +259,19 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit if not test_account.try_consume_quota(): raise HTTPException(status_code=429, detail="测试账号今日使用次数已达上限,请明天再试") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=subject_id, + user_id=user.id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + outcome="success", + details={"test_account": True}, + ) if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") logger.info("sms_login test_account ok user_id=%d phone=%s", user.id, mask_phone(req.phone)) @@ -183,9 +293,33 @@ def sms_login(req: SmsLoginRequest, request: Request, db: DbSession) -> TokenWit ) if not verify_code(req.phone, req.code): + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=subject_id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + outcome="failed", + reason="invalid sms code", + ) raise HTTPException(status_code=400, detail="invalid sms code") user = user_repo.upsert_user_for_login(db, phone=req.phone, register_channel="sms") + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=subject_id, + user_id=user.id, + device_id=req.device_id or None, + device_model=req.device_model or None, + phone=req.phone, + client_ip=_client_ip(request), + outcome="success", + ) if user.status != "active": raise HTTPException(status_code=403, detail="account disabled") diff --git a/app/api/v1/compare.py b/app/api/v1/compare.py index b1f56ae..ff1ba7e 100644 --- a/app/api/v1/compare.py +++ b/app/api/v1/compare.py @@ -28,19 +28,30 @@ import httpx from fastapi import APIRouter, HTTPException, Request, status from fastapi.concurrency import run_in_threadpool -from app.api.deps import OptionalUser +from app.api.deps import DbSession, OptionalUser from app.core.config import settings from app.core.logging import trace_id_ctx from app.core.pricebot_client import get_pricebot_client from app.core.pricebot_router import pick_pricebot from app.db.session import SessionLocal from app.repositories import comparison as crud_compare +from app.repositories import risk as risk_repo logger = logging.getLogger("shagua.compare") router = APIRouter(prefix="/api/v1", tags=["compare"]) +def _ensure_compare_allowed(user, db) -> None: + if user and risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user.id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ): + raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") + + # ============================================================ # harvest 落库(阻塞 SQLAlchemy → run_in_threadpool,独立 SessionLocal,不阻塞事件循环; # 任何写库异常都吞掉、绝不连累比价透传返回 —— 同 coupon.py 现有 best-effort 写法)。 @@ -52,10 +63,14 @@ def _harvest_running_blocking( device_id: str | None, device_info: dict | None, trace_url: str | None, ) -> None: with SessionLocal() as db: - crud_compare.harvest_running( + rec = crud_compare.harvest_running( db, trace_id=trace_id, user_id=user_id, business_type=business_type, device_id=device_id, device_info=device_info, trace_url=trace_url, ) + if rec.user_id is not None: + risk_repo.sync_compare_incident( + db, user_id=rec.user_id, at=rec.created_at + ) logger.info( "harvest running row (user=%s)", user_id, extra={"phase": "harvest_running", "status": "running", "user_id": user_id}, @@ -72,6 +87,10 @@ def _harvest_done_blocking( business_type=business_type, device_id=device_id, device_info=device_info, trace_url=trace_url, ) + if rec.user_id is not None: + risk_repo.sync_compare_incident( + db, user_id=rec.user_id, at=rec.created_at + ) logger.info( "harvest done → %s saved=%s newly=%s", rec.status, rec.saved_amount_cents, newly_success, @@ -199,25 +218,33 @@ async def _forward( @router.post("/intent/recognize", summary="外卖比价 Phase 1 意图识别 (透传 + 建 running 行)") -async def intent_recognize(request: Request, user: OptionalUser) -> dict[str, Any]: +async def intent_recognize( + request: Request, user: OptionalUser, db: DbSession +) -> dict[str, Any]: + _ensure_compare_allowed(user, db) resp, _, _ = await _forward(request, "/api/intent/recognize", user) return resp @router.post("/intent/step", summary="外卖比价 Phase 1 多帧意图识别 (透传, 仅淘宝源)") -async def intent_step(request: Request, user: OptionalUser) -> dict[str, Any]: +async def intent_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]: + _ensure_compare_allowed(user, db) resp, _, _ = await _forward(request, "/api/intent/step", user) return resp @router.post("/intent/precoupon/step", summary="外卖比价 Phase 0 识别前先用券 (透传, 仅美团源)") -async def intent_precoupon_step(request: Request, user: OptionalUser) -> dict[str, Any]: +async def intent_precoupon_step( + request: Request, user: OptionalUser, db: DbSession +) -> dict[str, Any]: + _ensure_compare_allowed(user, db) resp, _, _ = await _forward(request, "/api/intent/precoupon/step", user) return resp @router.post("/price/step", summary="外卖比价 Phase 2 步进 (透传 + done 落库)") -async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]: +async def price_step(request: Request, user: OptionalUser, db: DbSession) -> dict[str, Any]: + _ensure_compare_allowed(user, db) resp, trace_id, meta = await _forward(request, "/api/price/step", user) # 最终 done 帧(command=done 且 continue=false)→ harvest 更新成终态。 # (单平台中途 done 被 pricebot 改写成 wait+continue=true,不会命中这里,同 coupon 语义。) @@ -237,7 +264,10 @@ async def price_step(request: Request, user: OptionalUser) -> dict[str, Any]: @router.post("/trace/epilogue", summary="比价结果页尾声帧 (透传到 pricebot, 用户视角截图入 trace)") -async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any]: +async def trace_epilogue( + request: Request, user: OptionalUser, db: DbSession +) -> dict[str, Any]: + _ensure_compare_allowed(user, db) # App 收到 done、渲染完结果页后,把自己页面的截图(base64)传给 pricebot 存进 trace # 目录并触发重传 —— trace 里补上"用户实际看到的汇总页"(步骤帧只有目标 App 画面)。 # body ~几百 KB(截图 base64), 纯透传壳(harvest_first_frame=False:不建行/不落库, @@ -250,7 +280,10 @@ async def trace_epilogue(request: Request, user: OptionalUser) -> dict[str, Any] @router.post("/trace/finalize", summary="比价 trace 收尾上云 (透传 + 夭折落库)") -async def trace_finalize(request: Request, user: OptionalUser) -> dict[str, Any]: +async def trace_finalize( + request: Request, user: OptionalUser, db: DbSession +) -> dict[str, Any]: + _ensure_compare_allowed(user, db) # 用户终止 / Phase1 未识别没到 done 帧: pricebot 打包半截上云返回 {trace_url}; # app-server 顺手把该 trace 的 running 行更新成夭折终态(**不降级 success**)。 # 客户端 finalize body 带 status(cancelled/failed)+ reason;老客户端只带 trace_id → diff --git a/app/api/v1/compare_record.py b/app/api/v1/compare_record.py index 5afc8ac..a2652a6 100644 --- a/app/api/v1/compare_record.py +++ b/app/api/v1/compare_record.py @@ -19,6 +19,7 @@ from app.api.deps import CurrentUser, DbSession from app.db.session import SessionLocal from app.models.comparison import ComparisonRecord from app.repositories import comparison as crud_compare +from app.repositories import risk as risk_repo from app.schemas.compare_record import ( CompareStartReserveIn, CompareStartReserveOut, @@ -47,8 +48,15 @@ def reserve_compare_start( user: CurrentUser, db: DbSession, ) -> CompareStartReserveOut: + if risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user.id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ): + raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") try: - _, used = crud_compare.reserve_daily_start( + rec, used = crud_compare.reserve_daily_start( db, user_id=user.id, trace_id=payload.trace_id, @@ -65,6 +73,8 @@ def reserve_compare_start( status_code=status.HTTP_409_CONFLICT, detail="比价任务标识冲突,请重新发起", ) from None + # 风控阈值由后台动态配置,不能再只在固定的 100 次业务上限处同步。 + risk_repo.sync_compare_incident(db, user_id=user.id, at=rec.created_at) return CompareStartReserveOut( limit=crud_compare.DAILY_COMPARE_START_LIMIT, used=used, @@ -83,6 +93,13 @@ def report_record( db: DbSession, background_tasks: BackgroundTasks, ) -> ComparisonRecordCreatedOut: + if risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user.id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ): + raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") rec = crud_compare.upsert_record(db, user_id=user.id, payload=payload) # LLM 调用明细异步回填:同机拉 pricebot llm_calls 最长 5s 且是 best-effort,放后台 # 任务做,不阻塞上报响应(顺带给 pricebot 落盘留足余量)。upsert 已 commit,后台用 diff --git a/app/api/v1/tasks.py b/app/api/v1/tasks.py index beaaf5d..e5f79e0 100644 --- a/app/api/v1/tasks.py +++ b/app/api/v1/tasks.py @@ -11,6 +11,7 @@ import logging from fastapi import APIRouter, HTTPException, status from app.api.deps import CurrentUser, DbSession +from app.repositories import risk as risk_repo from app.repositories import task as crud_task from app.schemas.welfare import TaskClaimResultOut, TaskListOut, TaskOut @@ -36,6 +37,13 @@ def list_tasks(user: CurrentUser, db: DbSession) -> TaskListOut: summary="领取任务奖励", ) def claim_task(task_key: str, user: CurrentUser, db: DbSession) -> TaskClaimResultOut: + if risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user.id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ): + raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") try: coin, balance = crud_task.claim_task(db, user.id, task_key) except crud_task.UnknownTaskError as e: diff --git a/app/api/v1/wallet.py b/app/api/v1/wallet.py index 6441f47..64ea6bf 100644 --- a/app/api/v1/wallet.py +++ b/app/api/v1/wallet.py @@ -20,8 +20,9 @@ from app.core.rewards import ( WITHDRAW_MAX_CENTS, WITHDRAW_MIN_CENTS, ) -from app.repositories import wallet as crud_wallet from app.models.user import User +from app.repositories import risk as risk_repo +from app.repositories import wallet as crud_wallet from app.schemas.welfare import ( BindWechatRequest, BindWechatResultOut, @@ -204,6 +205,13 @@ def withdraw_info( dependencies=[Depends(rate_limit(5, 60, "withdraw"))], # IP 级粗限流;用户级未完成单限制在仓库层 ) def withdraw(req: WithdrawRequest, user: CurrentUser, db: DbSession) -> WithdrawResultOut: + if risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user.id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ): + raise HTTPException(status_code=403, detail="账号存在异常,该功能暂不可用") # 提现发起本身不调微信(打款在审核通过后),但仍要求微信支付已配置——否则审核通过也打不了款,提前拦 if not settings.wxpay_configured: raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="wechat pay not configured") diff --git a/app/core/config_schema.py b/app/core/config_schema.py index a46e96f..9a63720 100644 --- a/app/core/config_schema.py +++ b/app/core/config_schema.py @@ -11,6 +11,10 @@ from typing import Any from app.core import rewards as r +RISK_SMS_HOURLY_THRESHOLD_KEY = "risk_sms_hourly_threshold" +RISK_ONECLICK_DAILY_THRESHOLD_KEY = "risk_oneclick_daily_threshold" +RISK_COMPARE_DAILY_THRESHOLD_KEY = "risk_compare_daily_threshold" + # type 约定(给前端渲染编辑控件用):int / int_list / dict_str_int / bool / enum # hidden=True:仍是合法可配项(业务照常 get_value / admin 可经专用端点读写),但**不在通用 # 「系统配置」页渲染**(admin/routers/config.py:list_config 按此过滤)。用于把已下线/已改由 @@ -106,4 +110,36 @@ CONFIG_DEFS: dict[str, dict[str, Any]] = { "default 兜底未登记的模型。改价只影响之后回填的新记录,历史记录用当时价格快照。" ), }, + # 风控监控页专用配置。复用 app_config 持久化,但不在通用「系统配置」页重复展示; + # 由 /admin/api/risk-monitor/rules 专用接口整组校验、审计和即时重算当前窗口。 + RISK_SMS_HOURLY_THRESHOLD_KEY: { + "default": 5, + "label": "短信设备每小时告警阈值", + "group": "风控", + "type": "int", + "min": 1, + "max": 5, + "hidden": True, + "help": "同一设备在北京时间同一自然小时内成功下发短信达到该次数时告警;不得高于现有每小时 5 次的发送上限。", + }, + RISK_ONECLICK_DAILY_THRESHOLD_KEY: { + "default": 20, + "label": "一键登录设备每日告警阈值", + "group": "风控", + "type": "int", + "min": 1, + "max": 100_000, + "hidden": True, + "help": "同一设备在北京时间同一自然日内一键登录成功和失败合计达到该次数时告警。", + }, + RISK_COMPARE_DAILY_THRESHOLD_KEY: { + "default": 100, + "label": "比价账户每日告警阈值", + "group": "风控", + "type": "int", + "min": 1, + "max": 100, + "hidden": True, + "help": "同一账户在北京时间同一自然日内发起比价达到该次数时告警。", + }, } diff --git a/app/models/__init__.py b/app/models/__init__.py index bae1c43..040ce07 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -13,13 +13,7 @@ from app.models.analytics_selfstat import ( # noqa: F401 ) from app.models.app_config import AppConfig # noqa: F401 from app.models.comparison import ComparisonRecord # noqa: F401 -from app.models.cps_activity import CpsActivity # noqa: F401 -from app.models.cps_group import CpsGroup # noqa: F401 -from app.models.cps_link import CpsClick, CpsLink # noqa: F401 -from app.models.cps_order import CpsOrder # noqa: F401 -from app.models.cps_wx_user import CpsWxUser # noqa: F401 from app.models.comparison_milestone import ComparisonMilestoneClaim # noqa: F401 -from app.models.device import DeviceLiveness # noqa: F401 from app.models.coupon_state import ( # noqa: F401 CouponClaimEvent, CouponClaimRecord, @@ -27,6 +21,12 @@ from app.models.coupon_state import ( # noqa: F401 CouponPromptEngagement, CouponSession, ) +from app.models.cps_activity import CpsActivity # noqa: F401 +from app.models.cps_group import CpsGroup # noqa: F401 +from app.models.cps_link import CpsClick, CpsLink # noqa: F401 +from app.models.cps_order import CpsOrder # noqa: F401 +from app.models.cps_wx_user import CpsWxUser # noqa: F401 +from app.models.device import DeviceLiveness # noqa: F401 from app.models.feedback import Feedback # noqa: F401 from app.models.guide_video import GuideVideoPlay # noqa: F401 from app.models.inactivity import ( # noqa: F401 @@ -39,11 +39,12 @@ from app.models.launch_confirm_sample import LaunchConfirmSample # noqa: F401 from app.models.meituan_coupon import MeituanCoupon # noqa: F401 from app.models.notification import Notification # noqa: F401 from app.models.onboarding import OnboardingCompletion # noqa: F401 -from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401 from app.models.ops_marquee_seed import OpsMarqueeSeed # noqa: F401 from app.models.ops_stat_config import OpsStatConfig # noqa: F401 +from app.models.phone_rebind_log import PhoneRebindLog # noqa: F401 from app.models.price_observation import PriceObservation # noqa: F401 from app.models.price_report import PriceReport # noqa: F401 +from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction # noqa: F401 from app.models.savings import SavingsRecord # noqa: F401 from app.models.signin import SigninRecord # noqa: F401 from app.models.store_mapping import StoreMapping # noqa: F401 diff --git a/app/models/risk.py b/app/models/risk.py new file mode 100644 index 0000000..39156f7 --- /dev/null +++ b/app/models/risk.py @@ -0,0 +1,163 @@ +"""通用行为事件、风险事件与主体限制。 + +这三张表不是“风控监控页面专用表”: + +- ``behavior_event`` 保存服务端权威的关键行为流水,后续可复用于安全审计、 + 漏斗核查和客诉排查; +- ``risk_incident`` 保存规则命中后的可处置事件,负责“待处理/忽略/封禁/解除” + 生命周期; +- ``subject_restriction`` 保存当前生效的主体限制,统一承载设备、账号等主体的 + 业务拦截状态。 +""" +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + JSON, + Boolean, + DateTime, + Index, + Integer, + String, + UniqueConstraint, + func, + true, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + +_JSON = JSON().with_variant(JSONB(), "postgresql") + + +class BehaviorEvent(Base): + """服务端权威行为流水;只增不改。""" + + __tablename__ = "behavior_event" + __table_args__ = ( + Index("ix_behavior_event_type_time", "event_type", "occurred_at"), + Index( + "ix_behavior_event_subject_time", + "subject_type", + "subject_id", + "occurred_at", + ), + Index("ix_behavior_event_user_time", "user_id", "occurred_at"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + subject_type: Mapped[str] = mapped_column(String(32), nullable=False) + subject_id: Mapped[str] = mapped_column(String(128), nullable=False) + + user_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + device_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + device_model: Mapped[str | None] = mapped_column(String(128), nullable=True) + phone: Mapped[str | None] = mapped_column(String(20), nullable=True) + client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True) + + # success / failed / rejected / attempted + outcome: Mapped[str] = mapped_column(String(24), nullable=False, default="success") + reason: Mapped[str | None] = mapped_column(String(256), nullable=True) + details: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + occurred_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + + +class RiskIncident(Base): + """规则命中后形成的可处置风险事件。""" + + __tablename__ = "risk_incident" + __table_args__ = ( + UniqueConstraint( + "rule_code", + "subject_type", + "subject_id", + "window_key", + name="uq_risk_incident_rule_subject_window", + ), + Index("ix_risk_incident_rule_status", "rule_code", "status", "triggered_at"), + Index( + "ix_risk_incident_subject", + "subject_type", + "subject_id", + "triggered_at", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + rule_code: Mapped[str] = mapped_column(String(64), nullable=False) + event_type: Mapped[str] = mapped_column(String(64), nullable=False) + subject_type: Mapped[str] = mapped_column(String(32), nullable=False) + subject_id: Mapped[str] = mapped_column(String(128), nullable=False) + window_key: Mapped[str] = mapped_column(String(64), nullable=False) + + window_start: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + window_end: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + first_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + triggered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_event_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + event_count: Mapped[int] = mapped_column(Integer, nullable=False) + + # open / ignored / blocked / resolved + status: Mapped[str] = mapped_column(String(24), nullable=False, default="open") + action_reason: Mapped[str | None] = mapped_column(String(256), nullable=True) + handled_by: Mapped[int | None] = mapped_column(Integer, nullable=True) + handled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + details: Mapped[dict | None] = mapped_column(_JSON, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + + +class SubjectRestriction(Base): + """主体当前限制状态;同一主体同一作用域只有一条,可封禁后再解除/重启。""" + + __tablename__ = "subject_restriction" + __table_args__ = ( + UniqueConstraint( + "subject_type", + "subject_id", + "scope", + name="uq_subject_restriction_subject_scope", + ), + Index( + "ix_subject_restriction_lookup", + "subject_type", + "subject_id", + "scope", + "active", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + subject_type: Mapped[str] = mapped_column(String(32), nullable=False) + subject_id: Mapped[str] = mapped_column(String(128), nullable=False) + # auth_device / economic_account / all + scope: Mapped[str] = mapped_column(String(32), nullable=False) + active: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default=true() + ) + reason: Mapped[str | None] = mapped_column(String(256), nullable=True) + incident_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_by: Mapped[int | None] = mapped_column(Integer, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False + ) + revoked_by: Mapped[int | None] = mapped_column(Integer, nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + details: Mapped[dict | None] = mapped_column(_JSON, nullable=True) diff --git a/app/repositories/risk.py b/app/repositories/risk.py new file mode 100644 index 0000000..d3315f1 --- /dev/null +++ b/app/repositories/risk.py @@ -0,0 +1,569 @@ +"""通用行为事件、风险规则与主体限制仓库。""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from zoneinfo import ZoneInfo + +from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.core.config_schema import ( + RISK_COMPARE_DAILY_THRESHOLD_KEY, + RISK_ONECLICK_DAILY_THRESHOLD_KEY, + RISK_SMS_HOURLY_THRESHOLD_KEY, +) +from app.models.app_config import AppConfig +from app.models.comparison import ComparisonRecord +from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction +from app.repositories import app_config + +CN_TZ = ZoneInfo("Asia/Shanghai") + +RULE_SMS_HOURLY = "sms_device_hourly_5" +RULE_ONECLICK_DAILY = "oneclick_device_daily_20" +RULE_COMPARE_DAILY = "compare_account_daily_100" + +EVENT_SMS_SEND = "sms_send" +EVENT_SMS_LOGIN = "sms_login" +EVENT_ONECLICK_LOGIN = "oneclick_login" + +SCOPE_AUTH_DEVICE = "auth_device" +SCOPE_ECONOMIC_ACCOUNT = "economic_account" +AUTO_RESOLVED_REASON = "规则阈值调整后不再命中" +MANUAL_RESET_REASON = "管理员重置报警计数" +RISK_RESET_BASELINES_KEY = "risk_monitor_reset_baselines" + + +@dataclass(frozen=True) +class RuleSpec: + code: str + event_type: str + subject_type: str + threshold_key: str + window: str + count_outcomes: tuple[str, ...] + + +RULES: dict[str, RuleSpec] = { + RULE_SMS_HOURLY: RuleSpec( + code=RULE_SMS_HOURLY, + event_type=EVENT_SMS_SEND, + subject_type="device", + threshold_key=RISK_SMS_HOURLY_THRESHOLD_KEY, + window="hour", + count_outcomes=("success",), + ), + RULE_ONECLICK_DAILY: RuleSpec( + code=RULE_ONECLICK_DAILY, + event_type=EVENT_ONECLICK_LOGIN, + subject_type="device", + threshold_key=RISK_ONECLICK_DAILY_THRESHOLD_KEY, + window="day", + count_outcomes=("success", "failed"), + ), +} + +RULE_THRESHOLD_KEYS: dict[str, str] = { + RULE_SMS_HOURLY: RISK_SMS_HOURLY_THRESHOLD_KEY, + RULE_ONECLICK_DAILY: RISK_ONECLICK_DAILY_THRESHOLD_KEY, + RULE_COMPARE_DAILY: RISK_COMPARE_DAILY_THRESHOLD_KEY, +} + + +def get_rule_threshold(db: Session, rule_code: str) -> int: + """读取规则当前阈值;配置表为空时回退上线前的 5/20/100 默认值。""" + return int(app_config.get_value(db, RULE_THRESHOLD_KEYS[rule_code])) + + +def utcnow() -> datetime: + return datetime.now(UTC) + + +def _ensure_aware(value: datetime) -> datetime: + return value.replace(tzinfo=UTC) if value.tzinfo is None else value + + +def get_rule_reset_at(db: Session, rule_code: str) -> datetime | None: + """返回规则最近一次全局重置时间;异常旧值按未重置处理。""" + row = db.get(AppConfig, RISK_RESET_BASELINES_KEY) + if row is None or not isinstance(row.value, dict): + return None + raw = row.value.get(rule_code) + if not isinstance(raw, str): + return None + try: + parsed = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return None + return _ensure_aware(parsed).astimezone(UTC) + + +def reset_rule_baselines( + db: Session, + *, + rule_codes: tuple[str, ...], + reset_at: datetime, + admin_id: int, +) -> None: + """持久化全局重置基线;不删除任何行为或比价流水。""" + row = db.get(AppConfig, RISK_RESET_BASELINES_KEY) + value = dict(row.value) if row and isinstance(row.value, dict) else {} + timestamp = _ensure_aware(reset_at).astimezone(UTC).isoformat() + value.update({rule_code: timestamp for rule_code in rule_codes}) + if row is None: + db.add( + AppConfig( + key=RISK_RESET_BASELINES_KEY, + value=value, + updated_by_admin_id=admin_id, + ) + ) + else: + row.value = value + row.updated_by_admin_id = admin_id + db.flush() + + +def _window_bounds(at: datetime, window: str) -> tuple[str, datetime, datetime]: + local = _ensure_aware(at).astimezone(CN_TZ) + if window == "hour": + start_local = local.replace(minute=0, second=0, microsecond=0) + key = start_local.strftime("%Y-%m-%dT%H") + end_local = start_local + timedelta(hours=1) + elif window == "day": + start_local = local.replace(hour=0, minute=0, second=0, microsecond=0) + key = start_local.strftime("%Y-%m-%d") + end_local = start_local + timedelta(days=1) + else: # pragma: no cover - 规则声明错误应尽早暴露 + raise ValueError(f"unsupported risk window: {window}") + return key, start_local.astimezone(UTC), end_local.astimezone(UTC) + + +def _event_stats( + db: Session, + spec: RuleSpec, + *, + subject_id: str, + start: datetime, + end: datetime, + threshold: int, +) -> tuple[int, datetime | None, datetime | None, datetime | None]: + filters = ( + BehaviorEvent.event_type == spec.event_type, + BehaviorEvent.subject_type == spec.subject_type, + BehaviorEvent.subject_id == subject_id, + BehaviorEvent.outcome.in_(spec.count_outcomes), + BehaviorEvent.occurred_at >= start, + BehaviorEvent.occurred_at < end, + ) + count, first_at, last_at = db.execute( + select( + func.count(BehaviorEvent.id), + func.min(BehaviorEvent.occurred_at), + func.max(BehaviorEvent.occurred_at), + ).where(*filters) + ).one() + triggered_at = None + if int(count or 0) >= threshold: + triggered_at = db.execute( + select(BehaviorEvent.occurred_at) + .where(*filters) + .order_by(BehaviorEvent.occurred_at.asc(), BehaviorEvent.id.asc()) + .offset(threshold - 1) + .limit(1) + ).scalar_one() + return int(count or 0), first_at, last_at, triggered_at + + +def _upsert_incident( + db: Session, + *, + rule_code: str, + event_type: str, + subject_type: str, + subject_id: str, + window_key: str, + window_start: datetime, + window_end: datetime, + first_event_at: datetime, + triggered_at: datetime, + last_event_at: datetime, + event_count: int, + details: dict | None = None, +) -> RiskIncident: + incident = db.execute( + select(RiskIncident).where( + RiskIncident.rule_code == rule_code, + RiskIncident.subject_type == subject_type, + RiskIncident.subject_id == subject_id, + RiskIncident.window_key == window_key, + ) + ).scalar_one_or_none() + if incident is None: + incident = RiskIncident( + rule_code=rule_code, + event_type=event_type, + subject_type=subject_type, + subject_id=subject_id, + window_key=window_key, + window_start=window_start, + window_end=window_end, + first_event_at=first_event_at, + triggered_at=triggered_at, + last_event_at=last_event_at, + event_count=event_count, + status="open", + details=details, + ) + # 并发首次命中可能同时插入;保存点只回滚重复 incident,不丢行为流水。 + try: + with db.begin_nested(): + db.add(incident) + db.flush() + except IntegrityError: + incident = db.execute( + select(RiskIncident).where( + RiskIncident.rule_code == rule_code, + RiskIncident.subject_type == subject_type, + RiskIncident.subject_id == subject_id, + RiskIncident.window_key == window_key, + ) + ).scalar_one() + else: + reopened = incident.status == "resolved" and incident.action_reason in ( + AUTO_RESOLVED_REASON, + MANUAL_RESET_REASON, + ) + if reopened: + incident.status = "open" + incident.action_reason = None + incident.handled_by = None + incident.handled_at = None + # 同一自然窗口内重置后会复用唯一 incident;明细窗口必须同步切到新基线, + # 否则展开时会把重置前的旧流水也混进来。 + incident.window_start = window_start + incident.window_end = window_end + incident.first_event_at = first_event_at + if incident.status == "open": + incident.triggered_at = triggered_at + incident.last_event_at = last_event_at + incident.event_count = event_count + # 已忽略/封禁的事件只更新事实数据,不重新打开。 + if details: + incident.details = {**(incident.details or {}), **details} + return incident + + +def evaluate_behavior_rule( + db: Session, *, rule_code: str, subject_id: str, at: datetime +) -> RiskIncident | None: + spec = RULES[rule_code] + threshold = get_rule_threshold(db, rule_code) + window_key, window_start, end = _window_bounds(at, spec.window) + reset_at = get_rule_reset_at(db, rule_code) + start = max(window_start, reset_at) if reset_at else window_start + count, first_at, last_at, triggered_at = _event_stats( + db, + spec, + subject_id=subject_id, + start=start, + end=end, + threshold=threshold, + ) + if count < threshold or first_at is None or last_at is None or triggered_at is None: + return None + return _upsert_incident( + db, + rule_code=spec.code, + event_type=spec.event_type, + subject_type=spec.subject_type, + subject_id=subject_id, + window_key=window_key, + window_start=start, + window_end=end, + first_event_at=first_at, + triggered_at=triggered_at, + last_event_at=last_at, + event_count=count, + ) + + +def reconcile_behavior_rule( + db: Session, + *, + rule_code: str, + at: datetime | None = None, + commit: bool = True, +) -> int: + """按当前阈值重算短信/一键登录当前窗口,并收起已不再命中的待处理告警。""" + spec = RULES[rule_code] + current = at or utcnow() + threshold = get_rule_threshold(db, rule_code) + window_key, window_start, end = _window_bounds(current, spec.window) + reset_at = get_rule_reset_at(db, rule_code) + start = max(window_start, reset_at) if reset_at else window_start + filters = ( + BehaviorEvent.event_type == spec.event_type, + BehaviorEvent.subject_type == spec.subject_type, + BehaviorEvent.outcome.in_(spec.count_outcomes), + BehaviorEvent.occurred_at >= start, + BehaviorEvent.occurred_at < end, + ) + qualifying_rows = db.execute( + select( + BehaviorEvent.subject_id, + func.max(BehaviorEvent.occurred_at), + ) + .where(*filters) + .group_by(BehaviorEvent.subject_id) + .having(func.count(BehaviorEvent.id) >= threshold) + ).all() + qualifying = {str(subject_id) for subject_id, _ in qualifying_rows} + for subject_id, last_at in qualifying_rows: + evaluate_behavior_rule( + db, + rule_code=rule_code, + subject_id=str(subject_id), + at=last_at or current, + ) + + open_incidents = db.scalars( + select(RiskIncident).where( + RiskIncident.rule_code == rule_code, + RiskIncident.window_key == window_key, + RiskIncident.status == "open", + ) + ).all() + for incident in open_incidents: + if incident.subject_id not in qualifying: + incident.status = "resolved" + incident.action_reason = AUTO_RESOLVED_REASON + incident.handled_by = None + incident.handled_at = utcnow() + if commit: + db.commit() + return len(qualifying) + + +def record_behavior_event( + db: Session, + *, + event_type: str, + subject_type: str, + subject_id: str, + user_id: int | None = None, + device_id: str | None = None, + device_model: str | None = None, + phone: str | None = None, + client_ip: str | None = None, + outcome: str = "success", + reason: str | None = None, + details: dict | None = None, + occurred_at: datetime | None = None, + evaluate_rule: str | None = None, + commit: bool = True, +) -> BehaviorEvent: + at = occurred_at or utcnow() + event = BehaviorEvent( + event_type=event_type, + subject_type=subject_type, + subject_id=subject_id, + user_id=user_id, + device_id=device_id, + device_model=device_model, + phone=phone, + client_ip=client_ip, + outcome=outcome, + reason=reason, + details=details, + occurred_at=at, + ) + db.add(event) + db.flush() + if evaluate_rule: + evaluate_behavior_rule(db, rule_code=evaluate_rule, subject_id=subject_id, at=at) + if commit: + db.commit() + db.refresh(event) + return event + + +def sync_compare_incident( + db: Session, + *, + user_id: int, + at: datetime, + threshold: int | None = None, + commit: bool = True, +) -> RiskIncident | None: + effective_threshold = threshold or get_rule_threshold(db, RULE_COMPARE_DAILY) + # comparison_record 的既有写入口统一落“北京时间 naive”时间;这里必须沿用同一 + # 口径,否则 SQLite/PG session timezone 不同时会把凌晨记录算到前一天。 + local = at.astimezone(CN_TZ).replace(tzinfo=None) if at.tzinfo else at + window_start = local.replace(hour=0, minute=0, second=0, microsecond=0) + end = window_start + timedelta(days=1) + window_key = window_start.strftime("%Y-%m-%d") + reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY) + reset_local = ( + reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None + ) + start = max(window_start, reset_local) if reset_local else window_start + filters = ( + ComparisonRecord.user_id == user_id, + ComparisonRecord.created_at >= start, + ComparisonRecord.created_at < end, + ) + count, first_at, last_at = db.execute( + select( + func.count(ComparisonRecord.id), + func.min(ComparisonRecord.created_at), + func.max(ComparisonRecord.created_at), + ).where(*filters) + ).one() + if int(count or 0) < effective_threshold or first_at is None or last_at is None: + return None + triggered_at = db.execute( + select(ComparisonRecord.created_at) + .where(*filters) + .order_by(ComparisonRecord.created_at.asc(), ComparisonRecord.id.asc()) + .offset(effective_threshold - 1) + .limit(1) + ).scalar_one() + incident = _upsert_incident( + db, + rule_code=RULE_COMPARE_DAILY, + event_type="compare_start", + subject_type="user", + subject_id=str(user_id), + window_key=window_key, + window_start=start, + window_end=end, + first_event_at=first_at, + triggered_at=triggered_at, + last_event_at=last_at, + event_count=int(count), + ) + if commit: + db.commit() + db.refresh(incident) + return incident + + +def reconcile_compare_rule( + db: Session, *, at: datetime | None = None, commit: bool = True +) -> int: + """按当前阈值重算北京时间当日比价告警,并收起不再命中的待处理告警。""" + current = (at or utcnow()).astimezone(CN_TZ).replace(tzinfo=None) + window_start = current.replace(hour=0, minute=0, second=0, microsecond=0) + end = window_start + timedelta(days=1) + window_key = window_start.strftime("%Y-%m-%d") + reset_at = get_rule_reset_at(db, RULE_COMPARE_DAILY) + reset_local = ( + reset_at.astimezone(CN_TZ).replace(tzinfo=None) if reset_at else None + ) + start = max(window_start, reset_local) if reset_local else window_start + threshold = get_rule_threshold(db, RULE_COMPARE_DAILY) + rows = db.execute( + select( + ComparisonRecord.user_id, + func.max(ComparisonRecord.created_at), + ) + .where( + ComparisonRecord.user_id.is_not(None), + ComparisonRecord.created_at >= start, + ComparisonRecord.created_at < end, + ) + .group_by(ComparisonRecord.user_id) + .having(func.count(ComparisonRecord.id) >= threshold) + ).all() + qualifying = {str(user_id) for user_id, _ in rows} + for user_id, last_at in rows: + sync_compare_incident( + db, + user_id=int(user_id), + at=last_at or current, + threshold=threshold, + commit=False, + ) + + open_incidents = db.scalars( + select(RiskIncident).where( + RiskIncident.rule_code == RULE_COMPARE_DAILY, + RiskIncident.window_key == window_key, + RiskIncident.status == "open", + ) + ).all() + for incident in open_incidents: + if incident.subject_id not in qualifying: + incident.status = "resolved" + incident.action_reason = AUTO_RESOLVED_REASON + incident.handled_by = None + incident.handled_at = utcnow() + if commit: + db.commit() + return len(qualifying) + + +def get_active_restriction( + db: Session, *, subject_type: str, subject_id: str, scope: str +) -> SubjectRestriction | None: + return db.execute( + select(SubjectRestriction).where( + SubjectRestriction.subject_type == subject_type, + SubjectRestriction.subject_id == subject_id, + SubjectRestriction.scope.in_((scope, "all")), + SubjectRestriction.active.is_(True), + ) + ).scalar_one_or_none() + + +def is_restricted(db: Session, *, subject_type: str, subject_id: str, scope: str) -> bool: + return get_active_restriction( + db, subject_type=subject_type, subject_id=subject_id, scope=scope + ) is not None + + +def activate_restriction( + db: Session, + *, + subject_type: str, + subject_id: str, + scope: str, + reason: str, + incident_id: int | None, + admin_id: int, +) -> SubjectRestriction: + restriction = db.execute( + select(SubjectRestriction).where( + SubjectRestriction.subject_type == subject_type, + SubjectRestriction.subject_id == subject_id, + SubjectRestriction.scope == scope, + ) + ).scalar_one_or_none() + if restriction is None: + restriction = SubjectRestriction( + subject_type=subject_type, + subject_id=subject_id, + scope=scope, + ) + db.add(restriction) + restriction.active = True + restriction.reason = reason + restriction.incident_id = incident_id + restriction.created_by = admin_id + restriction.created_at = utcnow() + restriction.revoked_by = None + restriction.revoked_at = None + db.flush() + return restriction + + +def revoke_restriction( + db: Session, *, restriction: SubjectRestriction, admin_id: int +) -> None: + restriction.active = False + restriction.revoked_by = admin_id + restriction.revoked_at = utcnow() + db.flush() diff --git a/app/schemas/auth.py b/app/schemas/auth.py index 03a03b1..b5c185c 100644 --- a/app/schemas/auth.py +++ b/app/schemas/auth.py @@ -11,7 +11,6 @@ from datetime import datetime from pydantic import BaseModel, ConfigDict, Field - # ===== 用户对外信息 ===== class UserOut(BaseModel): @@ -65,6 +64,9 @@ class JverifyLoginRequest(BaseModel): "", max_length=64, description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", ) + device_model: str = Field( + "", max_length=128, description="客户端设备型号快照,用于登录安全审计" + ) # ===== 短信验证码 ===== @@ -75,6 +77,9 @@ class SmsSendRequest(BaseModel): "", max_length=64, description="硬件级设备标识(Android ANDROID_ID),用于发码防刷按 设备+IP 限流;空=按 IP 聚一桶", ) + device_model: str = Field( + "", max_length=128, description="客户端设备型号快照,用于短信安全审计" + ) class SmsSendResponse(BaseModel): @@ -90,6 +95,9 @@ class SmsLoginRequest(BaseModel): "", max_length=64, description="硬件级设备标识(Android ANDROID_ID),用于新手引导按 设备+账号 去重;空=按未完成处理", ) + device_model: str = Field( + "", max_length=128, description="客户端设备型号快照,用于短信验证安全审计" + ) # ===== Refresh ===== diff --git a/scripts/seed_risk_monitor_demo.py b/scripts/seed_risk_monitor_demo.py new file mode 100644 index 0000000..0916beb --- /dev/null +++ b/scripts/seed_risk_monitor_demo.py @@ -0,0 +1,274 @@ +"""为本地“风控监控”页面灌入一组可重复的完整演示数据。 + +仅允许在 APP_ENV=dev 运行。脚本会重建 ``risk-demo-*`` 前缀的数据,并创建本地 +后台账号 ``risk_demo / RiskDemo123!``,方便端到端和视觉验收。 +""" +from __future__ import annotations + +from datetime import timedelta + +from sqlalchemy import delete, select + +from app.admin.repositories import admin_user as admin_repo +from app.core.config import settings +from app.core.security import hash_password +from app.db.session import SessionLocal +from app.models.comparison import ComparisonRecord +from app.models.risk import BehaviorEvent, RiskIncident, SubjectRestriction +from app.repositories import risk as risk_repo +from app.repositories import user as user_repo + +DEMO_PREFIX = "risk-demo-" +ADMIN_USERNAME = "risk_demo" +ADMIN_PASSWORD = "RiskDemo123!" + + +def _reset_demo(db) -> None: + demo_user_ids = list( + db.scalars( + select(ComparisonRecord.user_id) + .where( + ComparisonRecord.trace_id.like(f"{DEMO_PREFIX}%"), + ComparisonRecord.user_id.is_not(None), + ) + .distinct() + ).all() + ) + demo_incident_ids = list( + db.scalars( + select(RiskIncident.id).where( + (RiskIncident.subject_id.like(f"{DEMO_PREFIX}%")) + | ( + (RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY) + & (RiskIncident.subject_id.in_([str(value) for value in demo_user_ids])) + ) + ) + ).all() + ) + if demo_incident_ids: + db.execute( + delete(SubjectRestriction).where( + SubjectRestriction.incident_id.in_(demo_incident_ids) + ) + ) + db.execute( + delete(SubjectRestriction).where( + SubjectRestriction.subject_id.like(f"{DEMO_PREFIX}%") + ) + ) + db.execute( + delete(RiskIncident).where( + (RiskIncident.subject_id.like(f"{DEMO_PREFIX}%")) + | ( + (RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY) + & (RiskIncident.subject_id.in_([str(value) for value in demo_user_ids])) + ) + ) + ) + db.execute( + delete(BehaviorEvent).where( + BehaviorEvent.subject_id.like(f"{DEMO_PREFIX}%") + ) + ) + db.execute( + delete(ComparisonRecord).where( + ComparisonRecord.trace_id.like(f"{DEMO_PREFIX}%") + ) + ) + db.commit() + + +def _ensure_admin(db) -> None: + admin = admin_repo.get_by_username(db, ADMIN_USERNAME) + if admin is None: + admin_repo.create_admin( + db, + username=ADMIN_USERNAME, + password=ADMIN_PASSWORD, + role="super_admin", + ) + return + admin.password_hash = hash_password(ADMIN_PASSWORD) + admin.role = "super_admin" + admin.status = "active" + db.commit() + + +def _seed_sms(db, now) -> None: + alerted = [ + ("risk-demo-sms-oppo-a5", "OPPO A5", "13812343001", 9), + ("risk-demo-sms-xiaomi14", "Xiaomi 14", "13812343002", 7), + ("risk-demo-sms-iphone15", "iPhone 15", "13812343003", 5), + ] + events: list[BehaviorEvent] = [] + for device_id, model, phone, count in alerted: + for index in range(count): + events.append( + BehaviorEvent( + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=device_id, + device_id=device_id, + device_model=model, + phone=phone if index == 0 else f"138{index:08d}"[-11:], + client_ip="127.0.0.1", + outcome="success", + details={"demo": True}, + occurred_at=now + timedelta(seconds=index), + ) + ) + # 其余成功下发分散到未达阈值的设备,每台最多 4 条,确保报警设备仍严格为 3 台。 + remaining = 1284 - sum(row[3] for row in alerted) + for index in range(remaining): + device_no = index // 4 + device_id = f"risk-demo-sms-normal-{device_no:04d}" + events.append( + BehaviorEvent( + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=device_id, + device_id=device_id, + device_model="演示普通设备", + phone=f"137{index % 100_000_000:08d}", + client_ip="127.0.0.1", + outcome="success", + details={"demo": True}, + occurred_at=now + timedelta(seconds=index % 1800), + ) + ) + db.add_all(events) + db.flush() + for device_id, _, phone, _ in alerted: + risk_repo.evaluate_behavior_rule( + db, + rule_code=risk_repo.RULE_SMS_HOURLY, + subject_id=device_id, + at=now, + ) + user = user_repo.upsert_user_for_login( + db, phone=phone, register_channel="sms" + ) + db.add( + BehaviorEvent( + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=device_id, + user_id=user.id, + device_id=device_id, + phone=phone, + outcome="success", + details={"demo": True}, + occurred_at=now + timedelta(minutes=2), + ) + ) + db.commit() + + +def _seed_oneclick(db, now) -> None: + devices = [ + ("risk-demo-oneclick-oppo-reno", "OPPO Reno", "13912343001", 482), + ("risk-demo-oneclick-vivo-y36", "vivo Y36", "13912343002", 481), + ] + for device_id, model, phone, count in devices: + user = user_repo.upsert_user_for_login( + db, phone=phone, register_channel="jverify" + ) + db.add_all( + [ + BehaviorEvent( + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + subject_type="device", + subject_id=device_id, + user_id=user.id, + device_id=device_id, + device_model=model, + phone=phone, + client_ip="127.0.0.1", + outcome="failed" if index % 11 == 0 else "success", + reason="运营商校验失败" if index % 11 == 0 else None, + details={"demo": True}, + occurred_at=now + timedelta(seconds=index * 2), + ) + for index in range(count) + ] + ) + db.flush() + risk_repo.evaluate_behavior_rule( + db, + rule_code=risk_repo.RULE_ONECLICK_DAILY, + subject_id=device_id, + at=now, + ) + db.commit() + + +def _seed_compare(db, now) -> None: + local_now = now.astimezone(risk_repo.CN_TZ).replace(tzinfo=None) + accounts = [ + ("13612343281", "risk-demo-compare-device-a", 1053), + ("13511106208", "risk-demo-compare-device-b", 1052), + ] + for phone, device_id, count in accounts: + user = user_repo.upsert_user_for_login( + db, phone=phone, register_channel="sms" + ) + rows = [] + for index in range(count): + rows.append( + ComparisonRecord( + user_id=user.id, + device_id=device_id, + trace_id=f"{DEMO_PREFIX}compare-{user.id}-{index}", + store_name=("春熙路小吃店", "科技园轻食", "万达广场烤肉")[ + index % 3 + ], + product_names=("招牌套餐", "鸡胸沙拉", "双人烤肉")[index % 3], + status="failed" if index % 17 == 0 else "success", + information="演示比价记录", + items=[{"name": "演示菜品", "qty": 1}], + comparison_results=[ + { + "platform_id": "meituan", + "platform_name": "美团", + "price": 23.8, + }, + { + "platform_id": "taobao", + "platform_name": "淘宝", + "price": 21.5, + }, + { + "platform_id": "jd", + "platform_name": "京东", + "price": 22.2, + }, + ], + skipped_dish_names=[], + saved_amount_cents=230, + raw_payload={"demo": True}, + created_at=local_now + timedelta(seconds=index * 2), + ) + ) + db.add_all(rows) + db.commit() + risk_repo.sync_compare_incident(db, user_id=user.id, at=local_now) + + +def main() -> None: + if settings.APP_ENV != "dev": + raise SystemExit("拒绝执行:风控演示数据脚本仅允许 APP_ENV=dev") + now = risk_repo.utcnow().replace(minute=10, second=0, microsecond=0) + with SessionLocal() as db: + _reset_demo(db) + _ensure_admin(db) + _seed_sms(db, now) + _seed_oneclick(db, now) + _seed_compare(db, now) + print("风控监控演示数据已重建") + print(f"后台账号:{ADMIN_USERNAME}") + print(f"后台密码:{ADMIN_PASSWORD}") + print("期望卡片:短信 3 / 1284;一键登录 2 / 963;比价 2 / 2105") + + +if __name__ == "__main__": + main() diff --git a/tests/test_admin_roles.py b/tests/test_admin_roles.py index 998a3f3..edc5e4b 100644 --- a/tests/test_admin_roles.py +++ b/tests/test_admin_roles.py @@ -68,10 +68,17 @@ def test_monitoring_audit_catalog_and_api_permissions( ).json() monitoring = next(group for group in catalog if group["group"] == "监控审计") assert [page["key"] for page in monitoring["pages"]] == [ - "device-liveness", "analytics-health", "event-logs", "audit-logs", + "risk-monitor", + "device-liveness", + "analytics-health", + "event-logs", + "audit-logs", ] - # 运营默认只能查设备存活,不能绕过导航直调技术/审计接口。 + # 运营默认可查风控和设备存活,不能绕过导航直调技术/审计接口。 + assert admin_client.get( + "/admin/api/risk-monitor/summary", headers=_auth(operator_token) + ).status_code == 200 assert admin_client.get( "/admin/api/device-liveness/stats", headers=_auth(operator_token) ).status_code == 200 @@ -82,8 +89,9 @@ def test_monitoring_audit_catalog_and_api_permissions( ): assert admin_client.get(path, headers=_auth(operator_token)).status_code == 403 - # 技术角色默认拥有监控审计组全部四项权限。 + # 技术角色默认拥有监控审计组全部五项权限。 for path in ( + "/admin/api/risk-monitor/summary", "/admin/api/device-liveness/stats", "/admin/api/analytics-health/overview?date_from=2026-07-01T00:00:00Z&date_to=2026-07-02T00:00:00Z", "/admin/api/event-logs", @@ -184,8 +192,8 @@ def test_builtin_roles_labels_and_pages(admin_client, super_token) -> None: # 页集对齐 Prototypes/dashboard/permissions.md 的 ROLES assert set(roles["finance"]["pages"]) == {"dashboard", "ad-revenue-report", "cps", "withdraws"} assert set(roles["tech"]["pages"]) == { - "dashboard", "device-liveness", "analytics-health", "config", "ad-revenue", "huawei-review", - "event-logs", "audit-logs", + "dashboard", "risk-monitor", "device-liveness", "analytics-health", "config", + "ad-revenue", "huawei-review", "event-logs", "audit-logs", } diff --git a/tests/test_risk_monitor.py b/tests/test_risk_monitor.py new file mode 100644 index 0000000..18923b0 --- /dev/null +++ b/tests/test_risk_monitor.py @@ -0,0 +1,525 @@ +"""风控监控:三类规则、明细及处置闭环。""" +from __future__ import annotations + +from datetime import datetime, timedelta +from uuid import uuid4 + +import pytest +from fastapi.testclient import TestClient + +from app.admin.main import admin_app +from app.admin.repositories import admin_user as admin_repo +from app.api.v1 import compare as compare_api +from app.core.config_schema import ( + RISK_COMPARE_DAILY_THRESHOLD_KEY, + RISK_ONECLICK_DAILY_THRESHOLD_KEY, + RISK_SMS_HOURLY_THRESHOLD_KEY, +) +from app.core.security import issue_token_pair +from app.db.session import SessionLocal +from app.models.app_config import AppConfig +from app.models.comparison import ComparisonRecord +from app.models.device import DeviceLiveness +from app.models.risk import RiskIncident +from app.repositories import risk as risk_repo +from app.repositories import user as user_repo + + +@pytest.fixture(autouse=True) +def _reset_risk_rule_config(): + keys = ( + RISK_SMS_HOURLY_THRESHOLD_KEY, + RISK_ONECLICK_DAILY_THRESHOLD_KEY, + RISK_COMPARE_DAILY_THRESHOLD_KEY, + risk_repo.RISK_RESET_BASELINES_KEY, + ) + with SessionLocal() as db: + db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete( + synchronize_session=False + ) + db.commit() + yield + with SessionLocal() as db: + db.query(AppConfig).filter(AppConfig.key.in_(keys)).delete( + synchronize_session=False + ) + db.commit() + + +def _admin_token() -> str: + username = "risk_monitor_admin" + with SessionLocal() as db: + if admin_repo.get_by_username(db, username) is None: + admin_repo.create_admin( + db, username=username, password="risk-pass-123", role="super_admin" + ) + with TestClient(admin_app) as client: + response = client.post( + "/admin/api/auth/login", + json={"username": username, "password": "risk-pass-123"}, + ) + assert response.status_code == 200 + return response.json()["access_token"] + + +def _headers() -> dict[str, str]: + return {"Authorization": f"Bearer {_admin_token()}"} + + +def _seed_incidents() -> tuple[int, int, int, int, str]: + suffix = uuid4().hex[:8] + sms_subject = f"risk_sms_{suffix}" + oneclick_subject = f"risk_oneclick_{suffix}" + now = (risk_repo.utcnow() - timedelta(minutes=5)).replace(microsecond=0) + with SessionLocal() as db: + user = user_repo.upsert_user_for_login( + db, + phone=f"139{int(suffix, 16) % 100_000_000:08d}", + register_channel="sms", + ) + second_user = user_repo.upsert_user_for_login( + db, + phone=f"137{int(suffix, 16) % 100_000_000:08d}", + register_channel="sms", + ) + db.add( + DeviceLiveness( + user_id=user.id, + device_id=sms_subject, + created_at=now - timedelta(days=10), + ) + ) + for index in range(5): + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=sms_subject, + device_id=sms_subject, + device_model="OPPO A5", + phone=f"13877771{index:03d}", + outcome="success", + occurred_at=now + timedelta(seconds=index), + evaluate_rule=risk_repo.RULE_SMS_HOURLY, + ) + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=sms_subject, + user_id=user.id, + device_id=sms_subject, + device_model="OPPO A5", + phone="13877771000", + outcome="success", + occurred_at=now + timedelta(seconds=30), + ) + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_LOGIN, + subject_type="device", + subject_id=sms_subject, + user_id=second_user.id, + device_id=sms_subject, + device_model="OPPO A5", + phone="13877771001", + outcome="success", + occurred_at=now + timedelta(seconds=31), + ) + + for index in range(21): + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_ONECLICK_LOGIN, + subject_type="device", + subject_id=oneclick_subject, + user_id=second_user.id if index == 20 else user.id, + device_id=oneclick_subject, + device_model="OPPO Reno", + phone=user.phone, + outcome="success" if index % 3 else "failed", + reason="mock provider failure" if index % 3 == 0 else None, + occurred_at=now + timedelta(seconds=index), + evaluate_rule=risk_repo.RULE_ONECLICK_DAILY, + ) + + local_now = now.astimezone(risk_repo.CN_TZ).replace(tzinfo=None) + for index in range(100): + db.add( + ComparisonRecord( + user_id=user.id, + device_id="risk_compare_device", + trace_id=f"risk-monitor-{suffix}-{index}", + store_name="测试门店", + product_names="测试菜品", + status="success", + items=[{"name": "测试菜品", "qty": 1}], + comparison_results=[ + {"platform_id": "meituan", "platform_name": "美团", "price": 12.5}, + {"platform_id": "taobao", "platform_name": "淘宝", "price": 11.8}, + ], + skipped_dish_names=[], + saved_amount_cents=70, + created_at=local_now + timedelta(seconds=index), + ) + ) + db.commit() + risk_repo.sync_compare_incident(db, user_id=user.id, at=local_now) + + incidents = { + incident.rule_code: incident.id + for incident in db.query(RiskIncident) + .filter( + RiskIncident.subject_id.in_( + (sms_subject, oneclick_subject, str(user.id)) + ) + ) + .all() + } + return ( + incidents[risk_repo.RULE_SMS_HOURLY], + incidents[risk_repo.RULE_ONECLICK_DAILY], + incidents[risk_repo.RULE_COMPARE_DAILY], + user.id, + sms_subject, + ) + + +def test_risk_monitor_summary_lists_and_details() -> None: + sms_id, oneclick_id, compare_id, _, _ = _seed_incidents() + headers = _headers() + with TestClient(admin_app) as client: + summary = client.get("/admin/api/risk-monitor/summary", headers=headers) + assert summary.status_code == 200 + cards = {card["kind"]: card for card in summary.json()["cards"]} + assert cards["sms"]["alert_subject_count"] >= 1 + assert cards["sms"]["today_total"] >= 5 + assert cards["oneclick"]["alert_subject_count"] >= 1 + assert cards["oneclick"]["today_total"] >= 20 + assert cards["compare"]["alert_subject_count"] >= 1 + assert cards["compare"]["today_total"] >= 100 + + sms_list = client.get("/admin/api/risk-monitor/incidents/sms", headers=headers) + assert sms_list.status_code == 200 + sms_item = next( + item for item in sms_list.json()["items"] if item["incident_id"] == sms_id + ) + assert sms_item["device_model"] == "OPPO A5" + assert sms_item["event_count"] == 5 + assert sms_item["first_used_at"].startswith( + (risk_repo.utcnow() - timedelta(days=10)).date().isoformat() + ) + + sms_detail = client.get( + f"/admin/api/risk-monitor/incidents/sms/{sms_id}/details", + headers=headers, + ) + assert sms_detail.status_code == 200 + assert sms_detail.json()["total"] == 5 + assert sms_detail.json()["distinct_accounts"] == 2 + assert any(item["verified"] for item in sms_detail.json()["items"]) + + oneclick_detail = client.get( + f"/admin/api/risk-monitor/incidents/oneclick/{oneclick_id}/details?limit=1", + headers=headers, + ) + assert oneclick_detail.status_code == 200 + assert oneclick_detail.json()["total"] == 21 + assert oneclick_detail.json()["distinct_accounts"] == 2 + + compare_detail = client.get( + f"/admin/api/risk-monitor/incidents/compare/{compare_id}/details", + headers=headers, + ) + assert compare_detail.status_code == 200 + assert compare_detail.json()["total"] == 100 + assert compare_detail.json()["items"][0]["prices"]["美团"] == 12.5 + + +def test_risk_monitor_ignore_and_block_are_enforced(client: TestClient) -> None: + sms_id, oneclick_id, compare_id, user_id, sms_subject = _seed_incidents() + headers = _headers() + with TestClient(admin_app) as admin_client: + ignored = admin_client.post( + f"/admin/api/risk-monitor/incidents/{oneclick_id}/ignore", + json={"reason": "测试确认正常"}, + headers=headers, + ) + assert ignored.status_code == 200 + assert ignored.json()["status"] == "ignored" + + blocked_device = admin_client.post( + f"/admin/api/risk-monitor/incidents/{sms_id}/block", + json={"reason": "测试设备异常"}, + headers=headers, + ) + assert blocked_device.status_code == 200 + assert blocked_device.json()["restriction_id"] + + blocked_user = admin_client.post( + f"/admin/api/risk-monitor/incidents/{compare_id}/block", + json={"reason": "测试账号异常"}, + headers=headers, + ) + assert blocked_user.status_code == 200 + device_restriction_id = blocked_device.json()["restriction_id"] + user_restriction_id = blocked_user.json()["restriction_id"] + + denied_sms = client.post( + "/api/v1/auth/sms/send", + json={ + "phone": "13877779999", + "device_id": sms_subject, + "device_model": "OPPO A5", + }, + ) + assert denied_sms.status_code == 403 + assert denied_sms.json()["detail"] == "当前设备环境异常,暂无法发送验证码" + + tokens = issue_token_pair(user_id) + denied_compare = client.post( + "/api/v1/compare/start", + json={"trace_id": "risk-blocked-new-trace", "device_id": "risk_compare_device"}, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert denied_compare.status_code == 403 + assert denied_compare.json()["detail"] == "账号存在异常,该功能暂不可用" + + denied_compare_step = client.post( + "/api/v1/intent/recognize", + json={}, + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + ) + assert denied_compare_step.status_code == 403 + assert denied_compare_step.json()["detail"] == "账号存在异常,该功能暂不可用" + + with TestClient(admin_app) as admin_client: + assert ( + admin_client.post( + f"/admin/api/risk-monitor/restrictions/{device_restriction_id}/revoke", + headers=headers, + ).status_code + == 200 + ) + assert ( + admin_client.post( + f"/admin/api/risk-monitor/restrictions/{user_restriction_id}/revoke", + headers=headers, + ).status_code + == 200 + ) + + allowed_sms = client.post( + "/api/v1/auth/sms/send", + json={ + "phone": "13877779998", + "device_id": sms_subject, + "device_model": "OPPO A5", + }, + ) + assert allowed_sms.status_code == 200 + + with SessionLocal() as db: + assert not risk_repo.is_restricted( + db, + subject_type="user", + subject_id=str(user_id), + scope=risk_repo.SCOPE_ECONOMIC_ACCOUNT, + ) + assert db.get(RiskIncident, compare_id).status == "resolved" + + +def test_admin_can_reset_all_alerts_and_sms_restarts_from_zero() -> None: + sms_id, oneclick_id, compare_id, _, sms_subject = _seed_incidents() + headers = _headers() + + with TestClient(admin_app) as client: + reset = client.post("/admin/api/risk-monitor/reset", headers=headers) + assert reset.status_code == 200 + payload = reset.json() + assert payload["reset_incident_count"] >= 3 + assert payload["reset_counts"]["sms"] >= 1 + assert payload["reset_counts"]["oneclick"] >= 1 + assert payload["reset_counts"]["compare"] >= 1 + + summary = client.get("/admin/api/risk-monitor/summary", headers=headers) + assert summary.status_code == 200 + cards = {card["kind"]: card for card in summary.json()["cards"]} + assert all(card["alert_subject_count"] == 0 for card in cards.values()) + # 重置只清待处理报警,不删除今日事实总量。 + assert cards["sms"]["today_total"] >= 5 + assert cards["oneclick"]["today_total"] >= 20 + assert cards["compare"]["today_total"] >= 100 + + reset_at = datetime.fromisoformat(payload["reset_at"]) + with SessionLocal() as db: + for incident_id in (sms_id, oneclick_id, compare_id): + incident = db.get(RiskIncident, incident_id) + assert incident.status == "resolved" + assert incident.action_reason == risk_repo.MANUAL_RESET_REASON + + for index in range(4): + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=sms_subject, + device_id=sms_subject, + phone=f"13688880{index:03d}", + outcome="success", + occurred_at=reset_at + timedelta(seconds=index + 1), + evaluate_rule=risk_repo.RULE_SMS_HOURLY, + ) + assert db.get(RiskIncident, sms_id).status == "resolved" + + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=sms_subject, + device_id=sms_subject, + phone="13688880999", + outcome="success", + occurred_at=reset_at + timedelta(seconds=5), + evaluate_rule=risk_repo.RULE_SMS_HOURLY, + ) + incident = db.get(RiskIncident, sms_id) + assert incident.status == "open" + assert incident.event_count == 5 + assert incident.window_start == reset_at.replace(tzinfo=None) + + +def test_real_compare_harvest_creates_incident_at_threshold() -> None: + suffix = uuid4().hex[:8] + local_now = risk_repo.utcnow().astimezone(risk_repo.CN_TZ).replace(tzinfo=None) + with SessionLocal() as db: + user = user_repo.upsert_user_for_login( + db, + phone=f"136{int(suffix, 16) % 100_000_000:08d}", + register_channel="sms", + ) + for index in range(99): + db.add( + ComparisonRecord( + user_id=user.id, + device_id="real-harvest-device", + trace_id=f"risk-real-harvest-{suffix}-{index}", + status="running", + created_at=local_now + timedelta(milliseconds=index), + ) + ) + db.commit() + user_id = user.id + + compare_api._harvest_running_blocking( + f"risk-real-harvest-{suffix}-99", + user_id, + "food", + "real-harvest-device", + {"model": "OPPO A5"}, + None, + ) + + with SessionLocal() as db: + incident = db.query(RiskIncident).filter( + RiskIncident.rule_code == risk_repo.RULE_COMPARE_DAILY, + RiskIncident.subject_id == str(user_id), + RiskIncident.window_key == local_now.strftime("%Y-%m-%d"), + ).one() + assert incident.event_count == 100 + + +def test_admin_can_edit_rules_and_current_window_is_reconciled() -> None: + suffix = uuid4().hex[:8] + subject = f"risk_dynamic_sms_{suffix}" + now = risk_repo.utcnow().replace(microsecond=0) + with SessionLocal() as db: + for index in range(3): + risk_repo.record_behavior_event( + db, + event_type=risk_repo.EVENT_SMS_SEND, + subject_type="device", + subject_id=subject, + device_id=subject, + device_model="动态规则测试机", + phone=f"13800001{index:03d}", + outcome="success", + occurred_at=now + timedelta(seconds=index), + ) + + headers = _headers() + with TestClient(admin_app) as client: + defaults = client.get("/admin/api/risk-monitor/rules", headers=headers) + assert defaults.status_code == 200 + assert defaults.json() == { + "sms_hourly_threshold": 5, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 100, + } + + lowered = client.patch( + "/admin/api/risk-monitor/rules", + headers=headers, + json={ + "sms_hourly_threshold": 3, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 100, + }, + ) + assert lowered.status_code == 200 + with SessionLocal() as db: + incident = db.query(RiskIncident).filter( + RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY, + RiskIncident.subject_id == subject, + ).one() + assert incident.status == "open" + assert incident.event_count == 3 + + raised = client.patch( + "/admin/api/risk-monitor/rules", + headers=headers, + json={ + "sms_hourly_threshold": 4, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 100, + }, + ) + assert raised.status_code == 200 + with SessionLocal() as db: + incident = db.query(RiskIncident).filter( + RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY, + RiskIncident.subject_id == subject, + ).one() + assert incident.status == "resolved" + assert incident.action_reason == risk_repo.AUTO_RESOLVED_REASON + + reopened = client.patch( + "/admin/api/risk-monitor/rules", + headers=headers, + json={ + "sms_hourly_threshold": 3, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 100, + }, + ) + assert reopened.status_code == 200 + with SessionLocal() as db: + incident = db.query(RiskIncident).filter( + RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY, + RiskIncident.subject_id == subject, + ).one() + assert incident.status == "open" + assert incident.triggered_at == ( + now + timedelta(seconds=2) + ).replace(tzinfo=None) + + invalid = client.patch( + "/admin/api/risk-monitor/rules", + headers=headers, + json={ + "sms_hourly_threshold": 21, + "oneclick_daily_threshold": 20, + "compare_daily_threshold": 100, + }, + ) + assert invalid.status_code == 422 diff --git a/tests/test_test_account.py b/tests/test_test_account.py index 2155a05..61598cd 100644 --- a/tests/test_test_account.py +++ b/tests/test_test_account.py @@ -190,6 +190,42 @@ def test_exempt_from_device_ip_send_rate_limit(client, enabled, monkeypatch) -> ) +def test_send_is_audit_only_and_does_not_trigger_risk_alert(client, enabled) -> None: + """测试号不真发短信:可留审计流水,但不能算正式短信下发或触发设备风控。""" + from app.db.session import SessionLocal + from app.models.risk import BehaviorEvent, RiskIncident + from app.repositories import risk as risk_repo + + device_id = "qa-test-account-device" + for _ in range(6): + response = client.post( + "/api/v1/auth/sms/send", + json={"phone": TEST_PHONE, "device_id": device_id}, + ) + assert response.status_code == 200 + + with SessionLocal() as db: + events = ( + db.query(BehaviorEvent) + .filter( + BehaviorEvent.event_type == risk_repo.EVENT_SMS_SEND, + BehaviorEvent.subject_id == device_id, + ) + .all() + ) + assert len(events) == 6 + assert {event.outcome for event in events} == {"test"} + assert ( + db.query(RiskIncident) + .filter( + RiskIncident.rule_code == risk_repo.RULE_SMS_HOURLY, + RiskIncident.subject_id == device_id, + ) + .count() + == 0 + ) + + # ============================ 计数单元逻辑 ============================ def test_quota_resets_across_day(enabled, monkeypatch) -> None: