# 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 业务统一生效。
