核心設(shè)計(jì):從數(shù)據(jù)庫(kù)建模到并發(fā)扣款與對(duì)賬)
簡(jiǎn)介這份畢業(yè)設(shè)計(jì)資料圍繞校園一卡通信息管理系統(tǒng)的完整設(shè)計(jì)展開面向計(jì)算機(jī)科學(xué)與技術(shù)等專業(yè)的本科生、畢業(yè)設(shè)計(jì)選題者及對(duì)高校信息化管理感興趣的開發(fā)者。文檔從需求分析、E-R 圖規(guī)劃到 SQL Server 數(shù)據(jù)庫(kù)實(shí)現(xiàn)再到 ASP.NET 技術(shù)開發(fā)系統(tǒng)地闡述了信息集成、消費(fèi)跟蹤、實(shí)時(shí)監(jiān)督等核心模塊并涉及安全性、可擴(kuò)展性等設(shè)計(jì)考量可直接作為課程設(shè)計(jì)或論文寫作的參考藍(lán)本。資源包共包含 1 個(gè) docx 文件整體大小 1.39MB內(nèi)容為完整論文文檔涵蓋封面、任務(wù)書、進(jìn)度計(jì)劃、中英文摘要、正文及系統(tǒng)功能設(shè)計(jì)等結(jié)構(gòu)便于讀者查閱與修改格式。目前已有 670 人學(xué)習(xí)瀏覽適合需要快速了解一卡通系統(tǒng)設(shè)計(jì)框架、撰寫畢業(yè)設(shè)計(jì)論文或梳理 ASP.NET SQL Server 項(xiàng)目思路的用戶。通過(guò)該文檔可以清晰地掌握校園一卡通系統(tǒng)的功能劃分與數(shù)據(jù)庫(kù)設(shè)計(jì)方法包括用戶信息管理、消費(fèi)記錄、信息更新與實(shí)時(shí)監(jiān)督等模塊的實(shí)現(xiàn)要點(diǎn)同時(shí)能獲取論文寫作的章節(jié)組織方式和任務(wù)書填寫范例對(duì)提升畢業(yè)設(shè)計(jì)完成效率具有實(shí)用價(jià)值。1. 校園一卡通系統(tǒng)到底在管什么一張卡背后的賬務(wù)閉環(huán)校園一卡通信息管理系統(tǒng)的設(shè)計(jì)這個(gè)題目在課程設(shè)計(jì)和畢業(yè)設(shè)計(jì)里出現(xiàn)頻率很高但它遠(yuǎn)不止「做一個(gè)能查余額、模擬消費(fèi)的界面」這么簡(jiǎn)單。一張校園卡背后連著賬戶、流水、商戶、掛失、補(bǔ)卡一整條業(yè)務(wù)鏈真正讓系統(tǒng)有含金量的部分不是頁(yè)面而是賬務(wù)閉環(huán)每一筆消費(fèi)都對(duì)得上賬、余額不會(huì)超扣、掛失確實(shí)能攔住舊卡。只做增刪改查項(xiàng)目做完很容易但把它當(dāng)作一個(gè)最小可用的真實(shí)賬務(wù)系統(tǒng)來(lái)設(shè)計(jì)就要處理數(shù)據(jù)一致性、并發(fā)扣款、對(duì)賬和日志審計(jì)這些才是一份設(shè)計(jì)文檔真正的核心。這個(gè)方向適合兩類人一類是正在做課程設(shè)計(jì)或畢設(shè)、需要交可運(yùn)行系統(tǒng)和設(shè)計(jì)文檔的同學(xué)另一類是剛接觸業(yè)務(wù)系統(tǒng)、想搞懂「余額、流水、賬戶」之間關(guān)系的初級(jí)開發(fā)者。按我經(jīng)手的這類課題來(lái)看設(shè)計(jì)得像樣的系統(tǒng)重點(diǎn)一般不是在管理界面上而是在底層幾個(gè)關(guān)鍵表能不能經(jīng)得起對(duì)賬。接下來(lái)我按「業(yè)務(wù)建模 → 建表 → 踩坑 → 接口落地 → 上線驗(yàn)證」的順序把完整做法過(guò)一遍。2. 一卡通的核心業(yè)務(wù)模型賬戶、流水、商戶與狀態(tài)機(jī)設(shè)計(jì)2.1 數(shù)據(jù)流向與業(yè)務(wù)閉環(huán)為什么流水表是系統(tǒng)的命根子一卡通的日常動(dòng)作可以收斂成四個(gè)環(huán)節(jié)開卡、充值、消費(fèi)、對(duì)賬。開卡時(shí)系統(tǒng)創(chuàng)建一個(gè)賬戶并綁定一張實(shí)體卡充值往賬戶余額里加錢消費(fèi)從賬戶余額里扣錢。幾乎所有系統(tǒng)設(shè)計(jì)文檔都會(huì)把這三步畫出來(lái)但決定系統(tǒng)質(zhì)量的是第四步對(duì)賬。我設(shè)計(jì)的這類系統(tǒng)里有一張交易流水表是絕對(duì)核心所有涉及余額變化的行為都必須寫流水。賬戶表只保存當(dāng)前余額的「快照」真正可信的歷史記錄在流水表里。這個(gè)思路一定得在文檔里說(shuō)透流水表只增不改不刪哪怕充值金額錯(cuò)了、消費(fèi)扣錯(cuò)了也不能去 UPDATE 那條流水只能再寫一條沖正或退款記錄。原因很簡(jiǎn)單賬務(wù)系統(tǒng)的審計(jì)要求每一分錢都能追溯到動(dòng)作改流水等于銷毀證據(jù)。數(shù)據(jù)流向可以概括為請(qǐng)求進(jìn)來(lái) → 系統(tǒng)鎖住賬戶 → 校驗(yàn)余額 → 寫流水 → 更新余額 → 返回結(jié)果。任何一步失敗整個(gè)事務(wù)回滾流水和余額必須同時(shí)成功同時(shí)失敗。文檔里的業(yè)務(wù)流程圖如果不體現(xiàn)這一層答辯時(shí)很容易被問住。2.2 實(shí)體關(guān)系梳理賬戶、卡片、商戶的動(dòng)作邊界校園一卡通里最重要的三個(gè)實(shí)體是賬戶、卡片、商戶。我一般用三張主表來(lái)表達(dá)賬戶表只管金額和賬戶狀態(tài)卡片表只管卡號(hào)和卡狀態(tài)商戶表只管消費(fèi)收款方的信息。賬戶和卡是一對(duì)多關(guān)系也就是說(shuō)一個(gè)人可以有一張主卡后來(lái)補(bǔ)辦一張新卡但新舊卡共享同一個(gè)賬戶余額。這里有個(gè)常見設(shè)計(jì)誤區(qū)把余額直接放在卡片表里。這樣做的隱患是補(bǔ)卡時(shí)余額遷移非常痛苦??ㄆ瑏G了補(bǔ)辦一張新卡要有舊卡余額就得把整條卡記錄復(fù)制或改綁一旦中間出了岔子余額就丟了。正確做法是卡片表只存 card_no 和 account_id余額歸屬賬戶卡只是賬戶的一個(gè)憑證。商戶在這個(gè)系統(tǒng)里是消費(fèi)流水的收款方。食堂窗口、超市、開水房都是商戶每筆消費(fèi)必須歸屬于某個(gè)商戶否則對(duì)賬時(shí)不知道錢流向哪里。商戶表相對(duì)簡(jiǎn)單主要是商戶編號(hào)、名稱、狀態(tài)但它承擔(dān)了后面流水表冗余字段的重要身份來(lái)源。2.3 狀態(tài)機(jī)與異常場(chǎng)景凍結(jié)、掛失、解凍的流程定義業(yè)務(wù)建模階段最容易偷懶的就是狀態(tài)設(shè)計(jì)。一個(gè)一卡通系統(tǒng)至少有三種狀態(tài)維度賬戶狀態(tài)、卡狀態(tài)、流水狀態(tài)。很多同學(xué)只設(shè)計(jì)一個(gè) status 字段結(jié)果后面做掛失、凍結(jié)、注銷時(shí)邏輯到處打補(bǔ)丁。賬戶狀態(tài)我習(xí)慣用 0 正常、1 凍結(jié)、2 注銷??顟B(tài)用 0 正常、1 掛失、2 凍結(jié)、3 注銷。為什么要分開因?yàn)閽焓ǔJ强▉G了用戶本人賬戶還是正常的只針對(duì)這一張卡停用凍結(jié)則可能是賬戶涉及糾紛或風(fēng)控連賬戶余額都不能動(dòng)。如果把兩者合在一個(gè)狀態(tài)里掛失之后想充值都做不了業(yè)務(wù)上說(shuō)不通。流水狀態(tài)一般有 0 處理中、1 成功、2 失敗、3 沖正。設(shè)計(jì)接口時(shí)先插入一條「處理中」的流水再在事務(wù)里完成余額變更最后把流水改成成功這種做法能方便地支撐超時(shí)重試和異常恢復(fù)。狀態(tài)機(jī)要給一張轉(zhuǎn)換表寫進(jìn)文檔比如「掛失卡不允許消費(fèi)允許充值」「凍結(jié)賬戶不允許消費(fèi)和取款」。狀態(tài)設(shè)計(jì)越早理清后面寫代碼時(shí)就越不需要到處 if 判斷。3. 數(shù)據(jù)庫(kù)表結(jié)構(gòu)設(shè)計(jì)從建表 SQL 到索引與分區(qū)策略3.1 基礎(chǔ)建表腳本賬戶表、卡片表、流水表表結(jié)構(gòu)設(shè)計(jì)是整個(gè)系統(tǒng)的地基我見過(guò)太多設(shè)計(jì)文檔里用 FLOAT 存錢這是絕對(duì)不能接受的。金額一律用 DECIMAL精度至少 DECIMAL(10,2)。余額字段用 DECIMAL(10,2) 的話最大支持 99999999.99對(duì)校園卡場(chǎng)景綽綽有余。賬戶表和流水表的核心建表語(yǔ)句我放在下面可以直接拿去改。-- 賬戶表余額的唯一歸屬方 CREATE TABLE t_account ( account_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 賬戶ID, account_no VARCHAR(32) NOT NULL COMMENT 賬戶編號(hào)業(yè)務(wù)唯一, available_balance DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 可用余額, total_recharge DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 累計(jì)充值金額, total_consume DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT 累計(jì)消費(fèi)金額, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1凍結(jié) 2注銷, version INT NOT NULL DEFAULT 0 COMMENT 樂觀鎖版本號(hào), create_time DATETIME NOT NULL COMMENT 創(chuàng)建時(shí)間, update_time DATETIME NOT NULL COMMENT 更新時(shí)間, PRIMARY KEY (account_id), UNIQUE KEY uk_account_no (account_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT賬戶表; -- 卡片表卡歸屬于賬戶 CREATE TABLE t_card ( card_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 卡片ID, card_no VARCHAR(32) NOT NULL COMMENT 物理卡號(hào)刷卡時(shí)讀到, account_id BIGINT NOT NULL COMMENT 關(guān)聯(lián)賬戶ID, status TINYINT NOT NULL DEFAULT 0 COMMENT 0正常 1掛失 2凍結(jié) 3注銷, issue_time DATETIME NOT NULL COMMENT 發(fā)卡時(shí)間, loss_time DATETIME DEFAULT NULL COMMENT 掛失時(shí)間, create_time DATETIME NOT NULL COMMENT 創(chuàng)建時(shí)間, PRIMARY KEY (card_id), UNIQUE KEY uk_card_no (card_no), KEY idx_account_id (account_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT卡片表; -- 交易流水表只增不改 CREATE TABLE t_trans_log ( trans_id BIGINT NOT NULL AUTO_INCREMENT COMMENT 流水ID, trans_no VARCHAR(64) NOT NULL COMMENT 交易流水號(hào)全局唯一, account_id BIGINT NOT NULL COMMENT 賬戶ID, card_no VARCHAR(32) NOT NULL COMMENT 卡號(hào)冗余方便定位, merchant_id BIGINT NOT NULL COMMENT 商戶ID, trans_type TINYINT NOT NULL COMMENT 1充值 2消費(fèi) 3退款 4沖正, amount DECIMAL(10,2) NOT NULL COMMENT 交易金額正數(shù), balance_before DECIMAL(10,2) NOT NULL COMMENT 交易前余額, balance_after DECIMAL(10,2) NOT NULL COMMENT 交易后余額, status TINYINT NOT NULL DEFAULT 0 COMMENT 0處理中 1成功 2失敗 3沖正, remark VARCHAR(255) DEFAULT NULL COMMENT 備注, create_time DATETIME NOT NULL COMMENT 交易時(shí)間, PRIMARY KEY (trans_id), UNIQUE KEY uk_trans_no (trans_no), KEY idx_account_time (account_id, create_time), KEY idx_card_no (card_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT交易流水表;這段建表 SQL 的邏輯需要解釋幾句。第一流水表里做了 card_no 和 balance_before、balance_after 的冗余這是刻意為之。對(duì)賬時(shí)經(jīng)常只需要拿著流水號(hào)定位一筆交易如果還要反查賬戶和卡才能知道當(dāng)時(shí)余額鏈路一長(zhǎng)查詢就慢。冗余字段犧牲了一點(diǎn)存儲(chǔ)換來(lái)的是查詢時(shí)不用高頻聯(lián)表在業(yè)務(wù)系統(tǒng)里這個(gè)取舍是值得的。第二流水號(hào) uk_trans_no 建了唯一索引。這個(gè)字段承擔(dān)冪等控制同一個(gè)流水號(hào)第二次插入直接報(bào)錯(cuò)程序就能以此攔住重復(fù)提交。流水號(hào)生成規(guī)則我一般用「時(shí)間戳 商戶號(hào)后四位 當(dāng)天自增序號(hào)」比如 20250612103015 0012 0001拼起來(lái) 18 位可讀性和唯一性兼顧。不要用 UUID 當(dāng)流水號(hào)雖然唯一但沒法排序、沒法肉眼排查問題。3.2 交易流水表的高頻查詢字段與索引設(shè)計(jì)流水表是增長(zhǎng)最快的表一個(gè)幾千人的學(xué)校跑一年輕松到百萬(wàn)級(jí)。常見的查詢有兩種一是查某個(gè)人某個(gè)時(shí)間段的消費(fèi)明細(xì)二是查某筆流水號(hào)對(duì)應(yīng)的交易詳情。上面建表時(shí)我加的兩個(gè)索引分別服務(wù)這兩種查詢idx_account_time 覆蓋「賬戶 時(shí)間范圍」檢索uk_trans_no 精確命中單筆流水。很多同學(xué)會(huì)給流水表的每個(gè)字段都加索引這是反面教材。索引不是越多越好每個(gè)索引都占用寫入開銷。流水表是寫多讀少的典型插入時(shí)每多一個(gè)索引就多一次 B 樹維護(hù)高并發(fā)下會(huì)明顯拖慢性能。我一般控制在三到四個(gè)索引覆蓋主查詢路徑就夠了。如果文檔里想體現(xiàn)「系統(tǒng)量大也能頂住」的設(shè)計(jì)可以加一句按月份分區(qū)。以 create_time 做 RANGE 分區(qū)每個(gè)月一個(gè)分區(qū)跨月查詢時(shí) MySQL 自動(dòng)裁剪分區(qū)。這個(gè)設(shè)計(jì)在答辯時(shí)說(shuō)清楚「為什么按月份而不是按賬戶哈希」因?yàn)槟悴樵兓径紟r(shí)間條件月份分區(qū)能直接跳過(guò)期數(shù)據(jù)。3.3 對(duì)賬需要的冗余字段與統(tǒng)計(jì)字段對(duì)賬是一卡通系統(tǒng)的隱藏需求設(shè)計(jì)文檔可以不提但真正上線后沒人敢不做。對(duì)賬的核心是拿流水表的累計(jì)金額和賬戶表的余額做核對(duì)但光靠流水表還不夠。流水表里每一筆都有 balance_before 和 balance_after理論上余額應(yīng)該等于賬戶表余額但一旦出現(xiàn)人為改庫(kù)、臟數(shù)據(jù)、并發(fā)丟更新兩邊就不一致。這時(shí)候靠什么定位靠每筆流水的前后余額。我在賬戶表里還加了 total_recharge 和 total_consume 兩個(gè)統(tǒng)計(jì)字段。每次寫流水時(shí)同步累加這兩個(gè)字段數(shù)據(jù)正確時(shí)可用余額等于累計(jì)充值減累計(jì)消費(fèi)對(duì)賬 SQL 掃一遍全表就能發(fā)現(xiàn)究竟是哪條賬戶記錄壞了。這筆「冗余統(tǒng)計(jì)」的代價(jià)很低但對(duì)賬效率提升非常明顯不用臨時(shí)去聚合幾百萬(wàn)行流水。還有一點(diǎn)必須寫進(jìn)文檔所有金額字段上了數(shù)據(jù)庫(kù)約束應(yīng)該加 CHECK 約束嗎MySQL 8.0 之前的版本 CHECK 是擺設(shè)不生效所以我一般不用而是放到應(yīng)用層校驗(yàn)。但如果用的是 PostgreSQLCHECK 約束是真的可以擋住余額為負(fù)的。選型時(shí)心里要清楚這一點(diǎn)免得文檔里寫了約束實(shí)際上沒效果。4. 一卡通系統(tǒng)設(shè)計(jì)與實(shí)現(xiàn)的常見問題排查四大踩坑現(xiàn)場(chǎng)4.1 余額和流水對(duì)不上改了流水賬就永遠(yuǎn)平不了現(xiàn)象對(duì)賬程序跑出來(lái)一大批賬戶余額與流水明細(xì)不一致金額差距還不固定有的差幾塊有的差幾十塊。原因開發(fā)階段圖省事余額扣錯(cuò)了直接 UPDATE 流水表把金額改掉或者手工在數(shù)據(jù)庫(kù)里改余額。流水一旦被改動(dòng)前后余額鏈就斷了。所有賬戶都是通過(guò)流水累加推導(dǎo)出來(lái)的你在中間動(dòng)了任意一筆后面的每一筆余額全部錯(cuò)位。解決馬上停掉一切手工改庫(kù)行為對(duì)賬以流水表為準(zhǔn)。寫一個(gè)校正腳本按賬戶分組重放全部流水從首筆開始計(jì)算應(yīng)有余額和賬戶表現(xiàn)有余額比對(duì)不一致的生成調(diào)賬流水把所有差異掛到「待處理」?fàn)顟B(tài)。之后在代碼層面加上約束流水表只允許 INSERT 和 UPDATE status不允許 UPDATE amount 和 balance 字段。這個(gè)約束可以在 MyBatis 的更新語(yǔ)句里刻意不寫這些字段也可以靠數(shù)據(jù)庫(kù)觸發(fā)器攔截。血淚經(jīng)驗(yàn)賬務(wù)系統(tǒng)里手工改數(shù)據(jù)是最大的事故源與其相信自己不會(huì)手滑不如從機(jī)制上斷掉這條路。4.2 并發(fā)扣款導(dǎo)致余額超扣先查再扣就是競(jìng)態(tài)漏洞現(xiàn)象一個(gè)學(xué)生食堂高峰期在 1 號(hào)窗口和 2 號(hào)窗口幾乎同時(shí)刷卡消費(fèi)系統(tǒng)返回余額不足但實(shí)際上余額夠付其中一筆。更嚴(yán)重的場(chǎng)景是余額只夠一筆卻被扣了兩筆。原因代碼寫成了「先 SELECT 余額檢查再 UPDATE 余額」兩步之間沒有加鎖。兩個(gè)請(qǐng)求同時(shí)讀到同一個(gè)余額都判斷「夠扣」于是都執(zhí)行了扣減。這是經(jīng)典的丟失更新問題。解決讓余額扣減成為一個(gè)原子操作。第一種做法是用 SELECT ... FOR UPDATE 鎖住賬戶行事務(wù)結(jié)束才釋放第二種做法是直接用一條 UPDATE 語(yǔ)句做條件扣減例如「UPDATE t_account SET available_balance available_balance - #{amount}, version version 1 WHERE account_id #{accountId} AND available_balance #{amount}」受影響行數(shù)為 0 代表余額不足。我推薦第二種少一次交互還天然防超扣。如果用了版本號(hào)字段更新條件里還得帶上 version防止其他事務(wù)改了余額你卻基于舊值覆蓋。-- 條件扣減余額充足才扣款返回影響行數(shù)判斷是否成功 UPDATE t_account SET available_balance available_balance - #{amount}, total_consume total_consume #{amount}, version version 1 WHERE account_id #{accountId} AND status 0 AND available_balance #{amount}4.3 掛失卡在離線消費(fèi)終端上被刷黑名單不是實(shí)時(shí)的現(xiàn)象學(xué)生掛失校園卡之后在某個(gè)離線刷卡機(jī)上又成功消費(fèi)了一筆學(xué)生投訴說(shuō)掛失根本沒用。原因消費(fèi)終端有兩種工作模式。在線模式每次刷卡都請(qǐng)求服務(wù)端校驗(yàn)掛失立即生效離線模式為了高峰不排隊(duì)讓終端本地緩存余額和黑名單定期同步。掛失信息同步到終端之前離線終端的本地黑名單里沒有這張卡就會(huì)放行。解決明確離線終端的「掛失生效延遲窗口」。常見做法是設(shè)置黑名單同步周期不超過(guò) 5 分鐘同時(shí)給離線終端加單筆消費(fèi)上限和當(dāng)日累計(jì)上限減少損失面。真正嚴(yán)格的方案是離線終端不發(fā)交易成功憑證等聯(lián)網(wǎng)后補(bǔ)傳流水服務(wù)端發(fā)現(xiàn)黑名單卡時(shí)對(duì)這筆交易做沖正。這個(gè)點(diǎn)設(shè)計(jì)文檔里必須寫清楚「終端的在線/離線模式差異」不然答辯時(shí)大概率被追問。4.4 流水表越查越慢索引失效和全表掃描現(xiàn)象系統(tǒng)上線兩三個(gè)月后后臺(tái)查某人某月消費(fèi)明細(xì)要等好幾秒對(duì)賬腳本更是跑到超時(shí)。原因兩個(gè)典型問題疊加。一是查詢條件里寫了「WHERE account_id 某值 AND create_time BETWEEN 某范圍」但 create_time 用了函數(shù)包裹比如 DATE_FORMAT(create_time, %Y-%m)函數(shù)導(dǎo)致索引失效二是 SQL 里對(duì)流水狀態(tài)做統(tǒng)計(jì)時(shí)只用了 status 字段做條件而 status 沒有索引。解決第一日期查詢直接寫成 create_time 2025-06-01 00:00:00 AND create_time 2025-07-01 00:00:00絕不包函數(shù)第二如果確實(shí)經(jīng)常按 status 過(guò)濾加一個(gè) status 的普通索引第三給流水表啟用分區(qū)按月裁剪舊數(shù)據(jù)。排查索引失效的通用思路是先 EXPLAIN 看執(zhí)行計(jì)劃確認(rèn) type 是 ALL 就說(shuō)明全表掃描然后檢查條件字段是否被函數(shù)包裹、是否有隱式類型轉(zhuǎn)換。5. 后端接口設(shè)計(jì)與交易鏈路落地從接口定義到 SQL 防注入5.1 統(tǒng)一返回結(jié)構(gòu)與消費(fèi)接口設(shè)計(jì)后端接口的風(fēng)格直接影響系統(tǒng)好不好維護(hù)。我一般定義統(tǒng)一的返回體包含 code、message、data 三個(gè)字段。code 為 0 表示成功非 0 表示失敗比如 10001 余額不足、10002 卡已掛失、10003 賬戶凍結(jié)、10004 重復(fù)交易。這個(gè)設(shè)計(jì)在文檔里很好呈現(xiàn)也方便后續(xù)對(duì)接支付渠道時(shí)保持穩(wěn)定。消費(fèi)接口是最核心的交易接口參數(shù)一般包含 card_no、merchant_id、amount、request_id。request_id 是調(diào)用方生成的請(qǐng)求唯一標(biāo)識(shí)服務(wù)端拿它做冪等。為什么需要 request_id因?yàn)橄M(fèi)終端和服務(wù)器之間是弱網(wǎng)環(huán)境終端超時(shí)后會(huì)自動(dòng)重發(fā)同一筆請(qǐng)求如果沒有冪等機(jī)制同一筆消費(fèi)會(huì)被扣兩次。接口清單可以按下面的表格整理進(jìn)設(shè)計(jì)文檔答辯時(shí)一目了然接口名稱請(qǐng)求方式核心參數(shù)用途開卡POSTaccount_no, card_no創(chuàng)建賬戶并綁卡充值POSTaccount_id, amount, request_id發(fā)起充值交易消費(fèi)POSTcard_no, merchant_id, amount, request_id發(fā)起消費(fèi)交易掛失POSTcard_no卡掛失余額查詢GETaccount_id查賬戶余額流水查詢GETaccount_id, start_time, end_time查交易明細(xì)5.2 扣款交易的核心代碼事務(wù)邊界與行鎖扣款邏輯是所有業(yè)務(wù)里最容易出問題的部分。我給出一個(gè)簡(jiǎn)化但完整的扣款 Service 代碼事務(wù)邊界放在方法上內(nèi)部先做冪等檢查再扣余額再寫流水。這三步必須在一個(gè)事務(wù)里任何一個(gè)環(huán)節(jié)拋異常整體回滾。Transactional(rollbackFor Exception.class) public void consume(ConsumeRequest req) { // 1. 冪等檢查同一請(qǐng)求號(hào)不能處理兩次 IdempotentRecord record idempotentMapper.selectByReqId(req.getRequestId()); if (record ! null) { throw new BizException(10004, 重復(fù)交易請(qǐng)求); } // 2. 查卡校驗(yàn)卡狀態(tài) Card card cardMapper.selectByCardNo(req.getCardNo()); if (card null || card.getStatus() ! CardStatus.NORMAL) { throw new BizException(10002, 卡不可用); } // 3. 扣減余額條件里帶余額判斷防止超扣 int rows accountMapper.deductBalance( card.getAccountId(), req.getAmount(), AccountStatus.NORMAL); if (rows 0) { throw new BizException(10001, 余額不足或賬戶狀態(tài)異常); } // 4. 查扣減后的余額用于寫流水 Account account accountMapper.selectById(card.getAccountId()); // 5. 寫交易流水狀態(tài)先置為成功 TransLog log new TransLog(); log.setTransNo(genTransNo(req.getMerchantId())); log.setAccountId(account.getAccountId()); log.setCardNo(card.getCardNo()); log.setMerchantId(req.getMerchantId()); log.setTransType(TransType.CONSUME); log.setAmount(req.getAmount()); log.setBalanceBefore(account.getAvailableBalance().add(req.getAmount())); log.setBalanceAfter(account.getAvailableBalance()); log.setStatus(TransStatus.SUCCESS); transLogMapper.insert(log); // 6. 寫冪等記錄 idempotentMapper.insert(req.getRequestId(), log.getTransNo()); }這段代碼有四個(gè)參數(shù)和設(shè)計(jì)點(diǎn)需要理解。第一是事務(wù)注解 rollbackFor Exception.class默認(rèn)情況下 RuntimeException 才回滾但業(yè)務(wù)里拋的是 BizException如果 BizException 不是 RuntimeException事務(wù)就不會(huì)回滾余額扣了流水沒寫賬就崩了。如果你繼承了 RuntimeException 可以省略但寫全更穩(wěn)。第二是 deductBalance 的 rows 判斷影響行數(shù)為 0 就說(shuō)明余額不夠或者賬戶狀態(tài)不對(duì)不用再查一次余額。第三是冪等記錄放在事務(wù)最后如果前面失敗冪等記錄也不會(huì)寫入下次重試可以繼續(xù)。第四是 balance_before 由扣款后的余額加回金額推出避免多查一次之前余額但這個(gè)做法要保證扣款后立刻查余額不能有其他事務(wù)插隊(duì)。并發(fā)極高時(shí)這里有個(gè)灰色地帶我一般配合賬戶行鎖使用把這個(gè)「查余額」操作放在 FOR UPDATE 事務(wù)里。上面代碼用條件扣減省了鎖但寫流水前查余額那一步嚴(yán)謹(jǐn)起見要把 accountMapper.selectById 改成 FOR UPDATE 版本。更可靠的方案是扣款 UPDATE 語(yǔ)句里直接返回舊的余額但 MyBatis 原生不支持 UPDATE 返回舊值需要自定義 SQL 或存儲(chǔ)過(guò)程。課程設(shè)計(jì)階段條件扣減加事務(wù)已經(jīng)足夠文檔里可以寫明「此處采用條件更新防超扣如需更強(qiáng)一致性可升級(jí)為行鎖方案」。5.3 MyBatis 參數(shù)綁定與 SQL 注入防護(hù)一卡通系統(tǒng)里所有 SQL 都必須用參數(shù)綁定這是底線。MyBatis 里 #{} 會(huì)生成 PreparedStatement 占位符參數(shù)由驅(qū)動(dòng)轉(zhuǎn)義不會(huì)注入${} 是直接拼接字符串等于把你的參數(shù)原樣塞進(jìn) SQL。曾有一個(gè)翻車案例某系統(tǒng)用 ${} 拼接排序字段前端傳了個(gè)「account_id DESC; DROP TABLE t_account」數(shù)據(jù)庫(kù)直接崩了。我一般對(duì) MyBatis 的 Mapper 文件定三條規(guī)則第一WHERE 條件里的值一律用 #{}第二動(dòng)態(tài)排序的列名和方向必須走白名單校驗(yàn)代碼里先判斷傳進(jìn)來(lái)的列名是否在允許列表里不在列表就拒絕第三LIKE 查詢不能用「LIKE %${keyword}%」這種寫法要改成「LIKE CONCAT(%, #{keyword}, %)」既防注入又能走索引。防注入不是高級(jí)技巧但它就是這類系統(tǒng)的安全底線設(shè)計(jì)文檔里單列一節(jié)會(huì)顯得很專業(yè)。6. 上線前必須做的安全加固與驗(yàn)證從日志到壓測(cè)的收尾功夫6.1 敏感操作日志與審計(jì)交易流水保的是資金賬操作日志保的是管理賬。誰(shuí)在什么時(shí)候給哪個(gè)賬戶充了值、改了狀態(tài)都必須有記錄。我會(huì)在系統(tǒng)里單獨(dú)建一張操作日志表記錄操作人、操作類型、目標(biāo)賬戶、請(qǐng)求參數(shù)、IP 和結(jié)果。后臺(tái)管理員的任何修改操作都寫日志普通用戶只能自助充值不能直接改余額。這個(gè)設(shè)計(jì)成本極低但在真實(shí)場(chǎng)景里能救命。某次系統(tǒng)上線后余額被人為改過(guò)就是靠操作日志定位到哪個(gè)管理員做了什么操作不然這種臟數(shù)據(jù)要排查好幾天。日志表不要和流水表混在一起職責(zé)不同混在一起會(huì)干擾對(duì)賬邏輯。6.2 交易冪等與重放防護(hù)消費(fèi)終端超時(shí)重試是常態(tài)冪等表不能省。實(shí)現(xiàn)方式是接收請(qǐng)求后先查 request_id 是否處理過(guò)處理過(guò)就直接返回上次結(jié)果。表結(jié)構(gòu)很簡(jiǎn)單就三個(gè)字段request_id、trans_no、create_timerequest_id 建唯一索引。這套機(jī)制對(duì)充值、消費(fèi)、退款全部適用。還有一個(gè)容易被忽略的重放場(chǎng)景同一張卡在同一個(gè)終端上同一秒內(nèi)發(fā)來(lái)兩筆參數(shù)完全相同的請(qǐng)求這可能是終端 bug 也可能是攻擊。處理辦法是在冪等檢查之外再比對(duì)卡號(hào)、金額、商戶、時(shí)間窗口4 個(gè)維度都相同就判定為重復(fù)請(qǐng)求。6.3 一個(gè)壓測(cè)驗(yàn)收技巧交付前我會(huì)做一次簡(jiǎn)單的并發(fā)驗(yàn)收不追求壓測(cè)工具的華麗只要驗(yàn)證兩件事余額不超扣、流水不丟失。做法是起一個(gè)本地腳本開 50 個(gè)線程同時(shí)對(duì)一個(gè)賬戶發(fā)起 500 筆金額為 1 元的消費(fèi)請(qǐng)求跑完后檢查該賬戶余額是否等于初始余額減 500流水表里對(duì)應(yīng) trans_no 是否有 500 條成功記錄。這個(gè)驗(yàn)證做完系統(tǒng)的賬務(wù)正確性就有了基本保障。如果只有 499 條流水說(shuō)明并發(fā)丟更新了去檢查事務(wù)和鎖如果余額少了更多說(shuō)明有重復(fù)扣款去檢查冪等。這個(gè)壓測(cè)手法我?guī)н^(guò)幾個(gè)學(xué)生用過(guò)其中 A 同學(xué)用 100 個(gè)線程一跑立刻暴露了超扣問題他在答辯現(xiàn)場(chǎng)展示了修復(fù)前后對(duì)比老師的評(píng)價(jià)是「這個(gè)系統(tǒng)是真的被驗(yàn)證過(guò)」。給自己留一套這樣的驗(yàn)證腳本比在文檔里寫十頁(yè)「系統(tǒng)穩(wěn)定性」都有說(shuō)服力。我做這類系統(tǒng)時(shí)養(yǎng)成了一個(gè)習(xí)慣交付前一晚重放一遍全流程——開卡、充值、消費(fèi)、掛失、解掛、對(duì)賬每次都用同一個(gè)賬戶從頭跑到尾。開發(fā)階段每個(gè)接口單測(cè)都是通的但連起來(lái)跑就會(huì)出現(xiàn)各種意想不到的邊界比如掛失后的卡還能不能查余額凍結(jié)賬戶還能不能充值。這些邊界靠腦補(bǔ)不可靠跑一遍才靠譜。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取