尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JavaScript函数式编程实战:模拟ATM机核心业务逻辑与状态管理

JavaScript函数式编程实战:模拟ATM机核心业务逻辑与状态管理 1. 项目概述用JavaScript函数模拟一个真实的ATM机最近在带几个刚入行的前端新人发现他们虽然能写一些基础的页面交互但一碰到稍微复杂点的业务逻辑比如状态流转、条件判断和用户交互流程就有点抓瞎。我琢磨着得找个既贴近生活、逻辑又足够清晰的例子来练手。想来想去ATM取款机这个场景再合适不过了——每个人几乎都用过它的业务流程插卡、输密码、选服务、取钱、退卡本身就是一套完整的状态机非常适合用JavaScript的函数式思维来拆解和实现。这个项目不是要做一个带图形界面的网页应用核心目标是用纯JavaScript函数模拟出ATM机背后的核心业务逻辑。我们会构建一系列函数分别代表“验证密码”、“查询余额”、“执行取款”、“更新账户”等操作并通过清晰的数据流将它们串联起来。在这个过程中你会深刻体会到如何用函数来封装行为、管理状态以及如何处理各种边界情况和异常比如密码错误、余额不足。这对于理解如何将现实世界的业务流程转化为严谨、可维护的代码是一次绝佳的实践。无论你是想巩固JavaScript基础的新手还是希望提升业务建模能力的中级开发者这个模拟项目都能让你有所收获。我们不会使用任何框架就靠最朴素的函数、对象和流程控制来构建一个逻辑自洽的“虚拟ATM系统”。2. 核心逻辑与状态设计模拟ATM机首先要抽象出它的核心实体和状态。我们不能一上来就写函数得先想清楚这个系统里有什么它们如何变化。2.1 核心数据模型设计ATM交互围绕两个核心实体账户和ATM会话。账户是持久化的数据而会话是一次交互的临时上下文。// 账户数据模型模拟数据库中的一条记录 const account { accountNumber: 6228480018888888888, // 银行卡号 pin: 123456, // 密码实际中应为加密哈希值 owner: 张三, balance: 5000.00, // 账户余额单位元 dailyWithdrawalLimit: 20000.00, // 每日取款限额 dailyWithdrawnAmount: 0.00, // 今日已取款金额 isLocked: false // 账户是否被锁定例如密码连续错误 }; // ATM会话状态模拟一次用户操作的生命周期 let atmSession { isAuthenticated: false, // 是否已通过密码认证 currentAccount: null, // 当前登录的账户对象引用 transactionHistory: [], // 本次会话的交易记录 errorMessage: // 当前错误信息 };这里有个关键点为什么把account设计成常量(const)而atmSession用let因为account对象模拟的是后端数据库里的数据在一次会话中我们不应该直接替换整个账户对象而是修改其内部的属性如balance。使用const可以防止意外重写但修改其属性是允许的。而atmSession在整个流程中会被反复赋值和重置所以用let。2.2 状态流转与函数映射ATM的操作流程是一个典型的状态机。我们可以用一系列函数来驱动状态的改变初始状态atmSession.isAuthenticated false。等待插卡输入卡号和密码。认证状态调用verifyPin(cardNumber, inputPin)函数。成功则更新atmSession.isAuthenticated true并加载账户信息到currentAccount失败则记录错误累计失败次数可能触发账户锁定。服务选择状态认证后用户可以选择操作如checkBalance()、withdraw(amount)。交易执行状态例如执行withdraw内部会调用validateWithdrawal(amount)进行校验然后调用updateAccountBalance(amount)更新数据。结束状态用户选择退出调用endSession()函数清空atmSession模拟退卡。每个函数都应该职责单一并且是纯函数或尽量接近纯函数。也就是说给定相同的输入永远得到相同的输出且不产生副作用如直接修改外部变量。对于必须产生的副作用如修改账户余额我们将其集中管理。注意在实际前端项目中修改账户余额这类操作会通过API调用后端服务完成。我们这里的函数模拟的是前端需要处理的客户端逻辑校验和状态管理以及向后端发送请求的决策过程。3. 核心函数实现与详解接下来我们逐一实现这些核心函数。我会先给出代码然后解释其中的关键点和易错点。3.1 密码验证函数这是大门的第一道锁安全性和用户体验的平衡点。/** * 验证银行卡密码 * param {string} cardNumber - 银行卡号 * param {string} inputPin - 用户输入的密码 * param {Array} accountDatabase - 模拟的账户数据库 * returns {Object} 返回验证结果对象 */ function verifyPin(cardNumber, inputPin, accountDatabase) { // 1. 查找账户 const account accountDatabase.find(acc acc.accountNumber cardNumber); if (!account) { return { success: false, message: 卡号不存在, account: null }; } // 2. 检查账户状态 if (account.isLocked) { return { success: false, message: 账户已被锁定请联系银行, account: null }; } // 3. 验证密码真实场景应对比加密后的哈希值 if (account.pin inputPin) { // 验证成功重置可能的错误计数这里简化处理 console.log(账户 ${cardNumber} 验证成功); return { success: true, message: 验证成功, account: account // 返回账户信息用于后续会话 }; } else { // 验证失败模拟错误计数逻辑 console.log(账户 ${cardNumber} 密码错误); // 注意此处本应更新账户的错误次数可能触发锁定。为简化仅返回错误。 return { success: false, message: 密码错误, account: null }; } }实操要点与避坑指南永远不要在前端存储或传输明文密码上述代码中直接比较account.pin inputPin是极其不安全的仅用于模拟。真实场景中前端应将用户输入的密码通过加密方式如哈希发送到后端后端与数据库中的密码哈希进行比对。错误信息要模糊注意看卡号不存在和密码错误我们返回的message是不同的。但在生产环境中为了安全通常会使用统一的模糊提示如“卡号或密码错误”以防止攻击者枚举有效的卡号。账户锁定策略函数中提到了锁定但未实现。一个完整的逻辑应该维护一个failedAttempts计数器当连续错误达到阈值如3次时将account.isLocked设为true。这个计数器通常由后端管理并持久化。3.2 余额查询函数这是最简单的服务之一但也要考虑状态。/** * 查询账户余额 * param {Object} atmSession - 当前的ATM会话状态 * returns {Object} 查询结果 */ function checkBalance(atmSession) { if (!atmSession.isAuthenticated || !atmSession.currentAccount) { return { success: false, message: 请先登录账户, balance: null }; } const balance atmSession.currentAccount.balance; // 记录查询交易 atmSession.transactionHistory.push({ type: QUERY_BALANCE, timestamp: new Date().toISOString(), amount: null, balanceAfter: balance }); return { success: true, message: 您的当前余额为¥${balance.toFixed(2)}, balance: balance }; }心得即使是一个简单的查询也要先进行权限校验isAuthenticated。同时记录交易历史是一个好习惯便于调试和审计。toFixed(2)用于格式化金额确保显示两位小数避免浮点数精度问题如0.1 0.2带来的显示困扰。3.3 取款流程的复合函数取款是核心业务逻辑最复杂。我们将其拆分为多个小函数遵循“单一职责原则”。第一步取款校验函数这个函数负责所有业务规则的检查不执行实际扣款。/** * 校验取款请求是否合法 * param {Object} account - 账户对象 * param {number} amount - 请求取款金额 * returns {Object} 校验结果 */ function validateWithdrawal(account, amount) { // 校验1基本输入校验 if (amount 0) { return { isValid: false, message: 取款金额必须大于零 }; } if (!Number.isInteger(amount) || amount % 100 ! 0) { return { isValid: false, message: 取款金额必须为100元的整数倍 }; } // 校验2余额校验 if (amount account.balance) { return { isValid: false, message: 账户余额不足 }; } // 校验3单日限额校验结合已取金额 const potentialTotal account.dailyWithdrawnAmount amount; if (potentialTotal account.dailyWithdrawalLimit) { return { isValid: false, message: 超过每日取款限额。今日剩余可取额度¥${(account.dailyWithdrawalLimit - account.dailyWithdrawnAmount).toFixed(2)} }; } // 校验4ATM机单次取款上限假设为5000元 const atmSingleLimit 5000; if (amount atmSingleLimit) { return { isValid: false, message: 单笔取款金额不得超过¥${atmSingleLimit.toFixed(2)} }; } // 所有校验通过 return { isValid: true, message: 校验通过 }; }第二步更新账户函数校验通过后执行实际的账户数据更新。/** * 执行账户更新扣款、更新已取金额 * param {Object} account - 账户对象 * param {number} amount - 取款金额 * returns {Object} 更新后的账户对象副本 */ function updateAccountForWithdrawal(account, amount) { // 创建账户对象的副本进行操作避免直接修改原对象函数式思想 const updatedAccount { ...account }; updatedAccount.balance - amount; updatedAccount.dailyWithdrawnAmount amount; console.log(账户 ${updatedAccount.accountNumber} 扣款 ¥${amount} 成功。新余额¥${updatedAccount.balance.toFixed(2)}); return updatedAccount; }这里使用了扩展运算符...来创建对象的浅拷贝。这样做的好处是updateAccountForWithdrawal是一个纯函数它不修改输入参数而是返回一个新的对象。这使得状态变化更可预测、更容易追踪和测试。第三步主取款函数整合校验和更新并管理会话状态。/** * 执行取款操作 * param {Object} atmSession - 当前ATM会话状态 * param {number} amount - 取款金额 * returns {Object} 取款结果 */ function withdraw(atmSession, amount) { // 1. 检查登录状态 if (!atmSession.isAuthenticated || !atmSession.currentAccount) { return { success: false, message: 请先登录账户 }; } const account atmSession.currentAccount; // 2. 业务校验 const validation validateWithdrawal(account, amount); if (!validation.isValid) { return { success: false, message: validation.message }; } // 3. 执行账户更新 const updatedAccount updateAccountForWithdrawal(account, amount); // 4. 更新会话中的账户信息 atmSession.currentAccount updatedAccount; // 5. 记录交易 atmSession.transactionHistory.push({ type: WITHDRAWAL, timestamp: new Date().toISOString(), amount: amount, balanceAfter: updatedAccount.balance }); // 6. 返回成功结果 return { success: true, message: 取款成功 ¥${amount.toFixed(2)}请取走现金。当前余额¥${updatedAccount.balance.toFixed(2)}, cashDispensed: amount, newBalance: updatedAccount.balance }; }为什么要把validateWithdrawal和updateAccountForWithdrawal分开这是一种重要的设计模式。校验逻辑可能非常复杂并且可能被多个地方调用比如在用户输入金额时做实时提示。将其独立出来使得代码更清晰、可复用。更新逻辑独立则便于进行单元测试也符合“一个函数只做一件事”的原则。4. 整合与流程模拟有了这些原子函数我们就可以像搭积木一样组装出完整的ATM操作流程。下面我们模拟一次完整的用户会话。// 模拟一个简单的“账户数据库” const accountDatabase [ { accountNumber: 6228480018888888888, pin: 123456, owner: 张三, balance: 5000.00, dailyWithdrawalLimit: 20000.00, dailyWithdrawnAmount: 1500.00, // 假设今天已经取过1500元 isLocked: false }, { accountNumber: 6228480029999999999, pin: 654321, owner: 李四, balance: 300.00, dailyWithdrawalLimit: 20000.00, dailyWithdrawnAmount: 0.00, isLocked: false } ]; // 初始化ATM会话 let atmSession { isAuthenticated: false, currentAccount: null, transactionHistory: [], errorMessage: }; console.log( 模拟ATM操作流程 \n); // 场景1用户插入卡号并输入密码 const cardNumber 6228480018888888888; const inputPin 123456; console.log(1. 用户插卡卡号: ${cardNumber}, 输入密码: ${inputPin}); const authResult verifyPin(cardNumber, inputPin, accountDatabase); if (authResult.success) { atmSession.isAuthenticated true; atmSession.currentAccount authResult.account; console.log( - ${authResult.message}\n); } else { console.log( - 认证失败: ${authResult.message}); // 流程终止 process.exit(1); // 模拟结束 } // 场景2用户查询余额 console.log(2. 用户选择【查询余额】); const balanceResult checkBalance(atmSession); console.log( - ${balanceResult.message}\n); // 场景3用户尝试取款2000元 console.log(3. 用户选择【取款】输入金额2000); const withdrawalResult1 withdraw(atmSession, 2000); console.log( - ${withdrawalResult1.message}\n); // 场景4用户再次尝试取款4000元将超过单日限额 console.log(4. 用户再次选择【取款】输入金额4000); // 注意此时账户的 dailyWithdrawnAmount 已被更新为 1500 2000 3500 const withdrawalResult2 withdraw(atmSession, 4000); console.log( - ${withdrawalResult2.message}\n); // 场景5用户取款500元非100整数倍 console.log(5. 用户尝试取款500元); const withdrawalResult3 withdraw(atmSession, 500); console.log( - ${withdrawalResult3.message}\n); // 场景6用户取款1000元成功 console.log(6. 用户取款1000元); const withdrawalResult4 withdraw(atmSession, 1000); console.log( - ${withdrawalResult4.message}\n); // 打印最终会话历史 console.log( 本次会话交易记录 ); atmSession.transactionHistory.forEach(tx { console.log([${tx.timestamp}] ${tx.type} ${tx.amount ? 金额: ¥ tx.amount.toFixed(2) : } 余额: ¥${tx.balanceAfter.toFixed(2)}); }); // 结束会话 atmSession { isAuthenticated: false, currentAccount: null, transactionHistory: [], errorMessage: }; console.log(\n会话已结束请取回您的卡片。);运行上述模拟代码你会在控制台看到一个清晰的流程输出涵盖了成功、失败余额不足、限额、规则不符等多种情况。这就像是在调试一个真实的状态机。5. 常见问题、边界情况与优化思路在实际编码和思考中你会发现很多细节问题。下面是我总结的一些关键点和优化方向。5.1 浮点数精度与金额计算JavaScript中0.1 0.2 ! 0.3是经典问题。对于金融计算使用浮点数(Number)是危险的。问题balance使用Number类型在进行多次加减后可能出现精度误差。解决方案以分为单位存储在数据库和核心计算中金额都以整数分为单位。例如5000.00元存储为500000分。显示时再除以100。let balanceInCents 500000; // 代表5000.00元 function withdraw(amountYuan) { const amountCents Math.round(amountYuan * 100); // 将元转为分 if (amountCents balanceInCents) { /* ... */ } balanceInCents - amountCents; }使用第三方库如decimal.js、big.js它们提供了精确的十进制运算。5.2 并发操作与状态安全我们的模拟是单线程、线性的。但在真实Web应用中可能存在并发请求虽然ATM一次只服务一人但Web端可能快速连续点击。问题用户快速连续点击“取款”按钮可能发送两个withdraw请求。如果第一个请求的数据库更新还未完成第二个请求校验余额时读到的仍是旧数据可能导致超额取款。解决方案前端防抖在发送取款请求后立即禁用按钮直到收到响应。后端幂等性与锁后端处理扣款时必须使用数据库事务Transaction和行锁如SELECT ... FOR UPDATE确保“查询余额 - 校验 - 扣款”这个序列操作是原子的。或者采用更乐观的版本号控制。5.3 函数设计扩展性现在的函数是紧耦合的。随着功能增加如存款、转账、修改密码代码会变得臃肿。优化思路命令模式或策略模式我们可以定义一个统一的“交易命令”接口。// 定义一个交易命令接口 class TransactionCommand { constructor(account, amount) { this.account account; this.amount amount; } validate() { throw new Error(validate method must be implemented); } execute() { throw new Error(execute method must be implemented); } } // 具体的取款命令 class WithdrawalCommand extends TransactionCommand { validate() { // 复用之前的 validateWithdrawal 逻辑 const validation validateWithdrawal(this.account, this.amount); return validation; } execute() { // 复用之前的 updateAccountForWithdrawal 逻辑 return updateAccountForWithdrawal(this.account, this.amount); } } // 使用方式 const command new WithdrawalCommand(currentAccount, 1000); const validation command.validate(); if (validation.isValid) { const newAccount command.execute(); // 更新会话... }这样新增一种交易类型如DepositCommand只需要新建一个类而不用修改主流程代码符合开闭原则。5.4 错误处理与用户体验目前的错误处理比较简单直接返回了{success: false, message: ...}。更健壮的做法定义错误类型创建特定的错误类如AuthenticationError、InsufficientBalanceError、DailyLimitExceededError。这样在调用方可以更精确地捕获和处理不同类型的错误。class BusinessError extends Error { constructor(message, code) { super(message); this.name BusinessError; this.code code; // 如 INSUFFICIENT_BALANCE } } // 在 validateWithdrawal 中抛出 if (amount account.balance) { throw new BusinessError(账户余额不足, INSUFFICIENT_BALANCE); }全局错误处理在模拟流程的最外层使用try...catch对不同类型的错误进行统一处理比如记录日志、展示友好的用户提示等。5.5 会话状态持久化我们的atmSession存在于内存中页面刷新就没了。在真实的单页应用(SPA)中需要考虑状态持久化。解决方案Vuex / Pinia (Vue) 或 Redux / Zustand (React)使用状态管理库来集中管理atmSession。它们提供了可预测的状态变更和调试工具。浏览器存储对于需要跨页面或短时间保留的状态比如登录状态可以结合sessionStorage标签页关闭后清除或localStorage长期存储来保存加密后的令牌或部分非敏感会话信息。切记绝对不要在其中存储密码或完整账户信息。通过这个从零到一的JavaScript函数模拟ATM机项目我们不仅练习了函数封装、条件判断、状态管理等基础编程技能更深入触及了业务逻辑建模、错误处理、数据一致性等在实际开发中至关重要的话题。下次当你再使用ATM时不妨想想背后这一套严谨的逻辑是如何被代码构建出来的这会让你的编程思维更加贴近真实的业务世界。
返回列表