昨天到账、今天才发现:用SQLite把积分流水的迟到事件算清楚

举报
yd_225778114 发表于 2026/10/25 09:19:46 2026/10/25
【摘要】 用Python标准库与SQLite区分迟到事件、重复投递和兑换支出,保留记账日期与首次观察日期,通过9项测试验证去重、冲突回滚及余额守恒。全部数据为本地合成样例。

在核对网页活动时,我遇到过一个很容易误算的场景:页面余额增加了,但新增明细的记账时间属于昨天;隔天再次刷新,同一条明细又出现在列表里。如果把“今天看见的所有加分”都当成今天挣到的积分,再把每轮结果相加,就会同时出现归属日期错误和重复累计。

这里用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

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。