继续浏览精彩内容
慕课网APP
程序员的梦工厂
打开
继续
感谢您的支持,我会继续努力的
赞赏金额会直接到老师账户
将二维码发送给自己后长按识别
微信支付
支付宝支付

用 Next.js 构建复杂规则计算器:类型建模、纯函数引擎与分层缓存

站着等风来1
关注TA
已关注
手记 2
粉丝 0
获赞 1

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

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

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

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

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

如果把条件判断、接口请求和 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 缓存,因此使用 React cache()

async function loadPowerballData() {
  const cached = await getCachedPowerballData();

  if (cached) {
    return cached;
  }

  return getSamplePowerballData();
}

export const getPowerballData =
  cache(loadPowerballData);

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

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 状态 多份状态迟早不同步 只保存源输入,通过纯函数推导结果
所有缓存共用一个生命周期 数据要么过期,要么频繁刷新 按请求、跨请求、页面和外部数据分层

结语

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

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

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

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

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

相关链接

本文中的税率和门槛用于讲解项目实现,不构成税务或法律建议。实际税负会受到申报身份、居住地、购票地、其他收入、抵扣项目和地方规则影响。

打开App,阅读手记
1人推荐
发表评论
随时随地看视频慕课网APP