用 Next.js 构建复杂规则计算器:类型建模、纯函数引擎与分层缓存
最近我整理了一个多地区税率计算器项目。第一版看起来很简单输入金额选择地区套用规则输出结果。真正做成可以公开使用的产品后问题很快冒了出来页面展示金额不一定等于实际计税基数官方数据、用户输入和比例估算的可信度不同预扣金额不等于最终预计税负不同身份会命中不同的计算门槛规则可能同时受到交易地、居住地和地方政策影响地方税并不总能安全地套一个固定比例分期现金流不能简单地用总额除以期数税率和门槛还会随年份变化。这些问题与国内开发者常见的个税、房贷、保险、补贴和收益测算工具很像。公式本身通常不难难的是规则来源、版本、优先级、异常输入和前端状态之间的关系。如果把条件判断、接口请求和 UI 状态全部塞进 React 组件页面也能跑但后面几乎没法核对更别说给规则升级。这篇手记不讨论具体产品运营只讲代码如何用 TypeScript 收紧状态空间用版本化配置管理规则用纯函数组织计算管线再用 Next.js 处理服务端数据、缓存和交互边界。文中的类型名和函数名保留源码原样业务术语则尽量抽象成可复用的工程问题。一、技术栈项目使用的技术都很常见技术用途Next.js 14 App Router路由、Server Component、静态生成和 ISRReact 18交互状态与派生结果TypeScript税务输入、结果和配置契约next-intl中英文路由与文案Redis实时数据、历史数据和汇率缓存Vitest领域函数和数学不变量测试Tailwind CSS数据表格与计算器界面这套组合没有什么稀奇的关键是每一层只做自己的事外部数据 → 归一化与 Redis → Server Component → 纯计算引擎 → Client Component → URL 保存可分享输入计算引擎不依赖 React国际化不进入公式Redis 也不直接暴露给浏览器。源码中的职责大致是这样app/[locale]/calculator/page.tsx └─ 服务端聚合实时数据、汇率和页面初始值 components/TaxCalculator.tsx └─ 收集输入、同步 URL、渲染结果 lib/tax.ts └─ 纯计算、输入归一化、假设和警告 config/tax/*.ts └─ 按税年保存规则、来源和版本 lib/tax.test.ts └─ 验证业务规则、降级路径和数学不变量页面组件不应该知道某个税档怎么算配置文件也不应该操作 React state。把这条边界守住后面的测试和升级才有意义。二、先用 TypeScript 收紧状态空间早期版本的输入可以简化成{ prizeAmount, stateTaxRate, payoutMode }它适合演示公式但不足以表达真实业务。当前版本把计算上下文定义成一份完整协议export type PowerballTaxInput { advertisedJackpot: number; payoutMode: PayoutMode; calculationMode: CalculationMode; officialCashValue?: number; customCashValue?: number; estimatedCashRatio?: number; filingStatus: FilingStatus; ticketPurchaseState: USStateCode; residencyState?: USStateCode; locality?: Locality; taxYear: number; drawDate?: string; };几个容易混淆的字符串也没有继续放任为普通stringexport type FilingStatus | single | married_jointly | head_of_household; export type PayoutMode cash | annuity; export type CalculationMode live | custom; export type CashValueSource | official | custom | estimated_ratio | annuity;这不是为了展示 TypeScript 技巧而是为了压缩非法状态。例如cashValueSource只有四种可能。组件渲染来源标签时可以穷举处理测试也能明确验证优先级。要是这里用普通字符串拼错一个值编译器完全管不着。这里明确区分了实时计算还是自定义计算现金领取还是年金领取官方现金值还是用户输入值申报身份购票州和居住州地方区域税务年份。结果类型也不只是一个resultexport type PowerballTaxResult { advertisedJackpot: number; grossPayout: number; cashValueSource: CashValueSource; cashValueRatio?: number; federalWithholding: number; estimatedAdditionalFederalTax: number; estimatedFederalTaxTotal: number; ticketStateTax: number; residencyTaxAdjustment?: number; localTax?: number; totalEstimatedTax: number; estimatedNetPayout: number; effectiveTaxRate: number; assumptions: string[]; warnings: string[]; taxDataVersion: string; calculatedAt: string; };除了金额它还返回cashValueSource本次现金金额来自哪里assumptions使用了哪些估算假设warnings哪些规则没有建模或发生了回退taxDataVersion使用哪一版税务数据calculatedAt结果生成时间。一个公开计算器不可能知道用户的全部报税信息。与其假装精确不如把模型边界也放进结果。三、计算管线比巨型函数更容易维护calculatePowerballTax()本身没有堆很多公式而是串起三个阶段export function calculatePowerballTax( input: PowerballTaxInput ): PowerballTaxResult { if (input.payoutMode annuity) { return calculatePowerballAnnuitySchedule(input).totals; } const context buildCalculationContext(input); const taxes calculatePaymentTax( context.grossPayout, context.stateRate, input.filingStatus, context.config ); return buildResult( context, taxes, new Date().toISOString(), input ); }三个阶段分别负责buildCalculationContext()归一化输入、选择配置、查找地区规则、收集假设和警告calculatePaymentTax()只处理金额公式buildResult()组装稳定的输出协议并保留兼容字段。这里的重点不是函数拆得越小越好而是把副作用和规则决策赶出核心公式。calculatePaymentTax()不读取 URL不访问 Redis也不依赖当前语言。输入相同结果就相同。这样的函数可以在页面、接口、批处理和测试中复用。中间的CalculationContext则像一层领域适配器type CalculationContext { advertisedJackpot: number; cashValueRatio?: number; cashValueSource: CashValueSource; config: TaxYearConfiguration; grossPayout: number; stateEntry?: StateTaxEntry; stateRate: number; assumptions: string[]; warnings: string[]; };它把“原始输入”转换成“已经完成规则选择的计算上下文”。后面的公式不需要反复判断官方数据是否存在、税年是否回退、地区条目是否缺失。四、结算金额不是单值而是一条解析链业务页面展示的可能是分期名义总额不一定等于当前可结算的现金金额。项目按下面的优先级解析结算金额用户自定义现金值 实时官方现金值 用户设置的估算比例 税年配置中的默认比例核心实现位于resolveCashValue()const customCashValue normalizeMoney(input.customCashValue); const officialCashValue normalizeMoney(input.officialCashValue); if (customCashValue ! undefined) { assumptions.push( Gross cash payout uses the user-supplied cash value. ); return { cashValueRatio: advertisedJackpot 0 ? customCashValue / advertisedJackpot : undefined, cashValueSource: custom, grossPayout: customCashValue }; } if (officialCashValue ! undefined) { assumptions.push( Gross cash payout uses the provided official cash value. ); return { cashValueRatio: advertisedJackpot 0 ? officialCashValue / advertisedJackpot : undefined, cashValueSource: official, grossPayout: officialCashValue }; }前两种都不存在时再使用比例估算const requestedRatio input.estimatedCashRatio; const ratio requestedRatio undefined ? config.defaultEstimatedCashRatio : Number.isFinite(requestedRatio) requestedRatio 0 requestedRatio 1 ? requestedRatio : config.defaultEstimatedCashRatio; assumptions.push( No official or custom cash value was available, so gross cash payout is approximated at ${(ratio * 100).toFixed(1)}% of the advertised jackpot. );这里没有把默认 60% 藏起来。结果会明确告诉用户这次使用的是官方现金值还是估算比例。异常输入也不会被静默接受if ( customCashValue advertisedJackpot advertisedJackpot 0 ) { warnings.push( The custom cash value exceeds the advertised jackpot. Verify the entered amounts. ); }这类警告不一定要阻断计算但必须可见。五、把税务规则做成版本化配置税率和门槛会随年份变化不适合散落在组件里。项目定义了统一的税年配置export type TaxYearConfiguration { annuity: { growthRate: number; paymentCount: number; }; defaultEstimatedCashRatio: number; federal: { additionalTopRateDelta: number; topBracketRate: number; topBracketThresholds: RecordFilingStatus, number; withholdingRate: number; }; sourceUrls: string[]; states: readonly StateTaxEntry[]; taxYear: number; version: string; };2026 年配置如下export const TAX_CONFIG_2026 { annuity: { growthRate: 0.05, paymentCount: 30 }, defaultEstimatedCashRatio: 0.6, federal: { additionalTopRateDelta: 0.13, topBracketRate: 0.37, topBracketThresholds: { head_of_household: 640_600, married_jointly: 768_700, single: 640_600 }, withholdingRate: 0.24 }, taxYear: 2026, version: 2026.1 };申报身份不再只是 UI 文案它会真正影响门槛const topBracketExposure Math.max( grossPayout - config.federal .topBracketThresholds[filingStatus], 0 ); const estimatedAdditionalFederalTax topBracketExposure * config.federal.additionalTopRateDelta;如果请求了没有配置的税年系统回退到最新版本但会留下警告const {config, usedFallback} getTaxConfiguration(input.taxYear); if (usedFallback) { warnings.push( Tax data for ${input.taxYear} is unavailable. The ${config.taxYear} configuration (${config.version}) was used as a fallback. ); }回退不可怕静默回退才可怕。六、地区税率不只是 JSON 里的一个数字把 50 个州的税率放进 JSON 只是基础操作。当前模型为每条地区数据增加了元信息export type StateTaxEntry { stateCode: USStateCode; stateName: string; ticketStateRate: number; residencyRuleNote: string; localTaxNote: string; localTaxSupport: none | warning_only; sourceUrls: string[]; verifiedAt: string; taxYear: number; version: string; zhName: string; };一条税率除了数值还需要回答属于哪个税年最后核验日期是什么数据来源在哪里是否存在未建模的地方税购票州和居住州不同时有哪些限制。对于跨州居住规则当前模型不会假装已经算完if ( input.residencyState input.residencyState ! input.ticketPurchaseState ) { const residencyEntry findStateEntry( config, input.residencyState ); warnings.push( residencyEntry ? ${residencyEntry.stateName} residency credits, additional resident tax, and nonresident filing rules are not modeled. : Residency-state tax data for ${input.residencyState} is unavailable, so no residency adjustment was calculated. ); }对于纽约市和 Yonkers项目也没有随便套一个固定比例if (input.locality input.locality ! none) { warnings.push( ${localityName} tax is not calculated because the repository does not contain a verified flat rule that is safe to apply to every winner. ); }算不准的部分明确标记为“不计算”比输出一个看起来很精确的错误数字更可靠。七、预扣税与预计最终税负分开计算函数将 24% 预扣与顶级税档补税估算分开const federalWithholding grossPayout * config.federal.withholdingRate; const topBracketExposure Math.max( grossPayout - config.federal .topBracketThresholds[filingStatus], 0 ); const estimatedAdditionalFederalTax topBracketExposure * config.federal.additionalTopRateDelta; const estimatedFederalTaxTotal federalWithholding estimatedAdditionalFederalTax; const ticketStateTax grossPayout * stateRate; const totalEstimatedTax estimatedFederalTaxTotal ticketStateTax;它不是完整报税程序。模型没有覆盖扣除、抵免、信托、遗产规划和完整的跨州申报。但它至少不会把“先预扣多少”和“最终预计承担多少”混成一个数字。八、分期现金流要保留时间维度当前项目中的分期方案由 30 笔逐年增长的付款组成第一笔在领取时支付后续每年一笔每笔比上一年增加 5%。因此下面的写法是错误的const annualPayment jackpot / 30;真实付款序列是几何级数0.05 × 1.05^(n - 1) -------------------------------- 1.05^30 - 1当前实现从税年配置中读取付款数量和增长率const {growthRate, paymentCount} context.config.annuity; const denominator Math.pow(1 growthRate, paymentCount) - 1; for ( let year 1; year paymentCount; year 1 ) { const share ( growthRate * Math.pow(1 growthRate, year - 1) ) / denominator; const grossPayout context.advertisedJackpot * share; const taxes calculatePaymentTax( grossPayout, context.stateRate, annuityInput.filingStatus, context.config ); }每一笔付款单独进入calculatePaymentTax()年度门槛也会逐年应用。如果先把 30 年总额加起来再当成一笔收入计税时间维度就丢失了。结果中还会明确加入限制context.warnings.push( The annuity estimate applies the ${context.config.taxYear} tax configuration to every payment and does not discount future payments to present value. );也就是说页面展示的是 30 年名义累计值不是折现后的净现值。九、React 状态分“源输入”和“派生结果”当前计算器既有简单模式也有高级模式。高级输入包括const [calculationMode, setCalculationMode] useStatelive | custom(...); const [filingStatus, setFilingStatus] useStateFilingStatus(single); const [residencyState, setResidencyState] useState(same); const [locality, setLocality] useStateLocality(none); const [customCashValue, setCustomCashValue] useState(); const [ estimatedCashRatioPercent, setEstimatedCashRatioPercent ] useState(60);这些都是用户源输入。税务结果不再单独保存而是通过useMemo推导const cashBreakdown useMemo( () calculatePowerballTax({ advertisedJackpot: parsedAmount, calculationMode, customCashValue: parsedCustomCashValue 0 ? parsedCustomCashValue : undefined, estimatedCashRatio, filingStatus, locality, officialCashValue: calculationMode live ? currentCashValueUsd ?? undefined : undefined, payoutMode: cash, residencyState: resolvedResidencyState, taxYear: 2026, ticketPurchaseState: selectedState.code as USStateCode }), [ calculationMode, currentCashValueUsd, estimatedCashRatio, filingStatus, locality, parsedAmount, parsedCustomCashValue, resolvedResidencyState, selectedState.code ] );estimatedNetPayout、totalEstimatedTax、警告列表和有效税率都不需要再建一份 state。能够推导的数据一旦保存第二份就会产生同步问题。十、把 URL 当成可序列化输入计算结果需要分享因此 URL 保存核心条件/calculator?jackpot1bstateNY首次加载通过useSearchParams()恢复。用户输入时没有调用router.replace()而是使用 History APIuseEffect(() { if (typeof window undefined) { return; } const params new URLSearchParams(); if (amount parsedAmount 0) { params.set(jackpot, amount); } if (stateCode stateCode ! propStateCode) { params.set(state, stateCode); } const query params.toString(); const next query ? ${pathname}?${query} : pathname; if ( ${window.location.pathname} ${window.location.search} ! next ) { window.history.replaceState( null, , next ); } }, [ amount, parsedAmount, stateCode, pathname, propStateCode ]);这样地址栏始终携带当前条件又不会在每次键入时触发新的 App Router 导航。十一、Server Component 与 Client Component 分工页面继续使用 Server Component 获取实时数据和汇率const [exchangeRate, powerballData] await Promise.all([ locale zh ? getUsdCnyRate() : Promise.resolve(null), getPowerballData() ]);交互计算器单独作为 Client Component并由Suspense包裹Suspense fallback{null} TaxCalculator currentCashValueUsd{currentCashValueUsd} currentJackpotUsd{currentJackpotUsd} exchangeRate{exchangeRate} locale{locale} states{states} / /Suspense这个边界解决了两个问题首屏数据在服务端准备浏览器不用再发一轮请求输入框、折叠面板和复制链接等交互留在客户端不会把整页都变成 Client Component。useSearchParams()会让相关子树进入客户端渲染因此这里显式使用Suspense。这不是为了加一个加载动画而是为了让 App Router 的静态预渲染边界保持清楚。十二、缓存要按数据生命周期分层项目中的缓存职责不同React cache → 单次服务端渲染内去重 Redis → 跨请求保存实时和历史数据 ISR → 控制页面输出更新时间 外部数据 TTL → 控制汇率等数据刷新频率Redis 读取不属于 Next.js 的fetch缓存因此使用 Reactcache()async function loadPowerballData() { const cached await getCachedPowerballData(); if (cached) { return cached; } return getSamplePowerballData(); } export const getPowerballData cache(loadPowerballData);一次读取多期详情时则使用 RedisMGETmget: async T(keys: string[]) { if (keys.length 0) return []; const values await redis.mGet(keys); return values.map((value) parseRedisValueT(value) ); }几十条数据的计算不贵几十次网络往返才贵。十三、测试规则、来源和降级当前测试已经不只验证几个金额。1. 输入来源优先级const result calculate({ advertisedJackpot: 500_000_000, calculationMode: live, customCashValue: 275_000_000, officialCashValue: 260_000_000 }); expect(result.grossPayout).toBe(275_000_000); expect(result.cashValueSource).toBe(custom);2. 不编造缺失的地方规则it.each([nyc, yonkers] as const)( returns a visible warning, (locality) { const result calculate({ locality, residencyState: NY, ticketPurchaseState: NY }); expect(result.localTax).toBeUndefined(); expect(result.warnings.join( )) .toContain(not calculated); } );3. 不支持的税年必须可见回退const result calculate({ estimatedCashRatio: 4, taxYear: 2030 }); expect(result.taxDataVersion).toBe(2026.1); expect(result.warnings.join( )) .toContain(2030); expect(result.warnings.join( )) .toContain(cash ratio);4. 每条地区数据必须有来源for (const entry of STATE_TAX_ENTRIES_2026) { expect(entry.sourceUrls.length) .toBeGreaterThan(0); expect(entry.verifiedAt) .toMatch(/^\d{4}-\d{2}-\d{2}$/); expect(entry.taxYear).toBe(2026); expect(entry.version).toBe(2026.1); }5. 分期计算的数学不变量expect(rows).toHaveLength( ANNUITY_PAYMENT_COUNT ); const totalGross rows.reduce( (sum, row) sum row.grossPayout, 0 ); expect(totalGross).toBeCloseTo(jackpot, 0); for ( let index 1; index rows.length; index 1 ) { const ratio rows[index].grossPayout / rows[index - 1].grossPayout; expect(ratio).toBeCloseTo( 1 ANNUITY_GROWTH_RATE, 6 ); }测试的不只是“算出了多少钱”还包括数据来源、模型边界和降级行为。十四、这类计算器最容易踩的坑问题后果处理方式把展示总额当成结算金额计税基数从源头就错了保存金额来源并建立明确的解析优先级把预扣当成最终税率用户会误解实际负担分开返回预扣、追加估算和总税负对不确定规则强行输出数字得到看似精确的错误结果返回结构化警告明确未建模范围配置只有数值没有版本和来源后续无法核验和升级保存税年、版本、核验日期和来源链接把多年现金流压成一笔收入丢失年度门槛和增长关系按期计算并验证总额与增长率不变量保存可以推导的 React 状态多份状态迟早不同步只保存源输入通过纯函数推导结果所有缓存共用一个生命周期数据要么过期要么频繁刷新按请求、跨请求、页面和外部数据分层结语这个项目从一个“金额乘税率”的小工具逐渐变成了一个带版本、来源、降级和可审计输出的小型领域模型。真正困难的不是公式数量而是下面这些问题输入是否表达了完整上下文每个金额是否有明确语义数据和规则是否有版本估算假设是否可见无法建模的部分是否诚实提示时间维度是否被保留多个页面是否共享同一套计算口径结果是否可以复现和测试。这套思路并不限于税率计算器。房贷、个税、保险、收益率和跨地区费用工具都可以采用类似结构先建立版本化领域模型再让前端负责收集输入和解释结果。