积分页上最危险的一个数字
积分页通常只有一个大数字:可用积分 12,480。用户对这个数字的理解是「我能抵 124.8 元」,端上对这个数字的理解往往是「上次的余额加今天发的减今天用的」。两种理解在大多数时候一致,在退款、过期、跨端登录、并发抵扣时就会分叉。
分叉的代价很直接:用户按页面上的积分抵扣,提交时服务端说余额不足;或者用户明明看到积分过期了,端上还显示可用。积分不是钱,但它能直接抵钱,所以它的展示必须遵守和金额一样的规则:余额是服务端事实,端上只能展示,不能自行加减。这一节把积分拆成一本流水账,四类流水、一个时间轴、一套重放规则,就能把「余额」从易变的数字变成可验证的结果。
四类流水,一条时间轴
积分账本只有四类事件,每类事件的语义与约束都不同。
| 流水 | 方向 | 作用 | 关键约束 |
|---|---|---|---|
发放 earn | 增加 | 活动、消费返积分 | 必须带入账时间与过期时间 |
抵扣 redeem | 减少 | 下单时抵现 | 必须带订单号,幂等键唯一 |
退回 refund | 增加 | 订单取消或抵扣失败 | 必须指向原抵扣,不能重复退 |
过期 expire | 审计 | 记录某批次失效 | 只用于对账,不重复扣余额 |
最重要的是最后一列。很多实现把「过期」也当成一次扣减:定时任务扫描到期批次,写一条负数流水。问题在于「到期」本身是时间属性,不是一次用户动作。端上在 23:59 看到 100 分,00:01 再看到 0 分,中间不一定有流水到达;如果端上还依赖「收到过期流水才更新」,就会在这段时间展示错误余额。本节的模型把过期分成两层:批次是否可用由时间边界判断,expire 流水只作为审计记录,告诉对账系统「这个批次已经失效」。这样既不会重复扣减,也不依赖定时任务是否及时到达端上。
余额必须重放,不能在端上加减
假设端上保存一个 balance 字段,每次发放就 balance += points,每次抵扣就 balance -= points。这个模型有四个问题:
- 漏消息。推送丢失、页面未刷新、跨端登录,任何一次事件没到,余额就永久偏了。
- 重复消息。同一条发放流水被推送两次,余额加两次。
- 顺序错乱。退款先到、抵扣后到,端上先加后减,中间态可能出现负数。
- 过期无法表达。余额字段不知道哪些积分即将过期,只能等过期流水。
正确做法是保存流水,余额由流水重放得出。重放的关键不是「把所有加减做一遍」,而是按时间排序,并按批次消费。每条发放或退回产生一个批次,每个批次有剩余量与过期时间;抵扣按规则消费批次;查询时只汇总未过期且仍有剩余的批次。这样,余额不是被维护出来的,而是被计算出来的。计算可以重复执行,结果只取决于流水集合与查询时间,不取决于端上收到消息的顺序。
过期批次:先到期先出
如果用户有两批积分:A 批次 100 分,30 天后过期;B 批次 50 分,5 天后过期。现在抵扣 120 分,应该先扣哪一批?直觉是按入账顺序先进先出,但更合理的规则是先到期先出:先扣 B 的 50 分,再扣 A 的 70 分。否则 B 会在 5 天后过期,用户损失 50 分,而 A 还留着。所以消费顺序应该是:到期时间早的批次优先;没有到期时间的批次最后;到期时间相同时,按入账时间与批次 id 稳定排序。这个顺序必须写进代码,而不是依赖数组的插入顺序。多端合并后数组顺序可能变化,只有显式排序才能保证结果一致。批次模型还带来一个展示能力:端上可以告诉用户「你有 50 分将在 5 天后过期」,而不是只给一个总数。这对减少投诉比任何文案都有效。
抵扣失败:追加退回,不删除原流水
下单时用积分抵扣,常见流程是:先锁定积分,创建订单,支付成功后扣减;如果创建订单失败,释放积分。端上最容易犯的错是「失败后把本地那条抵扣流水删掉」。删除流水会带来三个问题:幂等键消失,重试时可能重复抵扣;审计链断裂,客服无法还原「当时到底扣没扣」;多端同步时,另一端可能已经收到了抵扣流水,删除无法传播。正确做法是追加一条退回流水,指向原抵扣的 id。原抵扣保留,退回记录说明「这笔抵扣被撤销」。查询余额时,两条流水一正一负,结果正确;对账时,两条都在,链路完整。退回还必须满足两个约束:同一笔抵扣只能退一次;退回的积分要有新的过期时间,或者回到原批次的有效期,具体由服务端决定。端上不计算退回后的余额,只展示服务端返回的结果。
多端一致性:按 id 合并,按时间重放
用户在手机、平板、网页同时登录时,三端各自持有一份流水副本。合并规则必须满足三个条件:
- 按流水 id 去重。同一 id 只保留一条,重复推送不会造成重复加减。
- 同一 id 内容必须一致。如果两个端对同一 id 给出不同积分值,说明数据已经损坏,合并应该报错,而不是随便选一个。
- 合并后按时间与 id 排序。重放结果只取决于集合,不取决于哪一端先同步。
这三条看起来像分布式系统的基础课,但端上经常偷懒:用「最后写入赢」覆盖整份账本,或者只同步余额不同步流水。前者会丢事件,后者无法解释余额是怎么来的。积分账本的正确姿态是「流水是事实,余额是视图」。
一个可运行的积分账本模块
下面的模块属于 L1:纯函数、时间注入、无网络。它实现四件事:从流水重放余额、按先到期先出消费批次、用退回流水回滚抵扣、按 id 合并多端账本。
// file: src/member/points.ts
// tier: L1
// verify: node --test test/member/points.test.ts
export type PointsFlow = "earn" | "redeem" | "refund" | "expire";
export interface PointsEntry {
id: string; flow: PointsFlow; points: number; at: number;
expiresAt: number | null; orderId: string | null; sourceId: string | null;
}
export interface PointsAccount { entries: PointsEntry[]; }
export interface PointsBatch {
entryId: string; remaining: number; expiresAt: number | null; at: number;
}
export interface PointsSummary { available: number; batches: PointsBatch[]; }
export interface Allocation { entryId: string; points: number; }
export interface RedeemRequest { id: string; points: number; at: number; orderId: string; }
export interface RedeemResult {
account: PointsAccount; accepted: boolean; allocations: Allocation[];
reason: "accepted" | "duplicate" | "insufficient"; available: number;
}
export interface RefundRequest { id: string; redeemId: string; at: number; expiresAt: number | null; }
export interface RefundResult {
account: PointsAccount; accepted: boolean;
reason: "accepted" | "duplicate" | "missing"; available: number;
}
const FLOW_TEXT: Record<PointsFlow, string> = {
earn: "发放", redeem: "抵扣", refund: "退回", expire: "过期",
};
export function flowText(flow: PointsFlow): string {
return FLOW_TEXT[flow];
}
// 余额从流水重放得出,端上不在旧余额上做加减。
export function pointsSummary(account: PointsAccount, now: number): PointsSummary {
assertAccount(account);
assertTime(now, "查询时间");
const batches: PointsBatch[] = [];
for (const entry of sortEntries(account.entries)) {
if (entry.at > now) continue;
applyEntry(batches, entry);
}
const live = batches.filter((b) => b.remaining > 0 && !isExpired(b, now));
return {
available: live.reduce((sum, b) => sum + b.remaining, 0),
batches: live.map((b) => ({ ...b })),
};
}
export function redeem(
account: PointsAccount, request: RedeemRequest,
): RedeemResult {
assertAccount(account);
assertRedeem(request);
const existing = account.entries.find((entry) => entry.id === request.id);
if (existing !== undefined) {
if (existing.flow !== "redeem" || existing.points !== request.points
|| existing.at !== request.at || existing.orderId !== request.orderId) {
throw new Error(`流水 id 冲突:${request.id}`);
}
return {
account, accepted: true, reason: "duplicate", allocations: [],
available: pointsSummary(account, request.at).available,
};
}
const batches = replayTo(account, request.at);
const available = batches.filter((b) => b.remaining > 0 && !isExpired(b, request.at))
.reduce((sum, b) => sum + b.remaining, 0);
if (available < request.points) {
return { account, accepted: false, reason: "insufficient", allocations: [], available };
}
const allocations = consume(batches, request.points, request.at);
const entry: PointsEntry = {
id: request.id, flow: "redeem", points: request.points, at: request.at,
expiresAt: null, orderId: request.orderId, sourceId: null,
};
const next: PointsAccount = { entries: [...account.entries, entry] };
return {
account: next, accepted: true, reason: "accepted", allocations,
available: pointsSummary(next, request.at).available,
};
}
// 抵扣失败时追加一条退回流水,不删除原抵扣;同一笔抵扣只能退一次。
export function refundRedeem(
account: PointsAccount, request: RefundRequest,
): RefundResult {
assertAccount(account);
assertRefund(request);
const existing = account.entries.find((entry) => entry.id === request.id);
if (existing !== undefined) {
if (existing.flow !== "refund" || existing.sourceId !== request.redeemId) {
throw new Error(`流水 id 冲突:${request.id}`);
}
return {
account, accepted: true, reason: "duplicate",
available: pointsSummary(account, request.at).available,
};
}
const already = account.entries.find(
(entry) => entry.flow === "refund" && entry.sourceId === request.redeemId,
);
if (already !== undefined) {
return {
account, accepted: false, reason: "duplicate",
available: pointsSummary(account, request.at).available,
};
}
const original = account.entries.find((entry) => entry.id === request.redeemId);
if (original === undefined || original.flow !== "redeem") {
return {
account, accepted: false, reason: "missing",
available: pointsSummary(account, request.at).available,
};
}
if (request.at <= original.at) throw new Error("退回时间必须晚于原抵扣时间");
const entry: PointsEntry = {
id: request.id, flow: "refund", points: original.points, at: request.at,
expiresAt: request.expiresAt, orderId: original.orderId, sourceId: original.id,
};
const next: PointsAccount = { entries: [...account.entries, entry] };
return {
account: next, accepted: true, reason: "accepted",
available: pointsSummary(next, request.at).available,
};
}
// 多端合并按流水 id 去重,再按时间与 id 排序,结果与到达顺序无关。
export function mergeLedgers(
local: PointsAccount, remote: PointsAccount,
): PointsAccount {
assertAccount(local);
assertAccount(remote);
const byId = new Map<string, PointsEntry>();
for (const entry of [...local.entries, ...remote.entries]) {
const seen = byId.get(entry.id);
if (seen !== undefined) {
if (entryKey(seen) !== entryKey(entry)) {
throw new Error(`同一流水出现不同内容:${entry.id}`);
}
continue;
}
byId.set(entry.id, entry);
}
return { entries: sortEntries([...byId.values()]) };
}
function applyEntry(batches: PointsBatch[], entry: PointsEntry): void {
if (entry.flow === "earn" || entry.flow === "refund") {
batches.push({
entryId: entry.id, remaining: entry.points,
expiresAt: entry.expiresAt, at: entry.at,
});
} else if (entry.flow === "redeem") {
consume(batches, entry.points, entry.at);
}
// 过期流水是审计记录;批次是否失效由时间边界判定,避免重复扣减。
}
function replayTo(account: PointsAccount, at: number): PointsBatch[] {
const batches: PointsBatch[] = [];
for (const entry of sortEntries(account.entries)) {
if (entry.at > at) break;
applyEntry(batches, entry);
}
return batches;
}
// 先到期先出;无到期时间的批次最后使用,同到期时间按入账时间与 id。
function consume(batches: PointsBatch[], points: number, at: number): Allocation[] {
const candidates = batches
.filter((b) => b.remaining > 0 && !isExpired(b, at)).sort(compareBatch);
let left = points;
const allocations: Allocation[] = [];
for (const batch of candidates) {
if (left === 0) break;
const used = Math.min(batch.remaining, left);
batch.remaining -= used;
left -= used;
allocations.push({ entryId: batch.entryId, points: used });
}
if (left > 0) throw new Error("流水不足以支持抵扣");
return allocations;
}
function compareBatch(a: PointsBatch, b: PointsBatch): number {
const ae = a.expiresAt ?? Number.MAX_SAFE_INTEGER;
const be = b.expiresAt ?? Number.MAX_SAFE_INTEGER;
if (ae !== be) return ae - be;
if (a.at !== b.at) return a.at - b.at;
return a.entryId < b.entryId ? -1 : a.entryId > b.entryId ? 1 : 0;
}
function isExpired(batch: PointsBatch, at: number): boolean {
return batch.expiresAt !== null && at >= batch.expiresAt;
}
function sortEntries(entries: PointsEntry[]): PointsEntry[] {
return [...entries].sort((a, b) => {
if (a.at !== b.at) return a.at - b.at;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
}
function entryKey(entry: PointsEntry): string {
return [entry.id, entry.flow, entry.points, entry.at,
entry.expiresAt, entry.orderId, entry.sourceId].join("|");
}
function assertAccount(account: PointsAccount): void {
if (!Array.isArray(account.entries)) throw new Error("积分账本必须是流水数组");
const ids = new Set<string>();
for (const entry of account.entries) {
assertEntry(entry);
if (ids.has(entry.id)) throw new Error(`流水 id 重复:${entry.id}`);
ids.add(entry.id);
}
}
function assertEntry(entry: PointsEntry): void {
if (!entry.id) throw new Error("流水缺少 id");
if (!(entry.flow in FLOW_TEXT)) {
throw new Error(`未知积分流水类型:${String(entry.flow)}`);
}
assertPoints(entry.points);
assertTime(entry.at, "入账时间");
if (entry.expiresAt !== null) {
assertTime(entry.expiresAt, "过期时间");
if (entry.expiresAt <= entry.at) {
throw new Error(`过期时间必须晚于入账时间:${entry.id}`);
}
}
if (entry.flow === "redeem" && entry.expiresAt !== null) {
throw new Error(`抵扣流水不应带过期时间:${entry.id}`);
}
if (entry.flow === "expire" && entry.sourceId === null) {
throw new Error(`过期流水必须指明批次:${entry.id}`);
}
}
function assertRedeem(request: RedeemRequest): void {
if (!request.id || !request.orderId) throw new Error("抵扣请求缺少 id 或订单号");
assertPoints(request.points);
assertTime(request.at, "抵扣时间");
}
function assertRefund(request: RefundRequest): void {
if (!request.id || !request.redeemId) throw new Error("退回请求缺少 id");
assertTime(request.at, "退回时间");
if (request.expiresAt !== null) {
assertTime(request.expiresAt, "退回过期时间");
if (request.expiresAt <= request.at) {
throw new Error("退回积分的过期时间必须晚于退回时间");
}
}
}
function assertPoints(points: number): void {
if (!Number.isInteger(points) || points <= 0) throw new Error("积分必须是正整数");
}
function assertTime(at: number, label: string): void {
if (!Number.isSafeInteger(at) || at < 0) {
throw new Error(`${label}必须是安全整数毫秒`);
}
}
pointsSummary 是查询入口。它按时间排序,只重放查询时间之前的事件,再汇总未过期批次。未来入账的积分不会被提前算进余额,过期批次也不会被算进去。
redeem 是抵扣入口。它先检查幂等 id,再重放、判断余额、按批次消费,最后追加一条抵扣流水。余额不足时返回 insufficient,不产生部分流水;重复提交同一个 id 时返回 duplicate,不重复扣分。
refundRedeem 是回滚入口。它找到原抵扣,追加一条退回流水,并阻止同一笔抵扣被退两次。它不删除任何东西,账本只增不改。mergeLedgers 是多端合并入口。它按 id 去重,内容冲突时报错,最后统一排序。合并后的账本可以直接交给 pointsSummary 重放。
测试与真实输出
$ node --test test/member/points.test.ts
✔ 四类流水有稳定文案 (0.569584ms)
✔ 余额由发放流水推导并保留批次 (0.246292ms)
✔ 未来入账的积分不计入可用余额 (0.0715ms)
✔ 抵扣按先到期先出顺序选择批次 (0.568708ms)
✔ 余额不足时拒绝抵扣且不产生部分流水 (0.085417ms)
✔ 抵扣成功后余额下降并记录分配批次 (0.11175ms)
✔ 重复提交同一抵扣 id 幂等返回,不重复扣分 (0.097708ms)
✔ 同一 id 携带不同内容时拒绝合并 (0.23125ms)
✔ 抵扣失败后追加退回流水,余额恢复 (0.194708ms)
✔ 退回不存在的抵扣返回 missing (0.104167ms)
✔ 同一笔抵扣不能被第二次退回 (0.093375ms)
✔ 重复提交同一退回 id 幂等返回 (0.086375ms)
✔ 超过到期时间的批次不再计入余额 (0.049208ms)
✔ 过期流水只是审计记录,不会二次扣减 (0.061583ms)
✔ 抵扣不能使用入账时间尚未到达的积分 (0.060041ms)
✔ 多端账本合并去重且与到达顺序无关 (0.148ms)
✔ 同一流水在不同端内容不一致时拒绝合并 (0.071167ms)
✔ 非法流水类型、积分与过期批次被拒绝 (0.104416ms)
✔ 账本自身不足以支持历史抵扣时拒绝计算 (0.077041ms)
ℹ tests 19
ℹ suites 0
ℹ pass 19
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 154.20125
十九条用例覆盖:四类流水文案、余额重放、未来入账、先到期先出、余额不足、抵扣成功、幂等抵扣、id 冲突、退回恢复、退回缺失、重复退回、合并去重、内容冲突、过期排除、过期审计、非法流水、账本自相矛盾。其中三条最值得关注:先到期先出固定消费顺序,避免临期积分被浪费;同一笔抵扣不能二次退回,守住回滚幂等;过期流水只是审计记录,不参与二次扣减。
一次「本地扣积分」导致的对账差异复盘
「回声」早期的积分页在端上维护余额:用户点「使用积分」时,端上先减掉本地余额,再发请求。请求失败时,端上把余额加回去。这个方案在单端、弱网少的场景下看起来没问题。
一次大促后,客服收到一类反馈:用户下单时用了 2000 积分,订单取消后积分没有回来。复盘发现,取消订单的退回流水已经由服务端发出,但端上在退款页只刷新了订单状态,没有刷新积分;用户回到积分页时,页面用的是本地缓存的旧余额。更麻烦的是,另一位用户在两端同时操作,一端把本地余额加回去,另一端又按服务端结果覆盖,最终显示了一个两边都不对的数字。
修复做了三件事:端上不再维护余额字段,积分页每次进入都从流水重放;订单取消后主动刷新积分账本;抵扣与退回都使用幂等 id,重复消息不会重复加减。回归时重点验证了「抵扣成功→订单取消→积分恢复」与「抵扣请求超时→重试」两条路径。
这个复盘的教训是:积分余额不是状态,是流水的结果。一旦端上把它当状态维护,就必须自己处理漏消息、重复消息、顺序错乱和过期,而这四件事本来应该由账本模型处理。
小结
积分账本由四类流水组成:发放、抵扣、退回、过期。余额必须从流水重放,按批次与时间计算,不能在端上做加减;消费批次要先到期先出;抵扣失败用退回流水回滚,不删除原记录;多端合并按 id 去重、按时间排序。
这套模型的成本是端上多存一份流水、多一次重放;收益是余额可解释、可对账、可重试。对于能直接抵钱的积分,这个成本是值得的。
下一节讨论权益的另一端:会员到期后的降级。积分讲的是「怎么算」,降级讲的是「什么时候变、变了以后订单怎么办」。
阅读导航:上一节:8.1 会员状态与权益展示 · 下一节:8.3 权益降级与到期提醒 。
常见错误与边界
- 端上维护
balance字段,发放加、抵扣减,漏一条消息就永久偏差。 - 把过期当成一次扣减流水,定时任务延迟时端上仍显示可用积分。
- 抵扣按入账顺序而不是到期顺序消费,让即将过期的批次白白浪费。
- 抵扣失败后删除本地流水,幂等键与审计链一起丢失。
- 同一笔抵扣允许退回两次,用户获得双倍积分。
- 多端合并用「最后写入赢」覆盖整份账本,丢掉另一端的事件。
- 同一流水 id 内容冲突时随便选一条,掩盖数据损坏。
- 查询余额时把未来入账的积分提前算进去,用户抵扣时才发现不可用。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。