复杂税率计算器的工程化实践:Next.js、现金流建模与多层缓存
我最近一直在维护一个覆盖多个地区的奖金税率计算器。这类项目看起来很像典型的小工具:输入金额,选择地区,页面吐出税后结果。
如果只看界面,确实没多少东西。
但我把第一版跑通以后,很快发现真正难处理的不是 50 个州。州税率放进 JSON,谁都会。拿“配置替代 if-else”当架构亮点,多少有点糊弄同行。
麻烦在别处。
同一个“10 亿美元大奖”,至少可能指四个不同的数字:
- 广告上写的年金总额;
- 一次性领取时的现金价值;
- 进入税务计算的毛收入;
- 扣完联邦税和州税后的到手金额。
再往下还有 24% 联邦预扣、顶级税档补税、州税、地方税、30 年付款增长、每年单独应用税档门槛。页面如果不先把这些口径讲清楚,代码写得再漂亮,结果也是一本正经地算错。
这篇复盘不聊“如何用 React 写一个 select”。我想聊的是一个计算型产品更容易被忽略的部分:业务口径怎么建模,现金流怎么展开,状态放在哪里,结果怎么被验证。
技术栈不是清单,要看它解决了什么
项目使用的主要技术并不冷门:
| 技术 | 在项目里的职责 | 选择它的实际收益 |
|---|---|---|
| Next.js 14 App Router | 页面路由、Server Component、静态生成与 ISR | 同一站点可以混合静态内容、服务端数据和客户端交互,不必全站选一种渲染模式 |
| React 18 | 输入交互、派生结果与服务端请求去重 | 组件只保存源状态;React cache() 还能处理一次 RSC 渲染内的重复读取 |
| TypeScript | 计算输入、输出、地区配置与数据源契约 | 让“广告金额”“应税毛额”“预扣税”等不同口径在类型层可见 |
| next-intl | 多语言路由和文案 | 国际化留在页面层,不侵入计算函数 |
| Redis | 实时数据、历史数据和详情缓存 | 跨请求共享结果,降低外部 API 压力 |
| Vitest | 领域函数测试 | 不启动浏览器就能快速验证数学不变量 |
| Tailwind CSS | 响应式表格、输入面板和结果对比 | 数据密集型页面迭代快,样式作用域明确 |
| Cron + ISR | 外部数据刷新与页面再生成 | 抓取和页面更新不必发生在每个用户请求里 |
技术栈本身不是亮点。Next.js + TypeScript + Redis 放在简历上也说明不了什么。真正有价值的是边界怎么切:
外部数据源
→ 归一化与缓存
→ Server Component 读取
→ 纯函数计算
→ Client Component 交互
→ URL 持久化输入
计算逻辑不依赖 React,国际化不进入领域模型,Redis 不直接暴露给客户端。每一层只处理自己负责的问题。
先定义每个数字的含义,再碰公式
财务类计算最怕一个变量从头到尾都叫 amount。
刚开始看着省事,过两周就会出现这种问题:这里的 amount 是广告奖池,还是现金领取金额?州税乘的是哪一个?图表展示的是税前还是税后?分享文案又拿了哪个值?
项目里的核心输出类型放在 lib/tax.ts,这些数字被明确拆开:
export type TaxBreakdown = {
advertisedPrize: number;
grossPayout: number;
federalWithholding: number;
additionalFederalTax: number;
federalTax: number;
stateTax: number;
totalTax: number;
netPayout: number;
effectiveTaxRate: number;
};
这几个字段不是为了 TypeScript 看起来完整。
advertisedPrize 是广告奖池。grossPayout 是实际进入当前计算路径的毛额。现金领取时,两者通常不是一个数。federalWithholding 是领奖时预扣的 24%,additionalFederalTax 是项目对顶级税率差额的估算。最后才是 netPayout。
有了这层语义,UI 才能明确展示:
广告奖池
→ 现金价值或年度年金付款
→ 联邦预扣
→ 额外联邦税估算
→ 州税
→ 税后到手
这里我宁愿多几个字段,也不愿把所有税揉成一个 tax。前端项目里最便宜的是多写几个类型,最贵的是不同页面对同一个数字理解不一样。
现金价值不是常量,而是一个带置信度的输入
项目里有一个默认假设:
export const CASH_VALUE_RATIO = 0.6;
这不是说某类大奖的现金价值永远等于广告金额的 60%。实际比例会随着利率和官方报价变化。60% 只是用户输入任意历史或假设金额时的默认估算。
因此计算函数没有把这个比例封死,而是把它设计成可注入参数:
export type TaxInput = {
prizeAmount: number;
stateTaxRate: number;
payoutMode: PayoutMode;
cashValueRatio?: number;
};
export function calculateTax(input: TaxInput): TaxBreakdown {
const advertisedPrize = Math.max(0, input.prizeAmount || 0);
const cashValueRatio =
input.cashValueRatio ?? CASH_VALUE_RATIO;
const grossPayout =
input.payoutMode === "cash"
? advertisedPrize * cashValueRatio
: advertisedPrize;
// 后续税务计算
}
这一个可选参数,解决的是“通用估算”和“实时数据”两种场景。
通用计算器只有用户输入的广告奖池,就回退到 60%。首页实时快照 components/LiveJackpotSnapshot.tsx 如果拿到了上游现金价值,则先算出当天的实时比例:
function getCashValueRatio(
currentJackpotUsd: number,
currentCashValueUsd?: number | null,
): number | undefined {
if (!currentCashValueUsd || currentCashValueUsd <= 0) {
return undefined;
}
const ratio = currentCashValueUsd / currentJackpotUsd;
if (!Number.isFinite(ratio) || ratio <= 0) {
return undefined;
}
return Math.min(ratio, 1);
}
然后把比例注入同一个计算内核:
breakdown: calculateTax({
cashValueRatio,
payoutMode: "cash",
prizeAmount: currentJackpotUsd,
stateTaxRate: state.taxRate,
})
我觉得这是项目里一个比较实用的设计点。
算法没有因为数据来源不同复制两份。实时数据可用时提高精度,不可用时自动退回稳定假设。调用方明确知道自己传了什么,结果也能追溯到对应口径。
比起在组件里到处写 jackpot * 0.6,这种做法更接近一个能长期维护的计算产品。
24% 是预扣,不是最终税率
彩票税计算里最常见的误导,是直接写“联邦税 24%”。
24% 是强制预扣。大额奖金最终还可能进入 37% 顶级边际税率。项目没有假装自己能替代完整报税软件,但至少把这两层分开:
export const FEDERAL_WITHHOLDING_RATE = 0.24;
export const FEDERAL_TOP_RATE_DELTA = 0.13;
export const FEDERAL_TOP_BRACKET_SINGLE_2026 = 640_600;
const federalWithholding =
grossPayout * FEDERAL_WITHHOLDING_RATE;
const topBracketExposure = Math.max(
grossPayout - FEDERAL_TOP_BRACKET_SINGLE_2026,
0
);
const additionalFederalTax =
topBracketExposure * FEDERAL_TOP_RATE_DELTA;
const federalTax =
federalWithholding + additionalFederalTax;
const stateTax = grossPayout * stateTaxRate;
const totalTax = federalTax + stateTax;
const netPayout = Math.max(grossPayout - totalTax, 0);
这仍然是估算模型,不是逐档计算全部联邦所得税。申报身份、扣除项、地方税和跨州抵扣都没有完整覆盖。
但它至少没有混淆两件事:
- 领奖时先扣了多少;
- 最后大概还欠多少。
计算器的可信度,很多时候不取决于小数点多精确,而取决于有没有把假设摊在桌面上。
州税率数据放在 config/states.json 只是这一模型的输入源。真正重要的是州税永远作用在 grossPayout 上,并与联邦预扣、补税估算分别保留。这样后续增加地方税、非居民税或新的申报模型时,不需要推翻整个返回结构。
30 年年金不能用总额除以 30
现金领取是一笔钱。年金不是。
这个业务里的年金由 30 笔付款组成,第一笔在领奖时支付,后面每年一笔,每笔比上一年增加 5%。如果直接写:
const yearlyPayment = advertisedPrize / 30;
那从第一步就错了。
项目使用几何级数生成每年的付款份额:
export const ANNUITY_PAYMENT_COUNT = 30;
export const ANNUITY_GROWTH_RATE = 0.05;
const growth = ANNUITY_GROWTH_RATE;
const count = ANNUITY_PAYMENT_COUNT;
const denominator = Math.pow(1 + growth, count) - 1;
for (let year = 1; year <= count; year += 1) {
const share =
(growth * Math.pow(1 + growth, year - 1)) /
denominator;
const grossPayout = prize * share;
// 对这一年的付款单独计税
}
第 n 年的份额可以写成:
0.05 × 1.05^(n - 1) / (1.05^30 - 1)
分母负责归一化。30 个份额加起来等于 1,所以最终付款总额会回到广告奖池,不会因为逐年增长凭空多出一笔钱。
这还不是年金模型里最关键的地方。
真正影响税额的是:每一年的付款都要独立应用年度税档门槛。
for (let year = 1; year <= count; year += 1) {
const grossPayout = prize * share;
const federalWithholding =
grossPayout * FEDERAL_WITHHOLDING_RATE;
const topBracketExposure = Math.max(
grossPayout - FEDERAL_TOP_BRACKET_SINGLE_2026,
0
);
const additionalFederalTax =
topBracketExposure * FEDERAL_TOP_RATE_DELTA;
const stateTax = grossPayout * rate;
const totalTax =
federalWithholding + additionalFederalTax + stateTax;
rows.push({
year,
grossPayout,
federalWithholding,
additionalFederalTax,
federalTax:
federalWithholding + additionalFederalTax,
stateTax,
totalTax,
netPayout: Math.max(grossPayout - totalTax, 0)
});
}
如果先把 30 年付款加成 10 亿,再把它当作一笔当年收入计税,640,600 美元的门槛只会使用一次。
逐年计算时,这个门槛会在每笔年度收入上重新应用。两个模型的结果不会相同。
这类问题很典型。公式本身不难,难的是确认时间维度。很多“计算器 bug”不是加减乘除错了,而是把本来跨 30 年发生的事情压成了一个时间点。
另外需要说清楚:这里展示的是 30 年名义累计金额,不是折现后的净现值。它可以回答“按当前模型 30 年累计收到多少”,不能直接回答“年金和今天拿现金哪个投资价值更高”。后一个问题需要贴现率、通胀和机会成本,已经是另一套模型了。
纯函数的价值,是让同一个口径到处复用
lib/tax.ts 没有 React,也没有 Next.js。
它不读 URL,不格式化美元,不发请求,不知道当前语言。输入是数字和领取方式,输出是结构化结果。
这个边界让同一个 calculateTax() 被多个场景复用:
- 交互计算器计算用户当前输入;
- 首页实时快照给 50 个州批量排名;
- 州详情页生成固定奖池档位表;
- 历史开奖页计算往期奖池;
- 测试直接验证业务规则。
批量计算也没有单独发明一套公式:
export function computeJackpotStateRows(
jackpot: number,
states: Array<{
code: string; taxRate: number}>
): JackpotStateRow[] {
return states
.map((state) => ({
annuity: calculateTax({
payoutMode: "annuity",
prizeAmount: jackpot,
stateTaxRate: state.taxRate
}),
cash: calculateTax({
payoutMode: "cash",
prizeAmount: jackpot,
stateTaxRate: state.taxRate
}),
code: state.code,
rate: state.taxRate
}))
.sort(
(first, second) =>
second.cash.netPayout - first.cash.netPayout
);
}
这里 50 州配置化当然是必要的,但它只是数据层的常规操作。更有价值的是所有入口最终落到同一个领域函数。
否则首页一套公式、详情页一套公式、分享卡片再抄一套。短期都能跑,半年后税率或门槛一改,结果就开始互相打架。
数据源比公式更脏,先归一化再进入业务层
税率计算本身是确定性的,外部数据不是。
实时接口、州开放数据集和详情页抓取可能使用完全不同的字段名:
draw_date / drawDate / drawing_date
numbers / winning_numbers / white_balls
jackpot / jackpotUsd / jackpot_amount
如果每个页面各自兼容一遍,数据源差异会一路渗透到 UI。项目在 lib/powerball-normalize.ts 里先接收 unknown,再统一转换为领域类型:
export function normalizeDraw(
value: unknown
): PowerballDraw | null {
if (!isRecord(value)) {
return null;
}
const combinedNumbers = parseNumbers(
pickValue(value, [
"numbers",
"winning_numbers",
"winningNumbers"
])
);
const explicitWhiteBalls = parseNumbers(
pickValue(value, [
"white_balls",
"whiteBalls",
"main_numbers",
"balls"
])
).slice(0, 5);
const numberedWhiteBalls = parseBallFields(value);
const whiteBalls =
explicitWhiteBalls.length === 5
? explicitWhiteBalls
: numberedWhiteBalls.length === 5
? numberedWhiteBalls
: combinedNumbers.slice(0, 5);
const powerball =
Number(
pickValue(value, [
"powerball",
"power_ball",
"red_ball",
"redBall"
])
) || combinedNumbers[5];
const drawDate = parseDate(
pickValue(value, [
"draw_date",
"drawDate",
"date",
"drawing_date"
]),
POWERBALL_DRAW_TIME
);
if (
whiteBalls.length !== 5 ||
!Number.isInteger(powerball) ||
!drawDate
) {
return null;
}
return {
drawDate,
jackpotUsd: pickPositiveMoney(value, [
"jackpot",
"jackpotUsd",
"jackpot_amount"
]),
powerball,
powerPlay: parsePowerPlay(
pickValue(value, [
"multiplier",
"power_play",
"powerPlay"
])
),
videoUrl: parseVideoUrl(
pickValue(value, ["video_url", "videoUrl"])
),
whiteBalls
};
}
这段实现会在显式白球字段缺失时尝试 ball1 到 ball5,再回退到组合号码。这里的重点不是多写几个字段别名,而是把不可信输入挡在边界外。
后面的计算器只处理已经验证过的 PowerballDraw。它不需要知道数据来自 API、开放数据集还是 HTML。
多来源记录还可能重复,但各自携带的字段完整度不同。项目使用稳定业务键去重,并保留已有的有效值:
function getDrawKey(draw: PowerballDraw): string {
const drawDate = draw.drawDate.slice(0, 10);
return [
drawDate,
draw.whiteBalls.join("-"),
draw.powerball
].join(":");
}
const key = getDrawKey(draw);
const existing = deduped.get(key);
deduped.set(
key,
existing
? {
...existing,
jackpotUsd:
existing.jackpotUsd &&
existing.jackpotUsd > 0
? existing.jackpotUsd
: draw.jackpotUsd,
powerPlay:
existing.powerPlay &&
existing.powerPlay > 0
? existing.powerPlay
: draw.powerPlay,
videoUrl:
existing.videoUrl ?? draw.videoUrl
}
: draw
);
简单地用“最后一条覆盖前一条”并不稳。新来的记录可能只有号码,没有奖金;如果无脑覆盖,反而会把之前抓到的有效字段抹掉。
这种归一化和合并逻辑放在计算器文章里并不跑题。输入数据不稳定时,公式越精确,错误结果看起来越有迷惑性。
React 只保存源状态,不保存计算结果
交互组件 components/TaxCalculator.tsx 展示的东西不少:
- 现金税后金额;
- 年金税后金额;
- 两者差值;
- 有效税率;
- 横向对比图;
- 30 年付款表;
- 人民币估算;
- 分享 URL。
但组件真正保存的业务输入只有两个:
const [amount, setAmount] = useState(initialAmount);
const [stateCode, setStateCode] = useState(initialState);
其余几个 state 都是展开状态、复制提示之类的 UI 状态:
const [scheduleOpen, setScheduleOpen] = useState(false);
const [copiedAt, setCopiedAt] =
useState<number | null>(null);
const [shareOrigin, setShareOrigin] =
useState<string | null>(null);
税务结果全部推导:
const parsedAmount = useMemo(
() => parsePrizeAmount(amount),
[amount]
);
const cashBreakdown = useMemo(
() =>
calculateTax({
payoutMode: "cash",
prizeAmount: parsedAmount,
stateTaxRate: selectedState.taxRate,
}),
[parsedAmount, selectedState],
);
const annuitySchedule = useMemo(
() =>
computeAnnuitySchedule(
parsedAmount,
selectedState.taxRate
),
[parsedAmount, selectedState],
);
这里的重点不是“用了 useMemo,所以性能很好”。
30 次循环根本算不上重计算。useMemo 更主要的作用是把依赖关系写清楚:税务结果只由奖金和州决定。展开年金表、复制链接、两秒后清掉提示,都不该影响业务结果。
我没有给 netPayout 再建一个 state,也没有在 useEffect 里手动同步它。可推导数据一旦保存第二份,就会出现两个真相。输入变了,某个 effect 慢一拍,页面上不同模块就可能显示不同结果。
这种计算器不需要 Redux。状态不跨业务边界,硬上全局 store 只是在增加同步面。
URL 也是状态,但不该拖着 RSC 每次重跑
用户算完一个结果,通常会想保存或分享。
所以页面支持这种地址:
/en/calculator?jackpot=1b&state=NY
首次进入时,组件通过 useSearchParams() 恢复输入:
const initialAmount = useMemo(() => {
const raw = searchParams?.get("jackpot");
if (!raw) {
return defaultAmount;
}
return parsePrizeAmount(raw) > 0
? raw
: defaultAmount;
}, [searchParams, defaultAmount]);
但用户继续输入时,我没有直接调用 router.replace()。
App Router 的导航不只是改地址。它可能触发新的 RSC 请求。输入框每打一个字符都走一次导航链路,没有必要。
这里使用原生 History API 同步 URL:
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
]);
地址栏始终反映当前输入,页面不刷新,服务端组件也不会跟着每次键入重新跑。
这个做法还有一个细节:州详情页会把当前州作为 initialStateCode 传入。用户在 California 页面计算 California 时,URL 不需要再重复写 ?state=CA;只有切换到别州才写参数。
URL 不是组件 state 的镜像备份,而是一份可序列化、可分享的输入协议。把它当成协议来设计,很多边界会清楚不少。
Client Component 应该只包住必须交互的部分
因为计算器使用了 useSearchParams(),它必须是 Client Component。
但整个页面没必要因此全部客户端化。页面层仍然是 Server Component,负责拿实时奖池、汇率、翻译和静态内容:
const [exchangeRate, powerballData] = await Promise.all([
locale === "zh"
? getUsdCnyRate()
: Promise.resolve(null),
getPowerballData(),
]);
两个请求没有依赖关系,直接并行。计算器只接收已经整理好的 props。
Next.js 14 在静态预渲染时要求使用 useSearchParams() 的客户端子树放进 Suspense:
<Suspense fallback={null}>
<TaxCalculator
currentJackpotUsd={currentJackpotUsd}
exchangeRate={exchangeRate}
initialStateCode={state.code}
locale={locale}
states={states}
/>
</Suspense>
这样页面正文、州说明、FAQ 和结构化数据仍然可以走服务端渲染或静态生成。只有用户真正操作的计算器进入客户端。
50 州双语详情页则通过 generateStaticParams() 提前生成:
export function generateStaticParams() {
const params: {
locale: string;
state: string;
}[] = [];
for (const locale of ["en", "zh"]) {
for (const slug of getStateSlugs()) {
params.push({
locale, state: slug});
}
}
return params;
}
这不是为了炫静态生成。它解决的是页面属性不同、计算内核相同的问题。
每个州有自己的默认选择、说明和元数据,但不需要复制一份计算器。服务端页面负责上下文,客户端组件负责交互,领域函数负责结果。
缓存要按生命周期分层,也要处理冷启动成本
项目里的实时奖池读取集中在 lib/powerball.ts,数据来自 Redis,不是普通 fetch()。
Next.js 对 fetch 有自己的缓存和请求去重,但 Redis 客户端不在这个体系里。同一次 Server Component 渲染如果多个模块都读取奖池,没必要重复走 KV:
async function loadPowerballData(): Promise<PowerballData> {
const cached = await getCachedPowerballData();
if (cached) {
return cached;
}
return getSamplePowerballData();
}
export const getPowerballData = cache(loadPowerballData);
这里的 React cache() 管的是单次服务端渲染中的调用去重。
Redis 管跨请求的数据持久化。页面 revalidate 管 HTML 或 RSC 输出的再生成周期。汇率又有单独的 24 小时 TTL。
它们不是一回事:
React cache
→ 一次渲染内不要重复读
Redis
→ 多次请求之间保存实时数据
ISR revalidate
→ 控制页面输出多久更新
外部数据 TTL
→ 控制不同来源的刷新频率
把所有缓存都理解成“加个 revalidate”很容易出问题。实时奖池、州税配置和汇率的变化频率完全不同,本来就不该共用一个生命周期。
列表页还有一个很实际的问题:一次需要补齐多期详情。
如果循环调用 Redis GET,30 期数据就是 30 次网络往返。项目在 KV 适配层暴露了 mget():
mget: async <T>(keys: string[]) => {
if (keys.length === 0) return [];
const values = await redis.mGet(keys);
return values.map((value) =>
parseRedisValue<T>(value)
);
}
这不是算法优化,是 I/O 优化。计算几十条数据很便宜,跨网络来回几十次才贵。
写路径则交给定时任务。数据刷新后,Cron 主动抓取近期详情、写入缓存,再精确刷新受影响的页面。下面省略了新闻分页路径的计算,只保留缓存预热和失效部分:
const warm = await warmDrawDetailCache(warmLimit);
const news = await publishLatestPowerballNews();
for (const path of [
"/",
"/zh",
"/history",
"/zh/history",
"/trends",
"/zh/trends",
...newsIndexPaths,
"/sitemap.xml",
...news.articles.map((article) =>
buildLocalePath(
article.locale,
`news/${
article.slug}`
)
)
]) {
revalidatePath(path);
}
这样第一位访问列表页的用户不需要顺手承担 30 次详情抓取。抓取成本被移到后台任务,页面请求只读已经准备好的数据。
降级路径也分了几层:
const canCallApi = await canCallPowerballApi();
if (!canCallApi) {
return getCachedPowerballData();
}
const fresh = await fetchPowerballFromApi();
if (!fresh) {
return null;
}
await setCachedPowerballData(fresh);
return fresh;
外部 API 配额用完时先返回 Redis。页面读取层拿不到 Redis 数据时还能退回样例数据,避免本地开发和构建被外部依赖直接阻断。历史开放数据的回填失败则采用 best-effort,不阻断当前实时数据刷新。
这里没有追求“任何失败都吞掉”。计算结果依赖哪些数据、当前用了哪个降级层,仍然需要通过日志和数据来源字段可观测。降级的目的只是避免一个非核心数据源把整个页面拖死。
测试不要只盯着某个固定金额
计算器测试最偷懒的写法,是给一个输入,断言一个输出。
这种测试可以有,但更应该测模型不变量。lib/tax.test.ts 里的年金测试检查了三件事:
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
);
}
它验证的是:
- 永远有 30 笔付款;
- 所有付款总和回到广告奖池;
- 每一年确实比上一年增长 5%。
另一个测试专门比较逐年计税和单笔计税:
const {
totals} =
computeAnnuitySchedule(jackpot, stateRate);
const naive = calculateTax({
payoutMode: "annuity",
prizeAmount: jackpot,
stateTaxRate: stateRate
});
expect(totals.totalTax).toBeLessThan(
naive.totalTax
);
只要有人以后为了“简化”把逐年税档逻辑删掉,这个测试会直接报错。
固定结果会随着税率和门槛更新而变化。不变量更稳定,也更接近业务规则本身。
目前还有两处口径没有完全收干净
这次回头看代码,我也发现项目并不是已经没有问题。
第一处是现金价值。
首页实时快照已经会使用官方 cashValueUsd / jackpotUsd,但完整交互计算器目前只接收 currentJackpotUsd,没有把实时 cashValueUsd 一起传进去。用户点击“当前奖池”时,计算器仍然使用默认 60%。
更完整的做法应该是:
type TaxCalculatorProps = {
currentJackpotUsd?: number | null;
currentCashValueUsd?: number | null;
// ...
};
当输入正好等于当前奖池时使用实时比例,用户改成任意金额后再回退到默认假设。这个状态转换需要明确,不能悄悄切换。
第二处是年金口径。
主计算器已经使用 computeAnnuitySchedule() 逐年计算,但部分静态档位页仍调用:
calculateTax({
payoutMode: "annuity",
prizeAmount: jackpot,
stateTaxRate
});
这条路径会把广告奖池当成一笔总收入,是简化估算。它和主计算器的逐年模型不是同一精度。
下一步我会把年金总额统一收口到:
computeAnnuitySchedule(
jackpot,
stateTaxRate
).totals
或者干脆把简化路径从公共 API 中移除,避免调用方只看见 payoutMode: "annuity" 就误以为拿到了完整年金模型。
这两个问题暂时都不会让页面崩掉,但会让不同入口产生口径差异。计算型产品最难处理的技术债往往就是这种:类型正确、运行正常、业务含义却开始分叉。
最后
写完这个项目以后,我对“复杂计算器”的判断标准变了。
它不是公式多,也不是条件多。真正费时间的是下面几件事:
- 每个数字有没有明确语义;
- 假设能不能被替换;
- 跨时间的现金流有没有按时间展开;
- 同一套模型能不能被多个页面复用;
- URL、客户端状态和服务端数据有没有互相拖累;
- 测试是在验证结果,还是在验证规则;
- 不同入口是否还在使用同一口径。
JSON 配置只能解决“数据放在哪里”。解决不了这些问题。
我现在更愿意把计算器看成一个小型领域模型,Next.js 只是承载它的页面框架。模型边界稳了,州页面、历史页面、分享链接和实时快照才敢继续往上叠。
文章里的实现都来自当前项目。正文不反复放产品名,相关页面和源码集中列在下面。