昨天到账、今天才发现:用SQLite把积分流水的迟到事件算清楚
在核对网页活动时,我遇到过一个很容易误算的场景:页面余额增加了,但新增明细的记账时间属于昨天;隔天再次刷新,同一条明细又出现在列表里。如果把“今天看见的所有加分”都当成今天挣到的积分,再把每轮结果相加,就会同时出现归属日期错误和重复累计。
这里用Python标准库和SQLite做一个可复现的小实验。全部账号、事件ID、日期和数字都是合成样例,不连接任何活动接口,不读取真实账号,也不执行兑换。示例只解决本地证据整理的问题,不能修改平台流水或证明平台一定漏发。
一、先分清三种问题
第一种是“目前余额是多少”,应以平台最新余额为准。第二种是“这一轮新发现哪些流水”,取决于上次已保存的事件集合。第三种是“今天实际记账多少”,要依据平台明细的记账时间,而不是本地刷新时间。它们相关,但不是同一个指标。
为避免把合成账本的起点用错,先固定一个早于全部样例事件的历史锚点:400分。然后发生三条事件:10月23日奖励30分;10月24日奖励10分;10月24日兑换支出200分。三条记录都在24日首次导入本地工具。
因此,本轮新发现的奖励合计40分,其中属于24日的奖励只有10分。完整流水相对历史锚点的净变化为30+10-200=-160,当前合成余额为240。若平台24日开始余额已经是430,就只能再加当天10、减200,仍为240;不能把旧30分再次加到430上。
这也解释了为什么只计算“结束余额-开始余额”无法代表奖励总额:期间如果发生兑换,余额下降仍可能同时获得奖励。
二、去重需要稳定身份,不能只比较金额
两项不同任务可能同日各奖励10分,不能因为名称、日期和金额相同,就把两条记录合成一条。反过来,同一条30分奖励在两次刷新中重复出现,也不能算60分。
样例使用上游稳定事件ID,并以“账号+事件ID”作为联合主键。若现实页面没有公开稳定ID,只能把日期、类型、金额等组合视作待核对线索;这些字段可能碰撞,不宜宣称已经实现可靠去重。尤其不能为了取ID去调用隐藏接口或获取不必要的凭证。
CREATE TABLE ledger (
account TEXT NOT NULL,
event_id TEXT NOT NULL,
event_day TEXT NOT NULL,
delta INTEGER NOT NULL CHECK(typeof(delta)='integer'),
observed_day TEXT NOT NULL,
PRIMARY KEY(account, event_id)
);
event_day表示本例按明确日历日归属的记账日期;observed_day记录第一次观察到它的日期。真实系统还需保留原始时间、时区与规则版本,不能随意截取跨时区字符串。主键中的账号仅用于分开本地账本,不代替服务端的用户认证和租户权限检查。
三、重复与冲突必须区分
如果同一ID再次出现且日期、增量均一致,可以判为重放,不改变最早观察时间。如果同一ID的金额从30变成60,则需要停止并核对来源,而不是静默忽略或直接覆盖旧值。
SQLite的冲突处理文档说明,IGNORE会跳过适用的约束冲突行,REPLACE在主键或唯一性冲突时可能删除原记录再插入。因此,不能把“INSERT OR IGNORE没报错”解释成新旧内容完全相同,也不能用REPLACE掩盖证据差异。下面先比较载荷,再插入;出现冲突时整批回滚。
from datetime import date
def ingest(db, account, events, observed_day):
inserted = 0
db.execute('BEGIN IMMEDIATE')
try:
if date.fromisoformat(observed_day).isoformat() != observed_day:
raise ValueError('observation date must be YYYY-MM-DD')
for event_id, event_day, delta in events:
if not event_id or type(delta) is not int:
raise ValueError('nonempty ID and integer delta required')
if date.fromisoformat(event_day).isoformat() != event_day:
raise ValueError('event date must be YYYY-MM-DD')
prior = db.execute(
'SELECT event_day, delta FROM ledger '
'WHERE account=? AND event_id=?',
(account, event_id),
).fetchone()
if prior is not None:
if prior != (event_day, delta):
raise ValueError('event ID reused with changed payload')
continue
db.execute(
'INSERT INTO ledger VALUES (?, ?, ?, ?, ?)',
(account, event_id, event_day, delta, observed_day),
)
inserted += 1
db.execute('COMMIT')
except BaseException:
if db.in_transaction:
db.execute('ROLLBACK')
raise
return inserted
连接由调用方专用,使用sqlite3.connect(':memory:', isolation_level=None)建立,并先执行建表语句;调用时不能已处于其他事务中。本例BEGIN IMMEDIATE用于在比较和插入之前启动写事务,它仍可能遇到SQLITE_BUSY,不意味着任何并发都自动成功。SQLite官方事务文档明确只有一个同时进行的写事务。这里没有做多进程并发压测,不能把本地测试结果扩大为生产吞吐承诺。
四、用可重复的输入验证余额守恒
把上面的建表SQL保存为SCHEMA,连接和导入可以这样调用:
import sqlite3
db = sqlite3.connect(':memory:', isolation_level=None)
db.executescript(SCHEMA)
events = [
('reward-old', '2026-10-23', 30),
('reward-today', '2026-10-24', 10),
('redeem-demo', '2026-10-24', -200),
]
print(ingest(db, 'demo', events, '2026-10-24')) # 3
print(ingest(db, 'demo', events, '2026-10-25')) # 0
net = db.execute(
'SELECT COALESCE(SUM(delta),0) FROM ledger WHERE account=?',
('demo',),
).fetchone()[0]
print(400 + net) # 240
db.close()
我在本地先写测试,再补实现,最终运行9项测试全部通过:重复投递只入一次;相同ID载荷冲突回滚本批新行;迟到记录保留两种日期;兑换负增量不丢失;不同ID同金额分别保留;不同账号同ID互不混入;bool和浮点增量拒绝;后续日期非法导致整批回滚;重放不覆盖首次观察日期。另执行完整三事件样例,实际输出3、0、240。
这些用例验证的是本地导入行为。测试没有触发任何真实活动,没有证明页面列表完整,也不证明某项奖励满足审核条件。若官方余额与本地锚点加完整增量之和不一致,应检查分页是否齐全、是否跨了兑换或失效、时区是否一致,以及锚点本身是否已包含部分流水;不能直接把差额标成应补奖励。
五、把待审核和已到账分开存
“已提交建议”“回答已公开”“赛事已报名”适合放进任务状态表;只有看到实际加分明细,才导入已记账事件。未获采纳、未获证书的记录可以保留待办,但不要先生成预计奖励,再让它混入实际余额。
对于需要人工核查的情况,保留原始截图、获取时间和事件来源,并在新一轮记录中引用原证据即可。有限复查能发现迟到事件,重复触发任务却未必增加有效证据。真正需要保持稳定的,是每条记录的身份、时间含义和可追溯来源。
本文使用AI辅助整理,代码与数据均为本地合成示例,实际执行与测试范围已在文中说明。
参考:
- SQLite冲突处理:https://www.sqlite.org/lang_conflict.html
- SQLite事务:https://www.sqlite.org/lang_transaction.html
- 点赞
- 收藏
- 关注作者
评论(0)