资讯详情

资讯详情

饭卡管理系统实战:离线交易、幂等上传与资金对账的工程实现

简介这是一份面向编程初学者与课程设计学习者的饭卡管理系统完整项目源码适用于校园场景下的饭卡充值、消费记录查询与后台管理等练手需求。资源包共93个文件约2.57MB以class编译文件、doc文档、vsd设计图、java源码、form表单、xml配置、properties属性文件及mdb数据库等为主涵盖代码、设计文档与数据库文件便于对照理解系统结构。项目围绕数据库设计、用户界面、后端编程、事务处理、安全性、测试调试、文档编写与版本控制等知识点展开涉及用户表与交易表设计、充值消费接口实现、ACID事务回滚、密码加密与SQL注入防护等内容并配有需求分析、设计文档与测试用例可帮助读者走完需求分析、设计、编码、测试到维护的完整软件工程流程。目前已有441人学习适合作为课程设计参考或入门实战素材。1. 饭卡管理系统从刷卡到对账一条链路里的真实工程账饭卡管理系统这个词听起来像是课程设计里最没技术含量的那一类。但真把它放到一个日均三千笔交易、六个消费终端、两个充值窗口的园区食堂里跑上三个月你会发现它本质上是一套离线优先、最终一致、带资金对账的轻量级交易系统。刷卡那一下只用了两百毫秒背后要处理的是终端本地扣款、网络抖动时的离线缓存、定时上传后的幂等入库、以及每天凌晨的账实核对。任何一个环节偷懒第二天早上就会有窗口排起长队或者财务拿着一沓差异单来找你。这套系统适合谁如果你正在给学校、园区、工厂食堂做一套能落地的刷卡消费方案或者你手里已经有一个跑得不太稳的旧系统需要重构那这篇笔记里的选型思路、表结构、离线补传逻辑和排查手段基本可以照着复现。我默认你用的是常见做法终端侧跑一个轻量客户端服务端用关系库兜底中间靠一个上传队列把两边缝起来。下面从数据模型开始一层层拆到能跑起来的最小实现。2. 先把账算清楚饭卡管理系统的数据模型与选型理由2.1 为什么资金流水不能只存一条余额很多第一版系统会犯一个错用户表里放一个balance字段每次消费直接UPDATE balance balance - amount。单机跑没问题一旦终端离线补传、或者同一张卡在两台机器上几乎同时刷卡余额就会算错。正确做法是余额由流水推导而不是直接改。每笔充值、消费、退款都写一条不可变的流水记录余额是这些流水的聚合结果。这样即使补传顺序乱了只要流水本身带唯一业务号最终对账时就能把账找回来。常见做法是保留一张card_account存当前余额快照用于快速查询同时用card_transaction存全量流水。快照可以重建流水不能丢。下面是我一般会用的最小表结构字段不多但每个都有存在的理由。-- 账户表只存快照余额可被流水重算覆盖 CREATE TABLE card_account ( card_no VARCHAR(32) PRIMARY KEY, -- 卡号业务主键 user_name VARCHAR(64) NOT NULL, -- 持卡人 balance DECIMAL(12,2) NOT NULL DEFAULT 0.00, -- 当前余额快照 status TINYINT NOT NULL DEFAULT 1, -- 1正常 2挂失 3注销 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); -- 流水表不可变每笔交易一条 CREATE TABLE card_transaction ( txn_no VARCHAR(40) PRIMARY KEY, -- 全局唯一业务号幂等靠它 card_no VARCHAR(32) NOT NULL, txn_type TINYINT NOT NULL, -- 1充值 2消费 3退款 amount DECIMAL(12,2) NOT NULL, -- 正数方向由 txn_type 决定 terminal_id VARCHAR(32) NOT NULL, -- 产生这笔交易的终端 occurred_at DATETIME NOT NULL, -- 终端本地发生时间 uploaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 服务端入库时间 UNIQUE KEY uk_txn (txn_no), KEY idx_card_time (card_no, occurred_at) );txn_no是整套系统的命门。终端在离线状态下生成它格式建议是「终端号 本地单调递增序列 日期」保证同一终端不重复、跨终端不冲突。服务端入库时靠这个唯一键做幂等重复上传直接忽略不需要额外查重逻辑。occurred_at和uploaded_at分开存是为了排查「交易发生时间和入库时间差了几个小时」这类问题离线补传场景下这两个值经常对不上。2.2 终端选型为什么我倾向本地扣款而不是在线扣款在线扣款的意思是刷卡时终端把请求发给服务端等服务端返回成功再放行。这套逻辑在实验室里很优雅在食堂窗口就是灾难。网络一抖队伍就卡住。我见过的稳定方案都是本地扣款 异步上传终端本地维护一份该卡的最新余额缓存刷卡时直接扣扣完写本地流水后台线程慢慢往服务端传。代价是终端和服务端可能短暂不一致。比如卡在 A 终端消费后还没上传用户立刻去 B 终端刷B 终端看到的还是旧余额。这个窗口通常只有几秒到几分钟实际业务里可以接受。如果业务要求强一致那就得回到在线模式但你要接受网络故障时窗口停摆。我的判断是食堂场景优先保可用性一致性用对账来兜。终端本地缓存建议用 SQLite一张local_balance表加一张local_txn表结构和上面服务端类似。终端启动时从服务端拉一次全量余额之后靠上传回执和定时同步来更新。下面是一个终端侧扣款的伪代码重点看幂等和本地事务。import sqlite3, time, uuid def consume(card_no, amount, terminal_id): conn sqlite3.connect(terminal.db) cur conn.cursor() # 本地事务扣余额和写流水必须一起成功 cur.execute(BEGIN IMMEDIATE) cur.execute(SELECT balance FROM local_balance WHERE card_no?, (card_no,)) row cur.fetchone() if row is None or row[0] amount: conn.rollback() return {ok: False, msg: 余额不足或卡不存在} # 生成全局唯一业务号终端号 时间戳 随机后缀 txn_no f{terminal_id}-{int(time.time()*1000)}-{uuid.uuid4().hex[:6]} cur.execute(UPDATE local_balance SET balancebalance-? WHERE card_no?, (amount, card_no)) cur.execute(INSERT INTO local_txn(txn_no, card_no, txn_type, amount, terminal_id, occurred_at, uploaded) VALUES(?,?,2,?,?,?,0), (txn_no, card_no, amount, terminal_id, int(time.time()))) conn.commit() conn.close() return {ok: True, txn_no: txn_no}BEGIN IMMEDIATE是为了在扣款瞬间锁住写操作避免同一终端上两个线程同时扣同一张卡。uploaded字段标记是否已上传后台线程只扫uploaded0的记录。txn_no里的随机后缀是为了防止同一毫秒内两笔交易撞号虽然概率低但资金系统里没有后悔药宁可多几个字符。2.3 上传队列把离线交易安全送回服务端终端扣款成功后流水躺在本地local_txn里等上传。上传逻辑要处理三件事网络失败重试、服务端幂等、上传成功后标记。我一般用一个独立线程每隔几秒扫一次未上传记录批量发到服务端。服务端收到后按txn_no做插入或忽略返回每条的处理结果。import requests, sqlite3, time def upload_loop(server_url, terminal_id): while True: conn sqlite3.connect(terminal.db) cur conn.cursor() cur.execute(SELECT txn_no, card_no, txn_type, amount, occurred_at FROM local_txn WHERE uploaded0 ORDER BY occurred_at LIMIT 50) rows cur.fetchall() if rows: payload [{txn_no: r[0], card_no: r[1], txn_type: r[2], amount: str(r[3]), occurred_at: r[4], terminal_id: terminal_id} for r in rows] try: resp requests.post(f{server_url}/txn/batch, jsonpayload, timeout5) if resp.status_code 200: ok_nos resp.json().get(accepted, []) for no in ok_nos: cur.execute(UPDATE local_txn SET uploaded1 WHERE txn_no?, (no,)) conn.commit() except requests.RequestException: pass # 网络失败下轮重试不标记 conn.close() time.sleep(3)服务端/txn/batch接口的核心是对每条记录尝试插入card_transaction遇到唯一键冲突就跳过最后返回本次真正插入成功的txn_no列表。终端只标记这些成功的失败的下一轮继续传。这样即使服务端处理到一半崩了重启后重复上传也不会产生重复流水。批量大小设 50 是个经验值太大容易超时太小上传频率高3 秒一轮在局域网里够用。3. 服务端落地批量入库、余额重算与对账任务3.1 批量入库接口怎么写才不拖垮数据库终端上传是批量来的服务端如果一条条INSERT再COMMIT几千笔交易会把库压得很难受。常见做法是用一条INSERT ... ON DUPLICATE KEY UPDATE或者先查后插但更稳的是利用唯一键做批量插入让数据库自己处理冲突。下面这个接口用 Python 的 DB-API 做批量执行配合事务控制。from flask import Flask, request, jsonify import pymysql app Flask(__name__) app.route(/txn/batch, methods[POST]) def batch_txn(): items request.get_json() conn pymysql.connect(host127.0.0.1, userapp, password***, databasecanteen, charsetutf8mb4) accepted [] try: with conn.cursor() as cur: for it in items: try: cur.execute(INSERT INTO card_transaction (txn_no, card_no, txn_type, amount, terminal_id, occurred_at) VALUES (%s,%s,%s,%s,%s,%s), (it[txn_no], it[card_no], it[txn_type], it[amount], it[terminal_id], it[occurred_at])) accepted.append(it[txn_no]) except pymysql.err.IntegrityError: # 唯一键冲突说明已入库视为成功但不重复计数 accepted.append(it[txn_no]) conn.commit() finally: conn.close() return jsonify({accepted: accepted})这里有个细节唯一键冲突时我也把txn_no放进accepted因为对终端来说这笔交易确实已经安全到达服务端了标记为已上传可以避免它反复重传。真正的重复插入被数据库挡住了不会产生脏数据。occurred_at直接存终端传来的时间不做时区转换因为同一套系统里所有终端约定用同一个时区转换反而容易出错。3.2 余额重算什么时候跑、怎么跑才安全余额快照card_account.balance在正常消费时由服务端异步更新但离线补传、退款、人工调账都会让它和流水对不上。所以需要一个重算任务定期用流水聚合结果覆盖快照。重算不能太频繁否则和实时更新打架也不能太久不跑否则差异越滚越大。我一般设成每天凌晨跑一次全量白天只做增量校验。-- 全量重算按卡号聚合所有流水覆盖快照余额 UPDATE card_account a JOIN ( SELECT card_no, SUM(CASE WHEN txn_type 1 THEN amount WHEN txn_type 2 THEN -amount WHEN txn_type 3 THEN amount ELSE 0 END) AS calc_balance FROM card_transaction GROUP BY card_no ) t ON a.card_no t.card_no SET a.balance t.calc_balance WHERE a.balance t.calc_balance;WHERE a.balance t.calc_balance这个条件很关键它只更新有差异的行避免全表写锁。跑之前建议先SELECT出差异行存到一张reconcile_diff表里方便第二天查原因。如果差异行数突然暴涨说明某台终端的上传逻辑或者某段网络出了问题这时候重算只是止血根因还得去终端日志里找。3.3 对账任务把「账实不符」变成一张可追的差异表对账的本质是拿终端本地流水和服务端流水做比对。终端每天定时把本地local_txn的全量摘要按txn_no列表或按时间段的聚合发给服务端服务端比对两边都有哪些交易、金额是否一致。差异通常分三类终端有服务端没有上传丢了、服务端有终端没有几乎不会除非人工补录、两边都有但金额不同终端被篡改或程序 bug。def reconcile(terminal_id, local_txn_nos): conn pymysql.connect(host127.0.0.1, userapp, password***, databasecanteen, charsetutf8mb4) with conn.cursor() as cur: # 查服务端该终端在对应时间段的流水号 cur.execute(SELECT txn_no FROM card_transaction WHERE terminal_id%s AND occurred_at %s, (terminal_id, local_txn_nos[since])) server_nos {r[0] for r in cur.fetchall()} local_set set(local_txn_nos[txn_nos]) missing_on_server local_set - server_nos # 终端有、服务端没有 extra_on_server server_nos - local_set # 服务端有、终端没有 conn.close() return {missing: list(missing_on_server), extra: list(extra_on_server)}missing列表里的交易就是需要补传的终端收到后把对应记录的uploaded重置为 0下一轮上传会重新发送。extra列表一般用于审计如果出现大量服务端多出来的交易要查是不是有人绕过终端直接往库里写数据。对账任务建议每天跑一次结果存表并保留至少 90 天资金相关的记录不要轻易删。4. 避坑与排查饭卡管理系统上线后最容易翻车的五件事4.1 终端时间不准导致流水顺序错乱现象对账时发现同一张卡在几秒内出现「先消费后充值」的流水但实际用户是先充值再消费余额算出来是负的。原因某台终端的系统时间比服务端慢了十几分钟它产生的occurred_at偏小入库后按时间排序就乱了。解决终端启动时强制和服务端对时偏差超过 30 秒就拒绝交易并告警。服务端入库时也记录uploaded_at排查时以uploaded_at为辅助参考但业务排序仍以occurred_at为准前提是时间已经校准。4.2 批量上传把服务端连接池打满现象中午高峰期终端上传线程频繁报连接超时服务端日志里大量Too many connections。原因每台终端的上传线程在请求失败后立刻重试没有退避几十台终端同时重试就把连接池占满了。解决上传失败后加指数退避第一次等 3 秒第二次 6 秒最多退到 60 秒。服务端连接池大小按终端数乘以 2 来配并且给/txn/batch接口设一个独立的连接池不要和查询接口共用。4.3 余额快照更新和重算任务互相覆盖现象凌晨重算跑完后白天又出现余额对不上而且差异金额和重算前一样。原因重算任务在跑的时候实时消费还在更新card_account.balance重算的UPDATE把实时更新覆盖了。解决重算任务加一个全局锁跑的时候暂停余额更新或者改成只更新updated_at早于重算开始时间的行。更简单的做法是把重算放到业务低峰期并且重算前先停掉终端的余额同步跑完再恢复。4.4 挂失卡在离线终端上还能继续消费现象用户在窗口挂失后去另一台离线终端刷卡居然还能扣款成功。原因挂失状态存在服务端离线终端本地缓存里这张卡还是正常状态。解决挂失操作要尽量走在线通道并且服务端在挂失成功后主动向所有在线终端推送状态变更。对于离线终端只能靠缩短余额同步间隔来缩小窗口比如从 5 分钟改成 1 分钟。如果业务对挂失实时性要求极高那就得放弃离线扣款模式回到在线验证。4.5 退款流水方向搞反导致余额虚增现象用户退款后余额没变反而多了一笔钱。原因退款流水txn_type3在余额重算 SQL 里被当成了收入SUM时加了amount而不是减。解决重算 SQL 里退款应该和消费一样是减项或者约定退款流水存负数金额重算时统一用SUM(amount)。我倾向后者所有流水金额都带符号充值正、消费负、退款负重算逻辑最简单不容易写错方向。改完 SQL 后拿一笔测试退款跑一遍确认余额变化符合预期再上线。5. 进阶技巧用影子流水验证饭卡管理系统的对账准确性系统跑稳之后真正让我放心的是影子流水这个习惯。做法很简单在服务端加一张shadow_transaction表结构和card_transaction一样但写入时机不同。正常流水是终端上传后入库影子流水是服务端在每次余额更新成功后根据内存里的计算结果再写一条。两条流水理论上应该完全一致如果出现差异说明余额更新逻辑和流水入库逻辑之间有 bug。-- 影子流水表只用于比对不参与业务查询 CREATE TABLE shadow_transaction ( txn_no VARCHAR(40) PRIMARY KEY, card_no VARCHAR(32) NOT NULL, txn_type TINYINT NOT NULL, amount DECIMAL(12,2) NOT NULL, terminal_id VARCHAR(32) NOT NULL, occurred_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- 每天比对一次找出只在一边存在的流水 SELECT only_in_main AS side, txn_no FROM card_transaction WHERE txn_no NOT IN (SELECT txn_no FROM shadow_transaction) UNION ALL SELECT only_in_shadow, txn_no FROM shadow_transaction WHERE txn_no NOT IN (SELECT txn_no FROM card_transaction);这个比对不需要实时跑每天一次就够。差异结果如果长期为空说明主流程是可信的一旦出现差异顺着txn_no去查两边的写入日志通常能很快定位到是哪个环节漏了或重了。影子流水会多占一份存储但相比资金出错后的人工排查成本这点空间不值一提。另一个技巧是给对账任务加一个差异趋势图。不用很复杂每天把差异笔数写进一张reconcile_stat表用简单的折线图看趋势。如果某天差异突然从 0 跳到几十那一定是当天有变更上线或者某台终端出了问题。我一般会在差异超过 5 笔时触发告警让值班的人先看一眼终端在线状态再决定要不要人工介入。最后说个我自己的习惯每次改完余额相关的 SQL 或者上传逻辑先拿一张测试卡在离线终端上刷十笔断网、恢复、再刷十笔然后跑一遍对账。这个流程走下来只要十分钟但能挡住大部分低级错误。资金系统里没有后悔药能提前验的就别等线上出事再补。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →