vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
---
|
||||
tags: [pattern/auth, oauth2, jwt, token-authentication, refresh-token]
|
||||
create time: 2026-08-08 16:00
|
||||
update time: 2026-08-08 16:00
|
||||
---
|
||||
|
||||
# OAuth2 与 JWT
|
||||
|
||||
## 概述
|
||||
|
||||
OAuth2 是业界标准的授权框架,定义了四种获取访问令牌的流程。JWT(JSON Web Token)则是令牌本身的一种通用格式。二者配合使用构成了现代互联网最常见的认证/授权方案:OAuth2 负责"你是谁、你能做什么",JWT 负责"如何传递这些信息"。
|
||||
|
||||
## OAuth2 四种授权模式详解
|
||||
|
||||
### Authorization Code — 最安全的服务端模式
|
||||
|
||||
**适用场景**:传统 Web 应用、后端服务调用第三方 API。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户浏览器
|
||||
participant Client as 客户端应用
|
||||
participant Auth as 授权服务器
|
||||
participant Resource as 资源服务器
|
||||
|
||||
User->>Client: 点击"用 Google 登录"
|
||||
Client->>User: 重定向到授权端点<br/>?response_type=code&client_id=&redirect_uri=&scope=
|
||||
User->>Auth: 登录并授权
|
||||
Auth->>User: 重定向回 redirect_uri?code=XXX
|
||||
User->>Client: 携带 authorization code
|
||||
Client->>Auth: POST /token (code + client_secret)
|
||||
Auth-->>Client: access_token + id_token + refresh_token
|
||||
Client->>Resource: GET /api/profile (Bearer token)
|
||||
Resource-->>Client: 用户数据
|
||||
```
|
||||
|
||||
核心要点:
|
||||
- **code 是一次性的短期凭证**(通常有效期 5 分钟),换取 token 的过程在后端完成,浏览器拿不到 token。
|
||||
- `client_secret` 只存在于服务端通信中,前端 SPA 不应该持有它。
|
||||
- 回调时建议校验 `state` 参数防止 CSRF。
|
||||
|
||||
> [!NOTE]
|
||||
> PKCE 扩展:对于无法安全存储 `client_secret` 的场景(移动 App、SPA),RFC 7636 定义的 PKCE(Proof Key for Code Exchange)通过 `code_verifier` / `code_challenge` 对来替代 `client_secret`,使 Authorization Code 对所有客户端类型都是安全的。
|
||||
|
||||
### Implicit — 已废弃
|
||||
|
||||
曾用于纯前端 SPA 场景,直接返回 access_token:
|
||||
```
|
||||
redirect_uri?access_token=XXX&expires_in=3600
|
||||
```
|
||||
|
||||
问题在于 **token 暴露在前端**——可以存入 localStorage、被 XSS 窃取、无法撤销。IETF 已在 RFC 6819 中明确标记为过时,推荐使用 Authorization Code + PKCE 替代。
|
||||
|
||||
### Password — 不推荐的第一方应用模式
|
||||
|
||||
用户直接向授权服务器提交用户名和密码:
|
||||
```http
|
||||
POST /token
|
||||
grant_type=password&username=user@example.com&password=secret
|
||||
```
|
||||
|
||||
优点是实现简单。但问题是:
|
||||
- 客户端等同于拿到了用户的密码,**违背了 OAuth 的信任委派原则**。
|
||||
- 无法在不换密码的前提下撤销特定客户端的访问。
|
||||
- 仅适用于你完全信任的第一方应用。
|
||||
|
||||
### Client Credentials — 机器间通信
|
||||
|
||||
没有用户参与,客户端用自己的身份申请令牌:
|
||||
```http
|
||||
POST /token
|
||||
grant_type=client_credentials&client_id=&client_secret=
|
||||
```
|
||||
|
||||
典型场景:后台定时任务、微服务之间的内部调用、CI/CD 流水线拉取制品仓库。
|
||||
|
||||
### 四种模式对比
|
||||
|
||||
| 维度 | Authorization Code | Implicit | Password | Client Credentials |
|
||||
|------|-------------------|----------|----------|-------------------|
|
||||
| 涉及主体 | 用户 + 客户端 | 用户 + 客户端 | 用户 + 客户端 | 客户端 |
|
||||
| token 暴露风险 | 低(后端交换) | **高**(前端可见) | **极高** | 无(无用户) |
|
||||
| 是否需要 client_secret | 推荐(PKCE 可选) | 不适用 | 不适用 | **必需** |
|
||||
| 当前状态 | 标准推荐 | 已废弃 | 受限使用 | 标准推荐 |
|
||||
| 适用环境 | Web / 移动端 | ~~SPA~~ → 转 Code+PKCE | 第一方 App | 服务端互信 |
|
||||
|
||||
## JWT 结构
|
||||
|
||||
JWT 由三段 Base64Url 编码的字符串用 `.` 拼接而成:
|
||||
|
||||
```
|
||||
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. . cCpliwZISlC_ddV2aOKXVuUFtF4QdBSgOzAWo0XihXA
|
||||
Header Payload Signature
|
||||
```
|
||||
|
||||
### Header
|
||||
|
||||
```json
|
||||
{
|
||||
"alg": "RS256",
|
||||
"typ": "JWT"
|
||||
}
|
||||
```
|
||||
|
||||
指定签名算法和令牌类型。
|
||||
|
||||
### Payload(Claim 集)
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "user_12345",
|
||||
"name": "Alice",
|
||||
"role": "admin",
|
||||
"permissions": ["order:read", "order:write", "bucket:manage"],
|
||||
"iat": 1690000000,
|
||||
"exp": 1690003600,
|
||||
"jti": "uuid-v4-here"
|
||||
}
|
||||
```
|
||||
|
||||
标准保留声明:
|
||||
- `iss`(Issuer)— 签发者
|
||||
- `sub`(Subject)— 主题/用户标识
|
||||
- `aud`(Audience)— 接收方
|
||||
- `exp`(Expiration)— 过期时间
|
||||
- `iat`(Issued At)— 签发时间
|
||||
- `jti`(JWT ID)— 唯一标识,支持撤销
|
||||
|
||||
> [!WARNING]
|
||||
> 绝对不要把敏感信息(密码、身份证号)放在 JWT payload 中。JWT 只是 Base64 编码而非加密,任何人都可以解码阅读。需要保密就使用 JWE(加密型 JWT)。
|
||||
|
||||
### Header + Payload → Signature
|
||||
|
||||
```
|
||||
HMACSHA256(
|
||||
base64UrlEncode(header) + "." + base64UrlEncode(payload),
|
||||
secret
|
||||
)
|
||||
```
|
||||
|
||||
或用 RSA 私钥签名(RS256):
|
||||
```
|
||||
RSA-SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), privateKey)
|
||||
```
|
||||
|
||||
## HS256 vs RS256 签名算法选择
|
||||
|
||||
| 维度 | HS256(对称) | RS256(非对称) |
|
||||
|------|--------------|----------------|
|
||||
| 密钥 | 单一 shared secret | 公钥公开 / 私钥保密 |
|
||||
| 验证方 | 必须持有密钥 | 只需公钥 |
|
||||
| 适用场景 | 单体应用、内网服务 | 微服务、第三方集成 |
|
||||
| 撤销能力 | 几乎不可撤销 | 可通过黑名单或短生命周期缓解 |
|
||||
| 性能 | 更快(AES 级别运算) | 略慢(RSA 运算) |
|
||||
| 安全风险 | 密钥泄露 = 所有 token 可伪造 | 仅私钥泄露才有危险 |
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:为什么微服务架构推荐 RS256?因为每个微服务只需要验证公钥而不需要持有签发私钥,即使某个服务被攻陷,攻击者也无法伪造其他服务的 token。HS256 要求所有服务共享同一个密钥,任何一台机器的密钥泄漏都会导致全局信任崩溃。
|
||||
|
||||
## Refresh Token 轮换机制
|
||||
|
||||
Access Token 生命周期短(5~15 分钟),不适合频繁让用户重新登录。Refresh Token 用于无声续期,但必须设计得当以防止重放攻击。
|
||||
|
||||
### 旋转流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 客户端
|
||||
participant Auth as 认证服务器
|
||||
participant Store as Token Store
|
||||
|
||||
Note over Client,Store: 首次登录
|
||||
Auth->>Store: 存储 refresh_token (RT1) <-> user_id
|
||||
Auth-->>Client: RT1 + AT1(短效)
|
||||
|
||||
Note over Client,Store: Access Token 过期后刷新
|
||||
Client->>Auth: POST /refresh {refresh_token: RT1}
|
||||
Auth->>Store: 验证 RT1 有效性且未被吊销
|
||||
Store-->>Auth: 匹配成功
|
||||
Auth->>Store: 删除 RT1,插入 RT2
|
||||
Auth-->>Client: RT2 + AT2
|
||||
|
||||
Note over Client,Store: RT2 再次用于刷新(正常)
|
||||
Client->>Auth: POST /refresh {refresh_token: RT2}
|
||||
Auth->>Store: 验证 RT2...
|
||||
|
||||
Note over Client,Store: RT1 被重放(攻击者窃取了旧 RT1)
|
||||
Client->>Auth: POST /refresh {refresh_token: RT1}
|
||||
Auth->>Store: 查找 RT1 → 不存在!
|
||||
Auth-->>Client: 401 Unauthorized ⚠️ 检测到重放
|
||||
```
|
||||
|
||||
关键设计决策:
|
||||
1. **每次刷新都生成新的 Refresh Token**(Rotation),旧的立即失效。
|
||||
2. **如果同一个 Refresh Token 出现两次**:第一次按正常流程处理(生成新 Token),第二次判定为窃听攻击,同时**级联销毁该用户的所有 token 并要求重新登录**。
|
||||
3. Refresh Token 比 Access Token 更严格地保管——存放在 HttpOnly Cookie 而非 localStorage。
|
||||
|
||||
### 代码示例(Go 伪代码)
|
||||
|
||||
```go
|
||||
func (m *AuthManager) RotateRefreshToken(ctx context.Context, rt string) (newAT, newRT string, err error) {
|
||||
record, err := m.store.GetRefreshToken(ctx, rt)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid refresh token")
|
||||
}
|
||||
|
||||
// 幂等检查:如果这个 RT 已经被使用过,触发全量吊销
|
||||
if record.ConsumedAt != nil {
|
||||
m.store.RevokeAllUserTokens(ctx, record.UserID)
|
||||
return "", "", ErrSuspiciousReplay
|
||||
}
|
||||
|
||||
// 标记旧 RT 已消费,创建新 RT
|
||||
now := time.Now()
|
||||
store.UpdateRefreshTokenConsumed(ctx, rt, now)
|
||||
newRT = generateToken(record.UserID, store.RandomNonce())
|
||||
|
||||
return m.issueAccessToken(ctx, record.UserID), newRT, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Token 黑名单 vs Token 白名单
|
||||
|
||||
| 方案 | 实现方式 | 优点 | 缺点 |
|
||||
|------|---------|------|------|
|
||||
| **黑名单** | 撤销时将 JWT jti 加入 Redis Set | 实现简单,无需改 Token 结构 | 随时间累积占用空间,需定期清理 |
|
||||
| **白名单** | 服务端保存每条合法会话记录 | 天然支持细粒度控制(设备管理、远程注销) | 每次请求都要查库/缓存,有性能开销 |
|
||||
|
||||
实际生产中常用混合策略:
|
||||
- **短生命周期 Access Token**(5 分钟)+ 本地校验(无需网络查询)
|
||||
- **Refresh Token 存储在 Redis**,每次刷新做一次性校验
|
||||
- **特殊场景下的黑名单**用于主动登出、密码修改后的批量踢人
|
||||
|
||||
## 实践场景:完整认证流
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 前端
|
||||
participant AuthSrv as 认证服务
|
||||
participant TokenSrv as Token 服务
|
||||
participant API as 业务 API
|
||||
|
||||
Client->>AuthSrv: POST /login (username/password)
|
||||
AuthSrv->>AuthSrv: 验证凭据
|
||||
AuthSrv->>TokenSrv: 签发 RS256-signed JWT
|
||||
TokenSrv-->>AuthSrv: AT + RT
|
||||
AuthSrv-->>Client: 设置 Cookie(AT, RT) + 302 /dashboard
|
||||
|
||||
Note over Client,API: 后续请求
|
||||
Client->>API: GET /api/orders (Cookie: AT)
|
||||
API->>TokenSrv: 验签 AT(公钥本地验证,零网络调用)
|
||||
TokenSrv-->>API: valid {sub: user_123, role: admin}
|
||||
API-->>Client: JSON response
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 一个常见的误解:JWT ≠ 会话管理。JWT 只是一种**自包含的令牌格式**,它本身不提供会话管理能力。是否用数据库存 session、是否支持远程撤销、是否限制单设备登录——这些都是独立的架构决策,与选用 JWT 还是 opaque token 无关。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[RBAC 权限模型]]
|
||||
@@ -0,0 +1,261 @@
|
||||
---
|
||||
tags: [pattern/auth, rbac, role-inheritance, separation-of-duty, access-control]
|
||||
create time: 2026-08-08 15:30
|
||||
update time: 2026-08-08 15:30
|
||||
---
|
||||
|
||||
# RBAC 权限模型
|
||||
|
||||
## 概述
|
||||
|
||||
RBAC(Role-Based Access Control)是一种基于角色分配访问权限的模型,是目前企业级应用中最主流的权限管理方案。它的核心思想是**将用户与角色关联,而非将用户与权限直接绑定**——这层间接大大降低了权限管理的复杂度。
|
||||
|
||||
## 五张表设计
|
||||
|
||||
### 表结构定义
|
||||
|
||||
```sql
|
||||
-- 1. 用户表
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(128) NOT NULL,
|
||||
email VARCHAR(128),
|
||||
status TINYINT DEFAULT 1 COMMENT '1-active 0-disabled',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 2. 角色表
|
||||
CREATE TABLE roles (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(64) NOT NULL UNIQUE,
|
||||
description VARCHAR(256),
|
||||
is_super TINYINT DEFAULT 0 COMMENT '是否超级管理员',
|
||||
parent_id BIGINT DEFAULT NULL COMMENT '父角色ID(用于继承)',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 3. 权限表
|
||||
CREATE TABLE permissions (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
code VARCHAR(128) NOT NULL UNIQUE COMMENT '权限标识如 user:create',
|
||||
resource VARCHAR(64) NOT NULL COMMENT '资源类型: user/order/bucket',
|
||||
action VARCHAR(64) NOT NULL COMMENT '操作: create/read/update/delete',
|
||||
description VARCHAR(256)
|
||||
);
|
||||
|
||||
-- 4. 用户-角色(多对多)
|
||||
CREATE TABLE user_roles (
|
||||
user_id BIGINT NOT NULL,
|
||||
role_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, role_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id)
|
||||
);
|
||||
|
||||
-- 5. 角色-权限(多对多)
|
||||
CREATE TABLE role_permissions (
|
||||
role_id BIGINT NOT NULL,
|
||||
permission_id BIGINT NOT NULL,
|
||||
PRIMARY KEY (role_id, permission_id),
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id),
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id)
|
||||
);
|
||||
```
|
||||
|
||||
### ER 关系图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ user_roles : "belongs to"
|
||||
roles ||--o{ user_roles : "assigned via"
|
||||
roles ||--o{ role_permissions : "has"
|
||||
permissions ||--o{ role_permissions : "granted through"
|
||||
|
||||
users {
|
||||
BIGINT id PK
|
||||
varchar username
|
||||
varchar password_hash
|
||||
tinyint status
|
||||
}
|
||||
roles {
|
||||
BIGINT id PK
|
||||
varchar name
|
||||
varchar description
|
||||
tinyint is_super
|
||||
BIGINT parent_id FK
|
||||
}
|
||||
permissions {
|
||||
BIGINT id PK
|
||||
varchar code UK
|
||||
varchar resource
|
||||
varchar action
|
||||
}
|
||||
user_roles {
|
||||
BIGINT user_id PK
|
||||
BIGINT role_id PK
|
||||
}
|
||||
role_permissions {
|
||||
BIGINT role_id PK
|
||||
BIGINT permission_id PK
|
||||
}
|
||||
```
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 用户 → 角色 → 权限 的两层映射
|
||||
|
||||
传统 ACL(Access Control List)直接将用户和权限绑定:
|
||||
```
|
||||
Alice → read_order, write_order, delete_user
|
||||
Bob → read_order
|
||||
Carol → read_order, write_bucket
|
||||
```
|
||||
|
||||
问题在于当需要批量授权时(比如给所有财务加 20 个权限),必须逐一修改每个用户的权限列表。
|
||||
|
||||
RBAC 引入角色层后变成:
|
||||
```
|
||||
角色定义:
|
||||
Finance → 20 个权限(包括 read_order, export_report...)
|
||||
Operator → 10 个权限(read_order, write_bucket...)
|
||||
|
||||
用户分配:
|
||||
Alice → Finance
|
||||
Bob → Operator
|
||||
Carol → Operator
|
||||
```
|
||||
|
||||
批量调整只需改角色的权限映射,不影响用户。
|
||||
|
||||
### 超级管理员与普通角色的继承
|
||||
|
||||
超级管理员通常拥有所有权限。但直接给 super_admin 角色分配几百条权限记录既繁琐也不优雅。更合理的做法是**继承机制**:
|
||||
|
||||
```sql
|
||||
-- 在 roles 表中用 parent_id 表示层级
|
||||
INSERT INTO roles (name, is_super, parent_id) VALUES ('super_admin', 1, NULL);
|
||||
INSERT INTO roles (name, is_super, parent_id) VALUES ('admin', 0, (SELECT id FROM roles WHERE name='super_admin'));
|
||||
INSERT INTO roles (name, is_super, parent_id) VALUES ('finance', 0, (SELECT id FROM roles WHERE name='admin'));
|
||||
```
|
||||
|
||||
查询某用户全部权限时递归展开:
|
||||
```go
|
||||
func (m *AuthManager) GetUserPermissions(ctx context.Context, userID int64) ([]string, error) {
|
||||
// 1. 查用户所有角色
|
||||
roles, _ := m.db.QueryContext(ctx, `
|
||||
SELECT r.id, r.is_super, r.parent_id FROM user_roles ur
|
||||
JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = ?`, userID)
|
||||
|
||||
var allCodes []string
|
||||
for roles.Next() {
|
||||
var role Role
|
||||
roles.Scan(&role.ID, &role.IsSuper, &role.ParentID)
|
||||
|
||||
if role.IsSuper {
|
||||
return []string{"*"}, nil
|
||||
}
|
||||
|
||||
perms, _ := m.getRolePermissionCodes(ctx, role.ID)
|
||||
allCodes = append(allCodes, perms...)
|
||||
}
|
||||
return deduplicate(allCodes), nil
|
||||
}
|
||||
```
|
||||
|
||||
### 互斥职责分离(Separation of Duty)
|
||||
|
||||
某些场景下同一人不能同时拥有冲突的角色,例如:
|
||||
- **申请人与审批人不可同一人** — 你不能批准自己的报销单
|
||||
- **出纳与会计不可兼任** — 财务内控基本要求
|
||||
|
||||
实现方式是在角色创建或用户分配时做冲突检测:
|
||||
|
||||
```sql
|
||||
-- 假设 conflict_roles 表定义互斥关系
|
||||
CREATE TABLE conflict_roles (
|
||||
role_a BIGINT NOT NULL,
|
||||
role_b BIGINT NOT NULL,
|
||||
PRIMARY KEY (role_a, role_b)
|
||||
);
|
||||
|
||||
-- 分配角色前检查
|
||||
SELECT COUNT(*) FROM conflict_roles cr
|
||||
JOIN user_roles ur ON ur.role_id IN (cr.role_a, cr.role_b)
|
||||
WHERE ur.user_id = ? AND cr.role_a = ? AND cr.role_b = ?
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> 互斥约束应该在**分配角色层**校验,而不是每次鉴权时检查。前者是策略问题,后者是执行问题——在入口处挡住比到处拦击效率高得多。
|
||||
|
||||
### 动态权限加载流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User as 用户
|
||||
participant API as 认证服务
|
||||
participant DB as 数据库
|
||||
participant Redis as 缓存
|
||||
|
||||
User->>API: 登录
|
||||
API->>DB: 查询用户所有角色
|
||||
DB-->>API: 返回角色列表
|
||||
API->>DB: 查询各角色的权限码
|
||||
DB-->>API: 返回权限码列表
|
||||
API->>Redis: 写入用户权限缓存 (TTL=2h)
|
||||
Redis-->>API: OK
|
||||
API-->>User: 登录成功 + Token
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 登录时将完整权限树缓存在 Redis 中,鉴权接口只需 O(1) 读取缓存。权限变更时主动删除对应用户的缓存条目,实现最终一致。不要每次都查库。
|
||||
|
||||
## 代码示例:权限中间件(Go)
|
||||
|
||||
```go
|
||||
type ContextKey string
|
||||
|
||||
const PermissionKey ContextKey = "permissions"
|
||||
|
||||
func AuthMiddleware(h Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractToken(r)
|
||||
claims, _ := jwt.Parse(token)
|
||||
userID := claims.UserID
|
||||
|
||||
// 从 Redis 获取权限
|
||||
perms, err := cache.GetPermissions(r.Context(), userID)
|
||||
if err != nil {
|
||||
perms = loadFromDB(userID)
|
||||
cache.Set(userID, perms, 2*time.Hour)
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), PermissionKey, perms)
|
||||
h.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
// 检查某个权限
|
||||
func HasPermission(ctx context.Context, required string) bool {
|
||||
perms := ctx.Value(PermissionKey).([]string)
|
||||
for _, p := range perms {
|
||||
if p == "*" || p == required {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
| 场景 | 选型建议 |
|
||||
|------|---------|
|
||||
| 小型系统(用户 < 100) | 简单角色 + 硬编码判断即可,不必上完整 RBAC |
|
||||
| 中型业务系统 | 标准五表 RBAC + Redis 缓存 |
|
||||
| 平台型 SaaS | RBAC + 租户隔离(每租户可自定义角色) |
|
||||
| 强合规要求(金融/医疗) | RBAC + SoD 互斥约束 + 完整审计日志 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[OAuth2 与 JWT]]
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
tags: [pattern/fsm, workflow-engine, order-lifecycle, state-machine-pattern]
|
||||
create time: 2026-08-08 15:00
|
||||
update time: 2026-08-08 15:00
|
||||
---
|
||||
|
||||
# 有限状态机在业务中的应用
|
||||
|
||||
## 概述
|
||||
|
||||
FSM 在工程中最大的价值是将"散落的状态判断"收拢到"显式的状态定义"。本文讨论三种典型的业务场景:审批流、订单生命周期和资源交付,每种场景展示其状态图和关键注意事项。
|
||||
|
||||
## 审批流
|
||||
|
||||
### 典型流程
|
||||
|
||||
任务从提交到最终结果需经过多级审批,每个审批人可以批准或驳回。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Draft
|
||||
Draft --> Submitted: 提交申请
|
||||
Submitted --> ManagerReview: 主管审核
|
||||
ManagerReview --> Approved: 批准
|
||||
ManagerReview --> Rejected: 驳回
|
||||
ManagerReview --> ManagerRevise: 修改后重提
|
||||
Approved --> FinanceReview: 进入财务审核
|
||||
FinanceReview --> FinalApproved: 财务批准
|
||||
FinanceReview --> Rejected: 财务驳回
|
||||
Rejected --> [*]
|
||||
FinalApproved --> [*]
|
||||
ManagerRevise --> Draft: 退回草稿
|
||||
```
|
||||
|
||||
### 关键注意事项
|
||||
|
||||
| 注意点 | 说明 |
|
||||
|--------|------|
|
||||
| 权限校验 | 每个审批节点需要确认审批人是否有该角色的审批权限 |
|
||||
| 驳回的语义 | "驳回"可以是终止(回终态)也可以是退回上一步(回到前置状态),需要在模型中明确区分 |
|
||||
| 超期自动处理 | 审批超过 N 天未操作应触发超时事件,默认通过或升级给上级 |
|
||||
| 审计追踪 | 每次状态转换记录 `operator`、`timestamp`、`comment`,不可删除 |
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:如果审批链动态变化(比如 A 请假了由 B 代审),这不再是静态 FSM,而是需要引入**策略模式 + 规则引擎**。硬编码审批人在生产环境中是不可接受的。
|
||||
|
||||
## 订单生命周期
|
||||
|
||||
### 状态图
|
||||
|
||||
订单是最经典的 FSM 应用场景。合法转移必须严格受控,防止"跳状态"。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> PendingPayment
|
||||
PendingPayment --> Paid: 用户支付
|
||||
PendingPayment --> Cancelled: 超时取消
|
||||
Paid --> Shipped: 仓库发货
|
||||
Paid --> Refunding: 申请退款
|
||||
Shipped --> Completed: 用户确认收货
|
||||
Shipped --> Refunding: 申请退款
|
||||
Completed --> [*]
|
||||
Refunding --> Refunded: 退款完成
|
||||
Refunding --> Paid: 退款拒绝
|
||||
Cancelled --> [*]
|
||||
Refunded --> [*]
|
||||
```
|
||||
|
||||
### 非法转移示例
|
||||
|
||||
```
|
||||
Paid -> Cancelled ❌ 已支付的订单不能直接取消
|
||||
Completed -> Shipped ❌ 已完成不能倒退回已发货
|
||||
PendingPayment -> Shipped ❌ 未付款不能直接发货
|
||||
```
|
||||
|
||||
### 代码示例
|
||||
|
||||
```go
|
||||
func (m *OrderManager) Transition(ctx context.Context, orderID string, event string) error {
|
||||
// 1. 获取当前状态
|
||||
order, _ := m.repo.FindByID(ctx, orderID)
|
||||
|
||||
// 2. 查表确认转换是否合法
|
||||
handler, ok := m.transitions[order.Status][event]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid transition: %s -> [%s]", order.Status, event)
|
||||
}
|
||||
|
||||
// 3. 执行转换
|
||||
if err := handler(ctx, order); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.repo.Save(ctx, order)
|
||||
}
|
||||
```
|
||||
|
||||
## 资源交付八阶段(七牛云存储场景)
|
||||
|
||||
这是一个更复杂的 FSM 应用。假设我们正在实现一个对象存储服务,资源从创建到销毁经历以下八个阶段。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> BucketCreated
|
||||
BucketCreated --> FileUploaded: 上传文件
|
||||
FileUploaded --> CDNPrefetch: CDN预热
|
||||
CDNPrefetch --> AccessAuthorized: 设置访问策略
|
||||
AccessAuthorized --> TrafficTracked: 采集流量统计
|
||||
TrafficTracked --> LifecycleConfigured: 配置生命周期
|
||||
LifecycleConfigured --> CleanupPending: 满足清理条件
|
||||
CleanupPending --> [*]: 物理删除
|
||||
```
|
||||
|
||||
### 各阶段职责
|
||||
|
||||
| 阶段 | 核心操作 | 失败回退策略 |
|
||||
|------|---------|-------------|
|
||||
| BucketCreated | 创建存储空间,分配地域和冗余策略 | 幂等重试,已有 bucket 视为成功 |
|
||||
| FileUploaded | PUT 对象请求,校验 ETag 一致性 | 断点续传 + 分片合并 |
|
||||
| CDNPrefetch | 向 CDN 节点下发缓存指令 | 异步队列,不阻塞主链路 |
|
||||
| AccessAuthorized | 签发临时签名 URL 或配置 ACL | 与文件上传绑定,原子操作 |
|
||||
| TrafficTracked | 持续指标上报至时序数据库 | 本地 buffer,批量异步发送 |
|
||||
| LifecycleConfigured | 设置过期规则(如 90 天自动删除) | API 级别幂等更新 |
|
||||
| CleanupPending | 定时任务扫描到期对象,标记为删除中 | 二次确认后执行,可撤销 |
|
||||
| 物理删除 | 实际调用存储 API 删除对象 | 事务性删除,失败告警 |
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
> [!WARNING]
|
||||
> 资源删除必须是**两阶段**的:先标记 `CleanupPending`,再执行 `物理删除`。这避免误删且支持回滚。跳过这个阶段是数据丢失的头号原因。
|
||||
|
||||
> [!NOTE]
|
||||
> 为什么"流量统计"被建模为状态而不是事件?因为流量统计是一个**持续的监控行为**,不是一个瞬时动作。在实际工程中,它可能通过侧车(sidecar)或事件总线持续运行,不受单用户的控制流影响。FSM 在这里只记录"是否已进入此阶段"。
|
||||
|
||||
## 对比总结
|
||||
|
||||
| 维度 | 审批流 | 订单生命周期 | 资源交付 |
|
||||
|------|--------|-------------|---------|
|
||||
| 状态数量 | 5-8 | 6-8 | 8 |
|
||||
| 循环转移 | 有(退回修改) | 部分(退款→重新支付) | 无(线性流程) |
|
||||
| 并发安全要求 | 中等(单人审批) | **极高**(多人同时操作) | 高(异步任务冲突) |
|
||||
| 持久化策略 | 每步写 DB | 每步写 DB + 事件日志 | 最终一致 + 补偿机制 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[有限状态机核心概念]]
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
tags: [pattern/fsm, finite-state-machine, state-transition, dfa-nfa, fsm-implementation]
|
||||
create time: 2026-08-08 14:30
|
||||
update time: 2026-08-08 14:30
|
||||
---
|
||||
|
||||
# 有限状态机核心概念
|
||||
|
||||
## 概述
|
||||
|
||||
有限状态机(Finite State Machine,简称 FSM)是一种数学计算模型,也是软件工程中最实用的抽象之一。它将任何有明确"状态"和"动作"的系统建模为一组离散状态的集合,配合事件驱动的转换规则,让复杂业务流程变得可预测、可测试、可追溯。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 四大核心概念
|
||||
|
||||
| 概念 | 定义 | 示例 |
|
||||
|------|------|------|
|
||||
| 状态 (State) | 系统在某一时刻所处的条件或模式 | `PENDING`、`APPROVED`、`REJECTED` |
|
||||
| 事件 (Event) | 触发状态转换的外部或内部信号 | `submit`、`approve`、`reject` |
|
||||
| 转换 (Transition) | 从一个状态到另一个状态的映射关系 | `PENDING + approve -> APPROVED` |
|
||||
| 动作 (Action) | 状态转换时或进入状态时执行的副作用 | 发送邮件通知、写审计日志 |
|
||||
|
||||
一个完整的 FSM 可以用五元组表示:`(States, Events, Transitions, Actions, StartState)`
|
||||
|
||||
### DFA vs NFA
|
||||
|
||||
确定性有限自动机(DFA)和非确定性有限自动机(NFA)是两种理论模型,区别在于同一个状态下对同一事件的响应是否唯一。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph DFA["DFA - 确定性有限自动机"]
|
||||
A["状态A"] -->|"事件X"| B["状态B"]
|
||||
A -->|"事件Y"| C["状态C"]
|
||||
B -->|"事件X"| D["状态D"]
|
||||
B -->|"事件Y"| C
|
||||
end
|
||||
|
||||
subgraph NFA["NFA - 非确定性有限自动机"]
|
||||
E["状态E"] -->|"事件X"| F["状态F"]
|
||||
E -->|"事件X"| G["状态G"]
|
||||
E -->|"事件Y"| H["状态H"]
|
||||
end
|
||||
```
|
||||
|
||||
关键区别:
|
||||
|
||||
- **DFA**:每个状态对每个事件最多只有一个后继状态。行为可预测,适合工程实现。
|
||||
- **NFA**:同一个状态对同一事件可能转移到多个后继状态,甚至可以无转移地"猜"一条路径。等价于 DFA 但更紧凑,适合正则表达式引擎底层。
|
||||
|
||||
> [!NOTE]
|
||||
> 工程实践中的 FSM 几乎总是 DFA。Go 里的 switch-case、map-of-function-map、接口模式本质上都是 DFA 的编程表达。
|
||||
|
||||
### 状态转移方程
|
||||
|
||||
FSM 的核心是转移函数 δ(delta):
|
||||
|
||||
```
|
||||
δ(current_state, event) → next_state
|
||||
```
|
||||
|
||||
当转移存在 Action 时扩展为:
|
||||
|
||||
```
|
||||
δ(current_state, event) → (next_state, action)
|
||||
```
|
||||
|
||||
约束条件:
|
||||
- 转移函数必须是**部分函数** — 并非所有事件在所有状态都有效。例如订单已经"已退款"后不能再"支付"。
|
||||
- 非法转移应返回错误或被拒绝,而非静默忽略。
|
||||
|
||||
## 代码示例
|
||||
|
||||
### 方式一:switch-case 枚举
|
||||
|
||||
最直观的方式,适合状态数少于 10 的场景。
|
||||
|
||||
```go
|
||||
type OrderStatus int
|
||||
|
||||
const (
|
||||
Pending OrderStatus = iota
|
||||
Paid
|
||||
Shipped
|
||||
Completed
|
||||
Refunding
|
||||
Refunded
|
||||
)
|
||||
|
||||
func (o *Order) Handle(event string) error {
|
||||
switch o.Status {
|
||||
case Pending:
|
||||
if event == "pay" {
|
||||
o.Status = Paid
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot %s from %s", event, o.Status)
|
||||
case Paid:
|
||||
if event == "ship" {
|
||||
o.Status = Shipped
|
||||
return nil
|
||||
}
|
||||
// ...
|
||||
}
|
||||
return fmt.Errorf("invalid transition")
|
||||
}
|
||||
```
|
||||
|
||||
### 方式二:map-of-function-map
|
||||
|
||||
将状态转成数据驱动,新增状态只需注册新规则,无需改 switch。
|
||||
|
||||
```go
|
||||
type FSM struct {
|
||||
transitions map[State]map[string]func() error
|
||||
State State
|
||||
}
|
||||
|
||||
func (f *FSM) Register(state State, event string, fn func() error) {
|
||||
if f.transitions[state] == nil {
|
||||
f.transitions[state] = make(map[string]func() error)
|
||||
}
|
||||
f.transitions[state][event] = fn
|
||||
}
|
||||
|
||||
func (f *FSM) Fire(event string) error {
|
||||
handlers, ok := f.transitions[f.State][event]
|
||||
if !ok {
|
||||
return fmt.Errorf("no handler for %q in state %v", event, f.State)
|
||||
}
|
||||
return handlers()
|
||||
}
|
||||
```
|
||||
|
||||
### 方式三:State 接口 + Context 模式
|
||||
|
||||
面向对象的 FSM 设计,每个状态独立封装行为。
|
||||
|
||||
```go
|
||||
type State interface {
|
||||
Enter(ctx *Context) error
|
||||
Handle(event string) (State, error)
|
||||
}
|
||||
|
||||
type Context struct {
|
||||
state State
|
||||
data map[string]any
|
||||
}
|
||||
|
||||
func (c *Context) Fire(event string) error {
|
||||
next, err := c.state.Handle(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := next.Enter(c); err != nil {
|
||||
return err
|
||||
}
|
||||
c.state = next
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
| 选型策略 | 适用场景 | 优点 | 缺点 |
|
||||
|---------|---------|------|------|
|
||||
| switch-case | 状态少(≤10)、逻辑简单 | 直观、调试容易 | 违反开闭原则 |
|
||||
| map-of-function | 状态中等(10~50)、需要灵活扩展 | 注册式扩展、易测试 | 闭包上下文传递稍繁琐 |
|
||||
| State 接口 | 状态多(>50)、每个状态行为复杂 | 单一职责、面向对象 | 有一定架构开销 |
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考点:为什么不建议用数字枚举 + 硬编码数组来做状态校验?因为数字枚举不具备语义表达能力,编译期无法捕获非法转移。
|
||||
|
||||
> [!WARNING]
|
||||
> 常见误区:把 FSM 当成普通的 if-else。FSM 的核心价值是**把所有合法转换集中在一处定义**,而不是散落在业务逻辑各处。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[有限状态机在业务中的应用]]
|
||||
Reference in New Issue
Block a user