# Login Region Risk Architecture 本文档定义登录地区、语言和时区限制的当前推荐方案。方案按当前产品取舍设计:不引入 `pending_risk` 会话,不让各玩法服务分别判断风险状态;用户在未知 IP 首次登录时可以进入 App,后台异步 IP 风控命中不支持国家后撤销 session 并踢下线,后续同一 IP 再登录会在入口直接拒绝。 ## Scope | Capability | Owner | Rule | | --- | --- | --- | | 真实入口 IP 提取 | `gateway-service` | 从可信上游 header 或 `RemoteAddr` 提取,不信任前端 JSON 里的 IP | | 语言和时区快速判断 | `gateway-service` | 只做入口级弱信号拦截,命中明显不支持配置时直接拒绝登录 | | IP 风控缓存读取 | `gateway-service` | 登录前读取 Redis IP 决策缓存,`blocked` 直接拒绝,`allowed/unknown/miss` 放行并触发复核 | | 登录、session 和 token | `user-service` | 仍是 session owner;登录成功后创建 `auth_sessions` 并签发 token | | 异步 IP 风控任务 | `cron-service` 调度,`user-service` 执行 | 按优先级顺序查询 IP Geo 源;当前一个不可用时才降级到下一个,写持久审计和 Redis 缓存 | | session 撤销 | `user-service` | 命中不支持国家后撤销 session,写撤销原因和审计 | | 登录态接口统一拦截 | `gateway-service` | JWT 校验后统一检查 session 是否已撤销,避免玩法服务散落判断 | | 房间/IM 踢下线 | `gateway-service` 编排,`room-service`/腾讯云 IM 执行 | 只能走 Room Cell 命令或腾讯云 IM 服务端接口,不能直接改 room Redis | | 后台登录日志 | `hyapp-admin-server` | 只在用户管理一级菜单下新增二级菜单“登录日志”,不单独做风控策略后台 | ## Non-Goals | Non-goal | Reason | | --- | --- | | 不做 `pending_risk` session | 当前阶段要降低接入成本,避免设计白名单接口和受限权限集 | | 不在玩法服务判断地区风险 | 房间、礼物、钱包、VIP、任务都不应该各自写 `risk_status` 判断,容易漏 | | 不把 IP Geo 查询接口放进同步登录路径 | 外部接口慢、超时或不稳定会拖慢登录 | | 不信任前端传过来的 IP | 客户端字段可伪造,只能作为调试或兼容字段记录 | | 不靠“清客户端 token”完成踢下线 | JWT 已签发后客户端仍可能继续使用,必须服务端撤销 session 并由 gateway 拦截 | | 不把 Redis 当唯一事实来源 | Redis 只做快查缓存;审计、任务、明文 IP 和撤销原因必须能在 MySQL 追溯 | | 不做独立风控后台 | 当前后台只提供登录日志查看,不提供策略编辑、provider 管理或人工改判 | ## Core Decision 采用“入口快拦 + 登录后异步复核 + session 统一撤销”的模型: ```mermaid flowchart LR C["Client"] --> G["gateway-service"] G --> LangTZ["language/timezone fast guard"] G --> Cache["Redis IP decision cache"] Cache -->|blocked| Deny["reject login"] Cache -->|allowed/miss/unknown| U["user-service Auth"] U --> Session[("auth_sessions")] U --> Resp["token response"] U --> Job[("login_ip_risk_jobs / outbox")] Job --> RiskBatch["cron triggered IP risk batch"] RiskBatch --> IP1["IP Geo A"] IP1 -. "unavailable/timeout" .-> IP2["IP Geo B"] IP2 -. "unavailable/timeout" .-> IP3["IP Geo C"] IP3 -. "unavailable/timeout" .-> IP4["IP Geo D"] RiskBatch --> Decision[("risk decision + Redis cache")] RiskBatch -->|blocked| Revoke["revoke session"] Revoke --> Kick["IM/Room kick"] ``` 这个方案接受一个明确边界:未知 IP 第一次可能短暂进入 App。通过 Redis 缓存和 session 撤销,同一个 IP 后续不能反复完整登录。 ## Login Flow ### Password Login ```mermaid sequenceDiagram participant App participant Gateway participant User as user-service participant Redis participant Risk as cron-service/user-service batch App->>Gateway: POST /api/v1/auth/account/login Gateway->>Gateway: extract trusted client_ip Gateway->>Gateway: check language/timezone config Gateway->>Redis: GET ip decision alt ip blocked Gateway-->>App: AUTH_LOGIN_BLOCKED else allowed/miss/unknown Gateway->>User: LoginPassword(meta.client_ip, language, timezone) User->>User: verify password and create session User->>User: enqueue login_ip_risk_job User-->>Gateway: token(session_id) Gateway-->>App: token Risk->>Risk: async resolve IP by provider fallback chain alt unsupported country Risk->>User: RevokeSession(session_id, reason) Risk->>Redis: SET ip decision = blocked Risk->>Gateway: trigger kick orchestration else allowed country Risk->>Redis: SET ip decision = allowed end end ``` ### Third Party Login 三方登录和密码登录使用同一套入口风控,只是 `user-service` 在创建或复用用户 session 后写风险任务。三方首次注册时,客户端提交的 `country` 仍然只是用户选择国家;IP 国家由服务端 IP Geo 结果决定,不能用 `country` 替代风控国家。 ## Gateway Fast Guard gateway 只做三件事: 1. 提取可信入口 IP。 2. 对 `language` 和 `timezone` 做简单配置判断。 3. 查询 Redis IP 决策缓存,命中 `blocked` 时拒绝登录。 ### Trusted IP 可信 IP 来源顺序: ```text trusted edge header -> X-Forwarded-For first public IP -> RemoteAddr ``` 前端提交的 `ip` 字段不参与判断。旧客户端如果还传 IP,只能写入审计扩展字段,不能作为风控依据。 ### Language And Timezone 语言和时区是弱信号,因为客户端可以修改。它们只能做入口快速拦截: ```yaml login_risk: blocked_languages: - zh-CN blocked_language_primary_tags: - zh blocked_timezones: - Asia/Shanghai blocked_timezone_prefixes: - Asia/ ``` 规则: - `language` 按 BCP 47 轻量格式校验,例如 `en-US`、`zh-CN`。 - `timezone` 必须是 IANA 名称,例如 `America/Los_Angeles`。 - 命中阻断配置时,gateway 返回 `AUTH_LOGIN_BLOCKED`。 - 未传、格式异常或不可信时,不直接推断国家;只记录审计。 ### IP Decision Cache Redis key 建议不要保存明文 IP: ```text login_risk:ip:{app_code}:{sha256(ip)} ``` Value: ```json { "decision": "blocked", "country": "CN", "source": "geo_provider_chain", "checked_at_ms": 1710000000000, "expires_at_ms": 1710604800000 } ``` 决策值: | Decision | Gateway Action | | --- | --- | | `blocked` | 登录前直接拒绝 | | `allowed` | 放行登录,可低频复核 | | `unknown` | 放行登录,必须异步复核 | | miss | 放行登录,必须异步复核 | TTL 建议: | Decision | TTL | | --- | --- | | `blocked` | 7 到 30 天 | | `allowed` | 1 到 7 天 | | `unknown` | 10 到 60 分钟 | 可用性策略:Redis 不可用时放行登录,只记录错误和告警;不能因为 Redis 故障阻断登录。 ## Async IP Risk Check 异步 IP 风控 batch 按优先级顺序调用 IP Geo 源,不阻塞登录响应。一次任务只使用一个可用 provider 的结果;只有当前 provider 超时、不可用或返回不可解析结果时,才请求下一个 provider。 ### Job 风险任务需要包含: | Field | Rule | | --- | --- | | `job_id` | 幂等任务 ID | | `app_code` | 多 App 隔离 | | `session_id` | 命中后撤销的 session | | `user_id` | 审计和踢下线目标 | | `client_ip` | 明文入口 IP,后台登录日志直接展示 | | `client_ip_hash` | 缓存 key、索引和日志关联字段,避免普通日志输出明文 IP | | `channel` | 登录渠道:`account/google/facebook/twitter/tiktok` | | `platform` | 客户端平台:`android/ios` | | `language` | 登录时客户端语言 | | `timezone` | 登录时客户端时区 | | `request_id` | 串联登录日志 | | `status` | `pending/running/completed/failed/skipped` | | `created_at_ms` | 任务创建时间 | | `updated_at_ms` | 任务更新时间 | 任务可以落 MySQL 表 `login_ip_risk_jobs`,也可以由 user outbox 派生。首版如果没有统一 MQ,优先用 MySQL 任务表 + cron-service 调度的 batch claim,避免 Redis list 丢失导致永远不复核。MySQL 写任务失败时不影响登录结果,但必须记录错误和告警。 ### Provider Fallback Chain batch 行为: 1. 先查 Redis 是否已有新鲜决策;已有 `blocked/allowed` 可以跳过外部查询。 2. miss 时按配置顺序调用第一个 provider。 3. 如果 provider 在 timeout 内返回有效国家码,立即停止查询后续 provider。 4. 如果 provider 超时、不可用、限流、返回空国家或返回不可解析结果,记录失败原因并降级到下一个 provider。 5. 所有 provider 都失败时,输出 `unknown`,写短 TTL 缓存并等待后续任务重试;如果缓存也不可用,只记录日志和告警。 6. 有效国家码命中不支持国家时输出 `blocked`;有效国家码未命中不支持国家时输出 `allowed`。 7. 写 MySQL 决策记录和 Redis 缓存。 8. 如果命中不支持国家,撤销当前 session 并触发踢下线。 该模型没有“结果冲突”问题,因为同一个任务不会同时采信多个 provider。provider 顺序就是信任优先级: | Case | Decision | | --- | --- | | 第一个可用 provider 返回不支持国家 | `blocked` | | 第一个可用 provider 返回支持国家 | `allowed` | | provider 超时、不可用或返回无效结果 | 请求下一个 provider | | 所有 provider 都失败 | `unknown`,短 TTL,后续重试 | 不支持国家列表必须来自配置,不应硬编码在风控逻辑里;当前后台不提供策略编辑能力。 ## Session Revocation 命中不支持国家时,撤销 session 是核心动作。 ```text auth_sessions.revoked_at_ms = now auth_sessions.revoked_reason = GEO_POLICY_BLOCKED ``` 当前表只有 `revoked_at_ms`,实现时建议补充: | Field | Reason | | --- | --- | | `revoked_reason` | 区分用户 logout、refresh 轮换、后台封禁、IP 风控撤销 | | `revoked_request_id` | 串联风险任务和审计 | | `revoked_by` | `risk_worker`、`user_logout`、`admin` | 撤销成功后写 Redis denylist: ```text auth:revoked_session:{app_code}:{session_id} = GEO_POLICY_BLOCKED TTL = access token remaining ttl + safety window ``` ## Gateway Session Guard 如果 access token 是 JWT,只校验签名不够。用户已经拿到 access token 后,即使 `auth_sessions` 被撤销,JWT 在过期前仍然能访问接口。 因此 gateway 必须增加统一 session guard: ```text Authorization JWT valid + sid exists + auth:revoked_session:{app_code}:{sid} not exists ``` 首版可以先只查 Redis denylist,不对每个请求同步查 MySQL: - 登录成功时不需要写 active cache。 - 风控撤销 session 时写 denylist。 - logout、refresh 轮换、后台封禁同样写 denylist。 - access token TTL 到期后 denylist 自然失效。 后续如果需要更强一致性,再增加 `ValidateSession` RPC 或 `auth:active_session` cache。 所有 App 登录态接口都走 gateway middleware,因此玩法服务不需要感知风险状态: ```mermaid flowchart TD Req["Business Request"] --> JWT["Verify JWT"] JWT --> SID["Read sid claim"] SID --> Deny["Check Redis revoked_session"] Deny -->|revoked| Fail["SESSION_REVOKED"] Deny -->|active| Handler["Business handler"] ``` ## Kick Offline 踢下线分三层,按能做到的程度逐步接入: | Layer | Action | Required | | --- | --- | --- | | API | gateway session guard 返回 `SESSION_REVOKED` | 必须 | | IM | 调腾讯云 IM 服务端接口踢下线或发系统消息通知客户端清 token | 建议 | | Room | 如果用户在房间,通过 room-service Room Cell 命令踢出或触发离房 | 建议 | 不能直接删除 room-service Redis presence 或麦位缓存。房间状态 owner 是 Room Cell,风险踢人必须走 room-service 的命令入口;如果首版没有踢人命令,至少依靠 session guard 阻止后续 HTTP 操作,并让房间 heartbeat/断线补偿释放状态。 ## Error Codes 建议新增稳定业务码: | Code | HTTP | gRPC | Meaning | | --- | --- | --- | --- | | `AUTH_LOGIN_BLOCKED` | 403 | `PermissionDenied` | 登录入口命中地区、语言、时区或 IP 风控策略 | | `SESSION_REVOKED` | 401 | `Unauthenticated` | session 已撤销,客户端必须清登录态 | 客户端行为: - `AUTH_LOGIN_BLOCKED`:停留登录页,展示地区不支持类提示。 - `SESSION_REVOKED`:清本地 token,退出房间/IM,回登录页。 - 普通 5xx 或网络失败不能清 token。 ## Audit And Observability 必须记录: | Event | Fields | | --- | --- | | `login_fast_guard_blocked` | `request_id/app_code/client_ip_hash/language/timezone/block_reason` | | `login_ip_risk_job_created` | `request_id/app_code/session_id/user_id/client_ip/client_ip_hash/channel/platform` | | `login_ip_geo_provider_attempt` | `job_id/provider/order/country/status/duration_ms/error_code` | | `login_ip_risk_decision` | `job_id/session_id/user_id/decision/country/confidence` | | `login_session_revoked_by_risk` | `job_id/session_id/user_id/reason/country` | | `kick_offline_result` | `session_id/user_id/im_result/room_result` | 敏感规则: - 不记录明文 access token、refresh token、三方 credential。 - 明文 IP 可以落 MySQL 业务表,供后台登录日志查看;普通服务日志仍只记录 `client_ip_hash`,避免日志系统大范围暴露明文 IP。 - provider 返回原始报文不进日志,只记录本次尝试的顺序、标准化国家码、耗时和错误码。 ## Data Model ### `login_ip_risk_jobs` ```sql CREATE TABLE login_ip_risk_jobs ( app_code VARCHAR(32) NOT NULL, job_id VARCHAR(96) NOT NULL, session_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL, client_ip VARCHAR(64) NOT NULL, client_ip_hash VARCHAR(128) NOT NULL, channel VARCHAR(32) NOT NULL, platform VARCHAR(16) NOT NULL, language VARCHAR(32) NOT NULL DEFAULT '', timezone VARCHAR(64) NOT NULL DEFAULT '', request_id VARCHAR(96) NOT NULL DEFAULT '', status VARCHAR(32) NOT NULL, decision VARCHAR(32) NOT NULL DEFAULT '', country_code VARCHAR(3) NOT NULL DEFAULT '', failure_reason VARCHAR(128) NOT NULL DEFAULT '', locked_by VARCHAR(128) NULL, locked_until_ms BIGINT NULL, attempt_count INT NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, job_id), KEY idx_login_ip_risk_jobs_status (app_code, status, updated_at_ms), KEY idx_login_ip_risk_jobs_session (app_code, session_id), KEY idx_login_ip_risk_jobs_user (app_code, user_id, created_at_ms), KEY idx_login_ip_risk_jobs_ip (app_code, client_ip_hash, updated_at_ms) ); ``` ### `login_ip_risk_decisions` ```sql CREATE TABLE login_ip_risk_decisions ( app_code VARCHAR(32) NOT NULL, decision_id VARCHAR(96) NOT NULL, client_ip VARCHAR(64) NOT NULL, client_ip_hash VARCHAR(128) NOT NULL, decision VARCHAR(32) NOT NULL, country_code VARCHAR(3) NOT NULL DEFAULT '', confidence INT NOT NULL DEFAULT 0, provider_attempts_json JSON NOT NULL, decided_at_ms BIGINT NOT NULL, expires_at_ms BIGINT NOT NULL, created_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code, decision_id), KEY idx_login_ip_risk_decisions_ip (app_code, client_ip_hash, decided_at_ms), KEY idx_login_ip_risk_decisions_country (app_code, country_code, decided_at_ms), KEY idx_login_ip_risk_decisions_expires (app_code, expires_at_ms) ); ``` ### `login_audit` Additions 现有 `login_audit` 已记录登录类型、provider、结果和失败原因。为了支撑后台登录日志,需要把登录入口展示字段补齐: ```sql ALTER TABLE login_audit ADD COLUMN channel VARCHAR(32) NOT NULL DEFAULT '', ADD COLUMN platform VARCHAR(16) NOT NULL DEFAULT '', ADD COLUMN client_ip VARCHAR(64) NOT NULL DEFAULT '', ADD COLUMN ip_country_code VARCHAR(3) NOT NULL DEFAULT '', ADD COLUMN blocked TINYINT(1) NOT NULL DEFAULT 0, ADD COLUMN block_reason VARCHAR(64) NOT NULL DEFAULT '', ADD KEY idx_login_audit_user_created (app_code, user_id, created_at_ms), ADD KEY idx_login_audit_blocked (app_code, blocked, created_at_ms), ADD KEY idx_login_audit_ip_country (app_code, ip_country_code, created_at_ms); ``` 字段口径: | Field | Rule | | --- | --- | | `channel` | `account/google/facebook/twitter/tiktok`,三方 provider 必须归一成这几个展示值 | | `platform` | `android/ios`,来自客户端请求体或可信 header | | `client_ip` | 明文入口 IP,来自 gateway 提取结果 | | `ip_country_code` | IP Geo 解析出的国家码,未解析时为空 | | `blocked` | 当前登录是否被语言、时区、IP cache 或异步 IP 风控阻断 | | `block_reason` | `language/timezone/ip_cache/ip_async` 等稳定原因 | ### `auth_sessions` Additions ```sql ALTER TABLE auth_sessions ADD COLUMN revoked_reason VARCHAR(64) NOT NULL DEFAULT '', ADD COLUMN revoked_request_id VARCHAR(96) NOT NULL DEFAULT '', ADD COLUMN revoked_by VARCHAR(64) NOT NULL DEFAULT ''; ``` ## Admin Login Logs 后台管理当前只做查询和展示,不做风控配置编辑。入口位置: ```text 用户管理 └── 登录日志 ``` ### List Page 列表按登录事件展示,默认按 `created_at_ms DESC` 分页。 展示字段: | Column | Source | | --- | --- | | 用户 ID | `login_audit.user_id` | | 短号 | join `users.current_display_user_id` | | 昵称 | join `users.username` | | 国家/区域 | join `users.country/region_id`,只作用户资料快照展示 | | 登录 IP | `login_audit.client_ip` | | IP 所在国家 | `login_audit.ip_country_code` | | 渠道 | `login_audit.channel`,值为 `account/google/facebook/twitter/tiktok` | | 平台 | `login_audit.platform`,值为 `android/ios` | | 结果 | `success/failed/blocked/revoked` | | 屏蔽原因 | `login_audit.block_reason` | | 登录时间 | `login_audit.created_at_ms` | 交互规则: - `blocked=true` 或 `failure_code=AUTH_LOGIN_BLOCKED` 的表格行背景显示红色。 - 如果异步 IP 风控在登录成功后撤销 session,该次登录日志也要更新为屏蔽态,行背景显示红色。 - 点击任意用户行,进入该用户历史登录日志侧栏或详情页。 - 列表只读,不提供解除屏蔽、修改策略、重试 provider 或手动踢下线按钮。 筛选条件: | Filter | Rule | | --- | --- | | 用户 ID / 短号 | 精确搜索,短号需要先解析成 `user_id` | | IP | 精确搜索明文 IP | | IP 国家 | 按 `ip_country_code` | | 渠道 | `account/google/facebook/twitter/tiktok` | | 平台 | `android/ios` | | 是否屏蔽 | `all/blocked/not_blocked` | | 时间范围 | 按 `created_at_ms` | ### User History 点击用户行后展示该用户历史登录日志: | Field | Rule | | --- | --- | | 用户基础信息 | user_id、短号、昵称、头像、当前国家、当前区域 | | 最近登录列表 | 按时间倒序展示该用户所有登录事件 | | 风控结果 | 展示 IP 国家、是否屏蔽、屏蔽原因、撤销 session 时间 | | 渠道和平台 | 展示每次登录的 channel/platform | 用户历史页只读。若后续需要手动封禁、解封或策略配置,必须走独立后台能力和审计,不混在登录日志里。 ## Configuration 首版配置放 gateway、user-service 和 cron-service 的 YAML。当前后台只做登录日志查看,不管理不支持国家、provider 顺序或语言/时区策略: ```yaml login_risk: enabled: true fast_guard: blocked_languages: [] blocked_language_primary_tags: [] blocked_timezones: [] blocked_timezone_prefixes: [] ip: unsupported_countries: - CN blocked_ttl_sec: 2592000 allowed_ttl_sec: 604800 unknown_ttl_sec: 1800 provider_timeout_ms: 800 providers: - primary_geo - fallback_geo_a - fallback_geo_b - fallback_geo_c # services/cron-service/configs/config*.yaml tasks: login_ip_risk: enabled: true interval: 5s timeout: 3s lock_ttl: 30s batch_size: 100 ``` 配置原则: - gateway 快速判断和 user-service IP 风控策略的不支持国家列表必须同源,避免 gateway 放行但后台风控用另一套规则。 - provider 顺序必须显式配置,顺序代表信任优先级;不要在代码里随机切换。 - 线上配置不要写 provider secret;provider key 走密钥系统或环境变量。 - 多 App 时按 `app_code` 覆盖策略。 ## Failure Policy | Failure | Policy | | --- | --- | | Redis 不可用 | 放行登录;记录错误和告警,不读写 IP decision cache;session denylist 失效属于踢下线实时性下降,不能阻断登录 | | MySQL 写登录日志或风险任务失败 | 放行登录;记录 error 和告警,后续依赖下次登录或缓存命中补偿 | | 所有 IP provider 都失败 | 放行登录;能写缓存则写 `unknown` 短 TTL,不能写缓存也不阻塞 | | 前置 provider 不可用 | 自动降级到下一个 provider;如果全部不可用则放行 | | session 已经 logout | 后台风控撤销幂等成功,不重复踢 | | 用户已换新 session | 只撤销命中的 `session_id`;如果 IP 已 blocked,后续登录会被 gateway 拒绝 | | 踢 IM 失败 | session 已撤销仍然生效;记录补偿任务或等待客户端下一次 API 返回 `SESSION_REVOKED` | | 房间踢出失败 | 不直接改 Redis;记录补偿,依赖 Room Cell/heartbeat 兜底 | ## Implementation Sequence 1. 增加 `AUTH_LOGIN_BLOCKED` 错误码,并确认 gateway 对 `SESSION_REVOKED` 的客户端语义。 2. gateway 登录入口增加 language/timezone fast guard 和 IP decision cache 读取。 3. 登录请求把 `client_ip/language/timezone` 透传到 user-service;密码登录也需要补这两个字段。 4. user-service 登录成功后创建 `login_ip_risk_jobs`,任务至少包含 `session_id/user_id/client_ip/client_ip_hash/channel/platform/request_id`。 5. 扩展 `login_audit`,保存明文 IP、IP 国家、渠道、平台、是否屏蔽和屏蔽原因。 6. 实现 `UserCronService.ProcessLoginIPRiskBatch`:MySQL claim 任务,按优先级顺序调用 provider fallback chain,写决策和 Redis cache。 7. user-service 增加按 `session_id` 撤销 session 的内部能力,并写撤销原因。 8. gateway 增加统一 session denylist middleware,JWT 校验后查 `auth:revoked_session:{app_code}:{sid}`。 9. 风控撤销 session 时写 Redis denylist,并把对应登录审计更新为屏蔽态。 10. 接入腾讯云 IM 踢下线或系统消息通知。 11. 增加 room-service 踢人命令或复用现有 Room Cell 管理命令,不直接操作 room Redis。 12. admin 在用户管理下新增二级菜单“登录日志”,支持列表、筛选、红色屏蔽行和用户历史登录日志。 13. 增加日志、指标和告警:provider 超时率、unknown 比例、blocked 数、撤销数、踢下线失败数。 ## Test Cases | Case | Expected | | --- | --- | | language 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED`,不调用 user-service 登录 | | timezone 命中阻断 | gateway 直接返回 `AUTH_LOGIN_BLOCKED` | | IP cache blocked | gateway 直接返回 `AUTH_LOGIN_BLOCKED` | | IP cache miss | 登录成功,创建风险任务 | | 后台风控判定 allowed | 写 allowed cache,不撤销 session | | 后台风控判定 blocked | 撤销 session,写 blocked cache,写 denylist,触发踢下线 | | blocked 后同 IP 再登录 | gateway 登录前拒绝 | | 已撤销 session 调业务接口 | gateway middleware 返回 `SESSION_REVOKED` | | provider 全失败 | 放行,能写缓存则写 unknown cache,不踢下线 | | 第一个 provider 不可用、第二个 provider 返回 blocked country | 写 blocked cache,撤销 session | | 登录日志列表含 blocked 行 | 该行背景为红色 | | 点击用户登录日志行 | 展示该用户历史登录日志 | | refresh 使用已撤销 session | user-service 返回 `SESSION_REVOKED` | ## Open Decisions | Decision | Recommendation | | --- | --- | | Redis/MySQL/IP 查询不可用时是否阻断登录 | 当前方案统一 fail-open:全部放行,只记录错误和告警 | | 第一个可用 provider 命中不支持国家是否踢 | 当前方案直接踢;provider 顺序代表信任优先级 | | 明文 IP 是否落库 | 明文 IP 落 MySQL,普通服务日志仍只打 hash | | 是否做后台管理页面 | 只做用户管理下的“登录日志”二级菜单,不做策略编辑后台 | ## Final Rule 当前阶段最重要的工程约束是:地区风险只在 gateway 入口和 user-service session 层处理,玩法服务不感知风险状态。只要 gateway 统一检查 session denylist,后台异步命中后撤销 session 就能对房间、钱包、礼物、VIP、消息等所有 HTTP 业务统一生效。