文章目录
- [1. 设计模式概述](#1. 设计模式概述)
-
- [1.1 什么是设计模式](#1.1 什么是设计模式)
- [1.2 智能合约中的6个核心设计模式](#1.2 智能合约中的6个核心设计模式)
- [2. 访问控制模式](#2. 访问控制模式)
-
- [2.1 为什么需要访问控制](#2.1 为什么需要访问控制)
- [2.2 Ownable模式](#2.2 Ownable模式)
- [2.3 RBAC模式(基于角色的访问控制)](#2.3 RBAC模式(基于角色的访问控制))
- [2.4 使用OpenZeppelin的实现](#2.4 使用OpenZeppelin的实现)
- [3. 提现模式](#3. 提现模式)
-
- [3.1 传统做法的风险](#3.1 传统做法的风险)
- [3.2 攻击合约示例](#3.2 攻击合约示例)
- [3.3 安全方案1:Pull Over Push模式](#3.3 安全方案1:Pull Over Push模式)
- [3.5 结合重入锁](#3.5 结合重入锁)
- [4. 状态机模式](#4. 状态机模式)
-
- [4.1 什么是状态机](#4.1 什么是状态机)
- [4.2 ICO众筹示例](#4.2 ICO众筹示例)
- [4.3 适用场景](#4.3 适用场景)
1. 设计模式概述
1.1 什么是设计模式
在软件工程中,设计模式是被反复使用的、经过实践验证的解决方案。它们不是具体的代码,而是一种解决特定问题的思路和方法。
设计模式的价值:
-
提高代码质量:
- 经过实践验证的解决方案
- 减少常见错误
- 提高代码可维护性
-
加速开发:
- 不需要从零开始设计
- 复用成熟的方案
- 减少开发时间
-
增强安全性:
- 模式通常考虑了安全因素
- 避免常见的安全漏洞
- 提高合约的可靠性
-
便于协作:
- 团队成员都理解这些模式
- 代码更容易理解和维护
- 降低沟通成本
1.2 智能合约中的6个核心设计模式
在智能合约开发领域,有6个核心的设计模式,它们分别解决不同的问题:
-
访问控制模式:
- 解决权限管理问题
- 确保只有授权者能执行敏感操作
- 基础但至关重要
-
提现模式:
- 解决资金转账的安全问题
- 防止重入攻击
- 确保资金安全转移
-
状态机模式:
- 管理合约的生命周期
- 规范状态转换流程
- 适用于有明确阶段的场景
-
代理模式:
- 实现合约升级
- 分离数据和逻辑
- 解决不可变性与升级需求的矛盾
-
工厂模式:
- 批量部署相同类型的合约
- 降低部署成本
- 统一管理合约实例
-
紧急停止模式:
- 风险控制机制
- 快速暂停合约功能
- 保护用户资产安全
这些模式在实际项目中通常会组合使用,共同构建安全可靠的智能合约系统。
2. 访问控制模式
访问控制模式是几乎所有合约都需要的基础模式。通过权限管理,我们可以确保系统的安全性和完整性,控制谁可以执行敏感操作。
2.1 为什么需要访问控制
设想一下,如果一个合约没有任何访问控制,会发生什么?
没有访问控制的危险:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 没有访问控制的危险合约
contract UnsafeToken {
mapping(address => uint256) public balances;
uint256 public totalSupply;
/**
* @notice 任何人都可以铸造代币
* @dev 危险:没有权限检查,攻击者可以给自己铸造无限代币
*/
function mint(address to, uint256 amount) public {
balances[to] += amount;
totalSupply += amount;
}
/**
* @notice 任何人都可以销毁合约
* @dev 危险:没有权限检查,任何人都可以销毁合约并提取资金
*/
function destroy() public {
selfdestruct(payable(msg.sender));
}
}
问题分析:
-
任何人都可以铸造代币:
- 攻击者可以给自己铸造无限代币
- 代币价值会瞬间归零
- 项目完全崩溃
-
任何人都可以销毁合约:
- 攻击者可以销毁合约并提取所有资金
- 用户资金全部丢失
- 系统完全瘫痪
-
无法审计和追踪:
- 不知道谁执行了什么操作
- 无法追溯问题来源
- 无法进行权限管理
-
有了访问控制的好处:
- 确保只有授权者能执行敏感操作
- 不同角色可以拥有不同的权限
- 可以转移或撤销权限
- 能够审计追踪所有的操作历史
2.2 Ownable模式
Ownable模式是最基础的访问控制实现。合约有一个owner地址,所有关键操作都需要通过onlyOwner修饰符来检查调用者是否是owner。
实现示例:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Ownable模式:简单的单所有者权限控制
contract OwnableToken {
// 记录合约所有者
address public owner;
mapping(address => uint256) public balances;
uint256 public totalSupply;
// 事件:记录所有权转移
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
/**
* @notice 构造函数:初始化owner
* @dev 部署合约时,msg.sender成为owner
*/
constructor() {
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
}
/**
* @notice onlyOwner修饰符
* @dev 只有owner可以调用被此修饰符修饰的函数
*/
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
/**
* @notice 铸造代币(只有owner可以调用)
* @param to 接收者地址
* @param amount 铸造数量
* @dev 使用onlyOwner修饰符确保只有owner可以铸造
*/
function mint(address to, uint256 amount) public onlyOwner {
balances[to] += amount;
totalSupply += amount;
}
/**
* @notice 转移所有权
* @param newOwner 新的所有者地址
* @dev 只有当前owner可以转移所有权
*/
function transferOwnership(address newOwner) public onlyOwner {
require(newOwner != address(0), "Invalid owner");
address oldOwner = owner;
owner = newOwner;
emit OwnershipTransferred(oldOwner, newOwner);
}
/**
* @notice 放弃所有权
* @dev owner可以放弃所有权,之后合约将无法升级或修改
*/
function renounceOwnership() public onlyOwner {
address oldOwner = owner;
owner = address(0);
emit OwnershipTransferred(oldOwner, address(0));
}
}
Ownable模式的特点:
优点:
- 实现简单,易于理解
- Gas成本低
- 适合权限需求单一的场景
缺点:
- 只有一个角色(owner)
- 无法实现细粒度的权限控制
- 不适合复杂的权限需求
适用场景:
- 简单的代币合约
- 权限需求单一的项目
- 小型项目或原型
2.3 RBAC模式(基于角色的访问控制)
RBAC(Role-Based Access Control)是更灵活的权限管理方式。OpenZeppelin提供了AccessControl合约,允许我们定义多个角色,每个函数可以指定需要的角色。
实现示例:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// RBAC模式:基于角色的访问控制
contract RBACToken {
// 角色映射:角色 => 地址 => 是否有权限
mapping(bytes32 => mapping(address => bool)) private roles;
mapping(address => uint256) public balances;
uint256 public totalSupply;
bool public paused;
// 定义角色常量
// 使用keccak256确保角色标识符的唯一性
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
// 事件:记录角色授予和撤销
event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender);
event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender);
/**
* @notice 构造函数:初始化管理员
* @dev 部署者自动获得ADMIN_ROLE
*/
constructor() {
roles[ADMIN_ROLE][msg.sender] = true;
emit RoleGranted(ADMIN_ROLE, msg.sender, msg.sender);
}
/**
* @notice onlyRole修饰符
* @param role 需要的角色
* @dev 检查调用者是否拥有指定角色
*/
modifier onlyRole(bytes32 role) {
require(roles[role][msg.sender], "Access denied");
_;
}
/**
* @notice 检查地址是否拥有角色
* @param role 角色
* @param account 地址
* @return 是否拥有角色
*/
function hasRole(bytes32 role, address account) public view returns (bool) {
return roles[role][account];
}
/**
* @notice 授予角色
* @param role 角色
* @param account 地址
* @dev 只有ADMIN可以授予角色
*/
function grantRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
require(!roles[role][account], "Already has role");
roles[role][account] = true;
emit RoleGranted(role, account, msg.sender);
}
/**
* @notice 撤销角色
* @param role 角色
* @param account 地址
* @dev 只有ADMIN可以撤销角色
*/
function revokeRole(bytes32 role, address account) public onlyRole(ADMIN_ROLE) {
require(roles[role][account], "Does not have role");
roles[role][account] = false;
emit RoleRevoked(role, account, msg.sender);
}
/**
* @notice 铸造代币(只有MINTER可以调用)
* @param to 接收者地址
* @param amount 铸造数量
* @dev 使用onlyRole(MINTER_ROLE)确保只有MINTER可以铸造
*/
function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
require(!paused, "Contract is paused");
balances[to] += amount;
totalSupply += amount;
}
/**
* @notice 销毁代币(只有BURNER可以调用)
* @param from 销毁者地址
* @param amount 销毁数量
* @dev 使用onlyRole(BURNER_ROLE)确保只有BURNER可以销毁
*/
function burn(address from, uint256 amount) public onlyRole(BURNER_ROLE) {
require(balances[from] >= amount, "Insufficient balance");
balances[from] -= amount;
totalSupply -= amount;
}
/**
* @notice 暂停合约(只有PAUSER可以调用)
* @dev 暂停后,mint等操作将无法执行
*/
function pause() public onlyRole(PAUSER_ROLE) {
paused = true;
}
/**
* @notice 恢复合约(只有PAUSER可以调用)
* @dev 恢复后,合约功能恢复正常
*/
function unpause() public onlyRole(PAUSER_ROLE) {
paused = false;
}
}
RBAC模式的特点:
优点:
- 支持多个角色
- 权限管理灵活
- 可以实现细粒度的权限控制
- 适合大型项目
缺点:
- 实现相对复杂
- Gas成本稍高
- 需要仔细设计角色体系
适用场景:
- 大型DeFi协议
- 需要多角色管理的项目
- 复杂的权限需求
2.4 使用OpenZeppelin的实现
OpenZeppelin提供了经过充分审计的访问控制实现,推荐直接使用:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 导入OpenZeppelin的Ownable
import "@openzeppelin/contracts/access/Ownable.sol";
// 使用OpenZeppelin Ownable的代币合约
contract SecureToken is Ownable {
mapping(address => uint256) public balances;
uint256 public totalSupply;
/**
* @notice 构造函数
* @dev Ownable会自动将msg.sender设置为owner
*/
constructor() Ownable() {
// owner已经在Ownable的构造函数中设置
}
/**
* @notice 铸造代币
* @param to 接收者地址
* @param amount 铸造数量
* @dev 使用onlyOwner修饰符
*/
function mint(address to, uint256 amount) external onlyOwner {
balances[to] += amount;
totalSupply += amount;
}
}
OpenZeppelin的优势:
- 经过充分审计
- 被广泛使用
- 提供完整的功能
- 持续维护和更新
推荐做法:
除非有特殊需求,否则应该使用OpenZeppelin的标准实现,而不是自己从零实现。
3. 提现模式
提现模式专门用于处理资金转账的安全问题。这个模式能够有效防止重入攻击,确保资金的安全转移。
3.1 传统做法的风险
很多开发者会写出这样的代码:先调用transfer或send转账给用户,然后再更新用户的余额。这种写法有三大核心风险。
不安全的实现:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 存在重入漏洞的银行合约
contract VulnerableBank {
mapping(address => uint256) public balances;
/**
* @notice 存款函数
* @dev 用户可以存入以太币
*/
function deposit() public payable {
balances[msg.sender] += msg.value;
}
/**
* @notice 提现函数(存在重入漏洞!)
* @dev 危险:先转账,后更新状态
*/
function withdraw() public {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 危险:先转账
// 如果接收者是一个恶意合约,它可以在receive函数中再次调用withdraw
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
// 危险:后更新状态
// 如果发生重入,此时余额还没有被清零,攻击者可以再次提取
balances[msg.sender] = 0;
}
/**
* @notice 查询合约余额
*/
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
三大核心风险:
重入攻击风险:
- 如果接收者是一个恶意合约,它可以在receive或fallback函数中再次调用withdraw
- 由于余额还没有被清零,攻击者可以反复提取资金
- 2016年著名的The DAO攻击就是因为这个漏洞,导致损失了5000万美元
Gas不足导致转账失败:
- 如果transfer或send失败,但用户余额已经被扣除
- 就会导致用户资金被锁定在合约中
- 用户无法取回资金
影响其他用户:
- 如果某一笔转账失败,可能会影响到其他用户的正常操作
- 导致整个系统的不稳定
3.2 攻击合约示例
以下是一个利用重入漏洞的攻击合约:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 攻击合约:利用重入漏洞
contract Attacker {
VulnerableBank public bank;
uint256 public attackCount;
/**
* @notice 构造函数:初始化目标银行合约地址
* @param _bankAddress 目标银行合约地址
*/
constructor(address _bankAddress) {
bank = VulnerableBank(_bankAddress);
}
/**
* @notice 发起攻击
* @dev 攻击流程:先存款,再提现,在receive中触发重入
*/
function attack() public payable {
require(msg.value >= 1 ether, "Need at least 1 ether");
attackCount = 0;
// 步骤1:先向银行存入1 ether
bank.deposit{value: msg.value}();
// 步骤2:发起第一次提现
// 这会触发receive函数,在receive中会再次调用withdraw
bank.withdraw();
}
/**
* @notice 接收以太币时触发重入攻击
* @dev 这是攻击的关键:在receive函数中再次调用withdraw
*/
receive() external payable {
// 限制攻击次数,避免Gas耗尽
if (attackCount < 3 && address(bank).balance >= 1 ether) {
attackCount++;
// 重入攻击:再次调用withdraw
// 此时bank的balances[address(this)]还没有被清零
bank.withdraw();
}
}
/**
* @notice 提取攻击获得的资金
*/
function getStolen() public {
payable(msg.sender).transfer(address(this).balance);
}
}
攻击流程:
bash
1. 攻击者调用attack(),存入1 ether
- bank.balances[attacker] = 1 ether
2. 攻击者调用withdraw()
- 检查余额:1 ether(通过)
- 向攻击者转账1 ether
- 触发攻击者的receive()函数
3. receive()函数中再次调用withdraw()
- 此时balances[attacker]还是1 ether(还没被清零!)
- 检查余额:1 ether(通过)
- 再次向攻击者转账1 ether
- 再次触发receive()函数
4. 重复步骤3,直到攻击次数达到限制
- 最终攻击者提取了4 ether(1 ether本金 + 3 ether窃取)
3.3 安全方案1:Pull Over Push模式
Pull Over Push模式的核心思想是:让用户主动来提现,而不是合约主动推送资金。
Pull模式实现:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// Pull模式:用户主动提现
contract SafeBankPull {
// 记录每个用户的待提现金额
mapping(address => uint256) public pendingWithdrawals;
/**
* @notice 存款函数
* @dev 用户存入以太币
*/
function deposit() public payable {
// 直接记录待提现金额,不立即转账
pendingWithdrawals[msg.sender] += msg.value;
}
/**
* @notice 提现函数(Pull模式)
* @dev 用户主动调用此函数来提取自己的资金
*/
function withdraw() public {
// 获取用户的待提现金额
uint256 amount = pendingWithdrawals[msg.sender];
require(amount > 0, "No pending withdrawal");
// 先清零待提现金额(防止重入)
pendingWithdrawals[msg.sender] = 0;
// 然后转账
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
/**
* @notice 查询合约余额
*/
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
Pull模式的优势:
- 防止重入攻击:
余额在转账前就被清零
即使发生重入,余额检查也会失败
- Gas由用户承担:
用户主动调用withdraw
Gas消耗由用户支付
不会因为Gas耗尽导致功能不可用
- 更好的用户体验:
用户可以选择何时提现
可以分批提现
更灵活
3.4 安全方案2:CEI原则
CEI原则(Checks-Effects-Interactions)是一个非常重要的安全原则。执行顺序是:首先进行所有的检查,然后更新合约的状态变量,最后才进行外部交互。
CEI模式实现:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// CEI模式:遵循检查-效果-交互原则
contract SafeBankCEI {
mapping(address => uint256) public balances;
/**
* @notice 存款函数
*/
function deposit() public payable {
balances[msg.sender] += msg.value;
}
/**
* @notice 提现函数(遵循CEI原则)
* @dev 按照Checks-Effects-Interactions的顺序执行
*/
function withdraw() public {
// 1. Checks(检查):验证所有条件
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// 2. Effects(效果):先更新状态
// 关键:在外部调用之前更新状态
// 这样即使发生重入,余额检查也会失败
balances[msg.sender] = 0;
// 3. Interactions(交互):然后进行外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
/**
* @notice 查询合约余额
*/
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
CEI原则的关键点:
- Checks(检查):
首先验证所有前置条件
检查余额是否足够
检查参数是否有效
- Effects(效果):
然后更新合约状态
将余额清零
更新其他状态变量
- Interactions(交互):
最后进行外部调用
转账给用户
调用外部合约
- 为什么CEI模式安全:
余额在外部调用前就是0
即使发生重入,余额检查也会失败(require(amount > 0)会失败)
攻击无效
3.5 结合重入锁
除了CEI原则,我们还可以使用重入锁提供额外的保护:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 结合CEI和重入锁的安全实现
contract SafeBankWithLock {
mapping(address => uint256) public balances;
// 重入锁
bool private locked;
/**
* @notice 重入锁修饰符
* @dev 防止函数被重入调用
*/
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
/**
* @notice 提现函数(CEI + 重入锁)
* @dev 双重保护:CEI原则 + 重入锁
*/
function withdraw() public noReentrant {
// Checks
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Effects
balances[msg.sender] = 0;
// Interactions
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
最佳实践:
- 优先使用CEI模式(零成本)
- 关键函数添加重入锁(额外保护)
- 使用OpenZeppelin的ReentrancyGuard(经过审计)
4. 状态机模式
状态机模式用于管理合约的生命周期。通过定义有限状态和状态转换规则,我们可以规范合约的行为,确保在正确的状态下执行正确的操作。
4.1 什么是状态机
状态机(State Machine)是一种计算模型,它定义了一组有限的状态,以及状态之间的转换规则。在不同的状态下,系统允许执行的操作是不同的。
状态机的核心概念:
- 状态(State):
系统在某个时刻的特定情况
用enum定义所有可能的状态
- 转换(Transition):
从一个状态转换到另一个状态
需要满足特定的条件
- 规则(Rules):
定义在什么状态下可以执行什么操作
确保操作的合法性
状态机模式的优势:
- 行为规范:
每个状态下允许的操作是明确的
避免了在错误的时间执行错误的操作
- 可预测性:
状态转换的逻辑集中管理
便于理解和维护
- 安全性:
防止在错误状态下执行操作
减少逻辑错误
4.2 ICO众筹示例
让我们通过一个ICO众筹的例子来理解状态机模式:
bash
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// 状态机模式:ICO众筹合约
contract SimpleCrowdfunding {
// 定义所有可能的状态
enum State {
Preparing, // 准备阶段:项目初始化,还未开始募资
Funding, // 募资阶段:用户可以投资
Success, // 成功:达到募资目标
Failed // 失败:未达到募资目标
}
// 当前状态
State public state;
// 项目信息
address public owner;
uint256 public goal; // 募资目标
uint256 public raised; // 已募集金额
uint256 public deadline; // 截止时间
// 记录每个用户的投资金额
mapping(address => uint256) public contributions;
// 事件:记录状态变化和投资
event StateChanged(State newState);
event Contributed(address indexed contributor, uint256 amount);
/**
* @notice 构造函数:初始化项目
* @param _goal 募资目标(wei)
* @param _durationMinutes 募资持续时间(分钟)
* @dev 项目初始状态为Preparing
*/
constructor(uint256 _goal, uint256 _durationMinutes) {
owner = msg.sender;
goal = _goal;
deadline = block.timestamp + (_durationMinutes * 1 minutes);
state = State.Preparing; // 初始状态:准备阶段
}
/**
* @notice inState修饰符:检查当前状态
* @param _state 要求的状态
* @dev 确保函数只在特定状态下可以执行
*/
modifier inState(State _state) {
require(state == _state, "Wrong state");
_;
}
/**
* @notice onlyOwner修饰符:只有所有者可以调用
*/
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
/**
* @notice 开始募资
* @dev 只有owner可以调用,且只能在Preparing状态下调用
*/
function startFunding() public onlyOwner inState(State.Preparing) {
// 状态转换:Preparing -> Funding
state = State.Funding;
emit StateChanged(State.Funding);
}
/**
* @notice 投资函数
* @dev 只能在Funding状态下调用,且必须在截止时间之前
*/
function contribute() public payable inState(State.Funding) {
// 检查是否在截止时间之前
require(block.timestamp < deadline, "Funding ended");
require(msg.value > 0, "Must send ETH");
// 更新状态
contributions[msg.sender] += msg.value;
raised += msg.value;
emit Contributed(msg.sender, msg.value);
}
/**
* @notice 完成募资
* @dev 只能在Funding状态下调用,且必须在截止时间之后
* @dev 根据募集金额是否达到目标,转换到Success或Failed状态
*/
function finalize() public inState(State.Funding) {
// 检查是否已经过了截止时间
require(block.timestamp >= deadline, "Funding not ended");
// 根据募集金额决定最终状态
if (raised >= goal) {
// 达到目标:转换到Success状态
state = State.Success;
emit StateChanged(State.Success);
} else {
// 未达到目标:转换到Failed状态
state = State.Failed;
emit StateChanged(State.Failed);
}
}
/**
* @notice 提取资金(只有成功时才能提取)
* @dev 只能在Success状态下调用,只有owner可以调用
*/
function withdrawFunds() public onlyOwner inState(State.Success) {
payable(owner).transfer(address(this).balance);
}
/**
* @notice 退款(失败时用户可以退款)
* @dev 只能在Failed状态下调用
*/
function refund() public inState(State.Failed) {
uint256 amount = contributions[msg.sender];
require(amount > 0, "No contribution");
// 清零投资记录(防止重复退款)
contributions[msg.sender] = 0;
// 退款给用户
payable(msg.sender).transfer(amount);
}
}
状态流转图:
bash
Preparing(准备阶段)
↓ startFunding()
Funding(募资阶段)
↓ finalize()
├─ raised >= goal → Success(成功)
└─ raised < goal → Failed(失败)
状态机模式的关键点:
- 使用enum定义状态:
清晰明确
易于扩展
- 使用修饰符检查状态:
inState修饰符确保函数只在正确状态下执行
减少错误操作
- 状态转换逻辑集中:
所有状态转换都在特定函数中
便于维护和审计
4.3 适用场景
状态机模式非常适合有明确生命周期的场景:
- 众筹项目:
准备、募资、成功/失败
- 拍卖系统:
创建、竞拍、结束、结算
- 游戏合约:
准备、进行中、结束
- 投票系统:
创建、投票中、计票、完成