用 Next.js 构建复杂规则计算器:类型建模、纯函数引擎与分层缓存
2026/7/9 4:37:43 网站建设 项目流程

最近我整理了一个多地区税率计算器项目。

第一版看起来很简单:输入金额,选择地区,套用规则,输出结果。

真正做成可以公开使用的产品后,问题很快冒了出来:

  • 页面展示金额不一定等于实际计税基数;
  • 官方数据、用户输入和比例估算的可信度不同;
  • 预扣金额不等于最终预计税负;
  • 不同身份会命中不同的计算门槛;
  • 规则可能同时受到交易地、居住地和地方政策影响;
  • 地方税并不总能安全地套一个固定比例;
  • 分期现金流不能简单地用总额除以期数;
  • 税率和门槛还会随年份变化。

这些问题与国内开发者常见的个税、房贷、保险、补贴和收益测算工具很像。公式本身通常不难,难的是规则来源、版本、优先级、异常输入和前端状态之间的关系。

如果把条件判断、接口请求和 UI 状态全部塞进 React 组件,页面也能跑,但后面几乎没法核对,更别说给规则升级。

这篇手记不讨论具体产品运营,只讲代码:如何用 TypeScript 收紧状态空间,用版本化配置管理规则,用纯函数组织计算管线,再用 Next.js 处理服务端数据、缓存和交互边界。

文中的类型名和函数名保留源码原样,业务术语则尽量抽象成可复用的工程问题。

一、技术栈

项目使用的技术都很常见:

技术用途
Next.js 14 App Router路由、Server Component、静态生成和 ISR
React 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; };

几个容易混淆的字符串也没有继续放任为普通string

export 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只有四种可能。组件渲染来源标签时可以穷举处理,测试也能明确验证优先级。要是这里用普通字符串,拼错一个值,编译器完全管不着。

这里明确区分了:

  • 实时计算还是自定义计算;
  • 现金领取还是年金领取;
  • 官方现金值还是用户输入值;
  • 申报身份;
  • 购票州和居住州;
  • 地方区域;
  • 税务年份。

结果类型也不只是一个result

export 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 ); }

三个阶段分别负责:

  1. buildCalculationContext():归一化输入、选择配置、查找地区规则、收集假设和警告;
  2. calculatePaymentTax():只处理金额公式;
  3. 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: Record<FilingStatus, 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] = useState<"live" | "custom">(...); const [filingStatus, setFilingStatus] = useState<FilingStatus>("single"); const [residencyState, setResidencyState] = useState("same"); const [locality, setLocality] = useState<Locality>("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 ] );

estimatedNetPayouttotalEstimatedTax、警告列表和有效税率都不需要再建一份 state。

能够推导的数据一旦保存第二份,就会产生同步问题。

十、把 URL 当成可序列化输入

计算结果需要分享,因此 URL 保存核心条件:

/calculator?jackpot=1b&state=NY

首次加载通过useSearchParams()恢复。

用户输入时没有调用router.replace(),而是使用 History API:

useEffect(() => { 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);

一次读取多期详情时,则使用 RedisMGET

mget: async <T>(keys: string[]) => { if (keys.length === 0) return []; const values = await redis.mGet(keys); return values.map((value) => parseRedisValue<T>(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 状态多份状态迟早不同步只保存源输入,通过纯函数推导结果
所有缓存共用一个生命周期数据要么过期,要么频繁刷新按请求、跨请求、页面和外部数据分层

结语

这个项目从一个“金额乘税率”的小工具,逐渐变成了一个带版本、来源、降级和可审计输出的小型领域模型。

真正困难的不是公式数量,而是下面这些问题:

  • 输入是否表达了完整上下文;
  • 每个金额是否有明确语义;
  • 数据和规则是否有版本;
  • 估算假设是否可见;
  • 无法建模的部分是否诚实提示;
  • 时间维度是否被保留;
  • 多个页面是否共享同一套计算口径;
  • 结果是否可以复现和测试。

这套思路并不限于税率计算器。

房贷、个税、保险、收益率和跨地区费用工具都可以采用类似结构:先建立版本化领域模型,再让前端负责收集输入和解释结果。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询