hyapp-server/docs/login-region-risk-architecture.md
2026-05-09 21:47:33 +08:00

584 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 secretprovider key 走密钥系统或环境变量。
- 多 App 时按 `app_code` 覆盖策略。
## Failure Policy
| Failure | Policy |
| --- | --- |
| Redis 不可用 | 放行登录;记录错误和告警,不读写 IP decision cachesession 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 middlewareJWT 校验后查 `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 业务统一生效。