報(bào)名系統(tǒng):數(shù)據(jù)模型、并發(fā)控制與避坑指南)
簡(jiǎn)介這是一套面向微信小程序初學(xué)者與前端開(kāi)發(fā)者的在線活動(dòng)報(bào)名系統(tǒng)源碼基于微信小程序平臺(tái)實(shí)現(xiàn)可幫助讀者快速理解并搭建一套完整的線上報(bào)名流程。系統(tǒng)覆蓋活動(dòng)展示與分類(lèi)、報(bào)名信息填寫(xiě)、微信支付、報(bào)名狀態(tài)追蹤、消息推送以及后臺(tái)活動(dòng)與用戶管理等功能并借助微信開(kāi)放接口完成授權(quán)、支付與通知的生態(tài)融合。資源包共74個(gè)文件以38個(gè)png圖片、13個(gè)js腳本、11個(gè)wxss樣式、10個(gè)wxml頁(yè)面和2個(gè)json配置為主壓縮包約81KB目錄按頁(yè)面組件、工具函數(shù)、數(shù)據(jù)模型與接口服務(wù)等模塊劃分結(jié)構(gòu)清晰便于二次定制。目前已有899人學(xué)習(xí)下載。讀者可從中獲得完整的小程序項(xiàng)目骨架、頁(yè)面與樣式組織方式、報(bào)名與支付邏輯的實(shí)現(xiàn)思路以及WXML、WXSS與JavaScript配合開(kāi)發(fā)的實(shí)踐參考適合作為課程設(shè)計(jì)或練手項(xiàng)目的起步模板。1. 從一張報(bào)名表到一套系統(tǒng)在線活動(dòng)報(bào)名到底在解決什么做過(guò)線下活動(dòng)的人都有體會(huì)報(bào)名這件事人少的時(shí)候一張表格就夠了人一多就全亂套。名額超了沒(méi)人攔、重復(fù)提交分不清、簽到表和報(bào)名表對(duì)不上、活動(dòng)結(jié)束想導(dǎo)出名單還得手動(dòng)復(fù)制粘貼。微信小程序-在線活動(dòng)報(bào)名系統(tǒng)要解決的就是把「填表—審核—通知—簽到—統(tǒng)計(jì)」這條鏈路收進(jìn)一個(gè)入口讓參與者點(diǎn)幾下就完成讓組織者在后臺(tái)一眼看清全局。它適合誰(shuí)一是經(jīng)常辦沙龍、培訓(xùn)、社群活動(dòng)的運(yùn)營(yíng)同學(xué)二是需要給內(nèi)部活動(dòng)做報(bào)名登記的行政或技術(shù)同學(xué)三是想練手一個(gè)完整小程序全棧項(xiàng)目的開(kāi)發(fā)者。核心訴求其實(shí)就三件事名額不能超、數(shù)據(jù)不能亂、通知不能漏。下面按「先想清楚數(shù)據(jù)怎么設(shè)計(jì)再動(dòng)手把前后端跑通最后把坑填上」的順序講能照著復(fù)現(xiàn)。2. 先把數(shù)據(jù)模型定下來(lái)報(bào)名系統(tǒng)最容易返工的地方很多人一上來(lái)就寫(xiě)頁(yè)面結(jié)果做到一半發(fā)現(xiàn)「一個(gè)用戶能不能報(bào)多個(gè)場(chǎng)次」「取消后名額要不要放回去」這些沒(méi)想清楚只能推倒重來(lái)。數(shù)據(jù)模型是這套系統(tǒng)的地基地基歪了后面全是補(bǔ)丁。2.1 四張核心表與字段設(shè)計(jì)一個(gè)夠用的報(bào)名系統(tǒng)最少需要四張表活動(dòng)表、報(bào)名表、用戶表、簽到表。用戶表通常直接用微信的 openid 做唯一標(biāo)識(shí)不用自己維護(hù)賬號(hào)體系。下面是我一般會(huì)用的結(jié)構(gòu)字段名按常見(jiàn)習(xí)慣來(lái)你可以按自己后端框架調(diào)整。-- 活動(dòng)表一場(chǎng)活動(dòng)一條記錄 CREATE TABLE activity ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, -- 活動(dòng)標(biāo)題 cover VARCHAR(255) DEFAULT , -- 封面圖地址 start_time DATETIME NOT NULL, -- 活動(dòng)開(kāi)始時(shí)間 end_time DATETIME NOT NULL, -- 活動(dòng)結(jié)束時(shí)間 sign_deadline DATETIME NOT NULL, -- 報(bào)名截止時(shí)間 quota INT NOT NULL DEFAULT 0,-- 總名額0 表示不限 joined INT NOT NULL DEFAULT 0,-- 已報(bào)名人數(shù)冗余字段 status TINYINT NOT NULL DEFAULT 1 -- 1上架 0下架 ); -- 報(bào)名表一個(gè)用戶對(duì)一場(chǎng)活動(dòng)最多一條有效記錄 CREATE TABLE enrollment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, activity_id BIGINT NOT NULL, openid VARCHAR(64) NOT NULL, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, extra JSON DEFAULT NULL, -- 自定義表單字段 state TINYINT NOT NULL DEFAULT 1, -- 1已報(bào)名 2已取消 3已簽到 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_act_openid (activity_id, openid) );活動(dòng)表里的joined是個(gè)冗余計(jì)數(shù)字段專門(mén)用來(lái)做名額判斷避免每次都去 count 報(bào)名表。報(bào)名表的uk_act_openid唯一索引是關(guān)鍵它從數(shù)據(jù)庫(kù)層面擋住了同一個(gè)人重復(fù)報(bào)名比在代碼里查一遍再插入可靠得多。extra用 JSON 存自定義字段是因?yàn)椴煌顒?dòng)的報(bào)名表單往往不一樣硬編碼成列以后加字段就得改表。2.2 名額扣減為什么必須放在事務(wù)里名額判斷和扣減如果分成兩步寫(xiě)高并發(fā)下一定出事。兩個(gè)請(qǐng)求同時(shí)讀到「還剩 1 個(gè)名額」然后都去插入報(bào)名記錄結(jié)果就是超賣(mài)。正確做法是把「判斷名額 扣減 插入報(bào)名」放進(jìn)一個(gè)事務(wù)并且用帶條件的更新語(yǔ)句來(lái)兜底。START TRANSACTION; -- 條件更新只有 joined quota 時(shí)才會(huì)真正扣減 UPDATE activity SET joined joined 1 WHERE id ? AND (quota 0 OR joined quota); -- 檢查上一步是否真的影響了一行沒(méi)影響說(shuō)明名額已滿 -- 應(yīng)用層判斷 affectedRows 1 才繼續(xù)否則 ROLLBACK INSERT INTO enrollment (activity_id, openid, name, phone) VALUES (?, ?, ?, ?); COMMIT;這里quota 0表示不限名額所以條件寫(xiě)成quota 0 OR joined quota。應(yīng)用層拿到affectedRows后判斷等于 1 說(shuō)明搶到名額繼續(xù)插入報(bào)名記錄等于 0 說(shuō)明名額已滿直接回滾并返回「名額已滿」。這套寫(xiě)法把并發(fā)控制交給了數(shù)據(jù)庫(kù)的行鎖比在應(yīng)用層加鎖簡(jiǎn)單也不容易出錯(cuò)。2.3 取消報(bào)名后名額怎么放回去取消報(bào)名不是把記錄刪掉而是把state改成 2同時(shí)把活動(dòng)的joined減 1。這里有個(gè)容易忽略的點(diǎn)減 1 的時(shí)候要防止減成負(fù)數(shù)所以條件里加joined 0。另外如果活動(dòng)已經(jīng)截止報(bào)名或已開(kāi)始就不該允許取消這個(gè)判斷放在業(yè)務(wù)層做。START TRANSACTION; UPDATE enrollment SET state 2 WHERE activity_id ? AND openid ? AND state 1; -- 只有確實(shí)改動(dòng)了報(bào)名狀態(tài)才回退名額 UPDATE activity SET joined joined - 1 WHERE id ? AND joined 0; COMMIT;兩條更新都放在事務(wù)里保證「報(bào)名狀態(tài)」和「名額計(jì)數(shù)」要么一起成功要么一起失敗。如果只改了一個(gè)數(shù)據(jù)就對(duì)不上了后面統(tǒng)計(jì)會(huì)很難查。3. 小程序端怎么把報(bào)名流程跑通數(shù)據(jù)模型定好之后前端就輕松了。小程序端要做的事其實(shí)就三件展示活動(dòng)詳情、收集報(bào)名信息、提交并處理結(jié)果。難點(diǎn)不在頁(yè)面而在提交時(shí)的狀態(tài)處理和用戶提示。3.1 活動(dòng)詳情頁(yè)與報(bào)名按鈕的狀態(tài)機(jī)報(bào)名按鈕不是簡(jiǎn)單的「能點(diǎn)/不能點(diǎn)」它至少有五種狀態(tài)未開(kāi)始、可報(bào)名、名額已滿、已截止、已報(bào)名。這五種狀態(tài)要在頁(yè)面加載時(shí)根據(jù)活動(dòng)數(shù)據(jù)算出來(lái)而不是等用戶點(diǎn)了才判斷。// 根據(jù)活動(dòng)數(shù)據(jù)和當(dāng)前用戶報(bào)名狀態(tài)計(jì)算按鈕狀態(tài) function getButtonState(activity, myEnrollment) { const now Date.now(); const deadline new Date(activity.sign_deadline).getTime(); const start new Date(activity.start_time).getTime(); if (myEnrollment myEnrollment.state 1) { return { text: 已報(bào)名, disabled: true, action: none }; } if (now deadline || now start) { return { text: 報(bào)名已截止, disabled: true, action: none }; } if (activity.quota 0 activity.joined activity.quota) { return { text: 名額已滿, disabled: true, action: none }; } return { text: 立即報(bào)名, disabled: false, action: enroll }; }這個(gè)函數(shù)把判斷邏輯集中在一處頁(yè)面里只負(fù)責(zé)渲染。注意判斷順序先看是否已報(bào)名再看是否截止最后看名額。順序反了會(huì)出現(xiàn)「已報(bào)名但顯示名額已滿」這種尷尬情況。disabled控制按鈕是否可點(diǎn)action用來(lái)區(qū)分點(diǎn)擊后走哪個(gè)分支。3.2 提交報(bào)名的請(qǐng)求封裝與錯(cuò)誤處理提交報(bào)名時(shí)前端要做兩件事一是防止用戶連點(diǎn)二是把后端的業(yè)務(wù)錯(cuò)誤翻譯成人話。連點(diǎn)問(wèn)題用一個(gè)submitting標(biāo)志位就能解決錯(cuò)誤處理則要區(qū)分「網(wǎng)絡(luò)錯(cuò)誤」和「業(yè)務(wù)錯(cuò)誤」。async function submitEnrollment(activityId, formData) { if (this.submitting) return; // 防連點(diǎn) this.submitting true; try { const res await wx.request({ url: ${BASE_URL}/enrollment, method: POST, data: { activityId, ...formData }, header: { content-type: application/json } }); // 后端約定 code0 為成功其余為業(yè)務(wù)錯(cuò)誤 if (res.data.code 0) { wx.showToast({ title: 報(bào)名成功, icon: success }); return true; } // 業(yè)務(wù)錯(cuò)誤直接展示后端返回的提示 wx.showToast({ title: res.data.msg || 報(bào)名失敗, icon: none }); return false; } catch (e) { // 網(wǎng)絡(luò)層錯(cuò)誤提示重試 wx.showToast({ title: 網(wǎng)絡(luò)異常請(qǐng)重試, icon: none }); return false; } finally { this.submitting false; // 無(wú)論成敗都釋放 } }submitting標(biāo)志位放在finally里釋放保證請(qǐng)求失敗時(shí)也能再次點(diǎn)擊。業(yè)務(wù)錯(cuò)誤和網(wǎng)絡(luò)錯(cuò)誤分開(kāi)處理是因?yàn)榍罢咧卦嚊](méi)用比如名額已滿后者重試可能成功。后端返回的msg直接展示省得前端再維護(hù)一套錯(cuò)誤碼文案。3.3 報(bào)名成功后的通知怎么發(fā)報(bào)名成功后要通知用戶常見(jiàn)做法是用小程序的訂閱消息。這里有個(gè)坑訂閱消息必須由用戶主動(dòng)觸發(fā)授權(quán)不能后臺(tái)偷偷發(fā)。所以流程是「用戶點(diǎn)報(bào)名 → 彈出授權(quán) → 用戶同意 → 后端記錄授權(quán) → 活動(dòng)前發(fā)通知」。// 報(bào)名成功后請(qǐng)求訂閱授權(quán) wx.requestSubscribeMessage({ tmplIds: [活動(dòng)開(kāi)始提醒的模板ID], success(res) { // res[模板ID] accept 表示用戶同意 if (res[活動(dòng)開(kāi)始提醒的模板ID] accept) { // 把授權(quán)結(jié)果告訴后端后端在活動(dòng)前調(diào)用發(fā)送接口 reportSubscribe(activityId); } } });模板 ID 要在小程序后臺(tái)申請(qǐng)一次授權(quán)只能發(fā)一條所以如果活動(dòng)要發(fā)多條通知得在多個(gè)觸發(fā)點(diǎn)分別請(qǐng)求授權(quán)。這個(gè)限制很多人第一次做會(huì)踩以為授權(quán)一次就能一直發(fā)。4. 避坑與排查報(bào)名系統(tǒng)上線后最容易翻車(chē)的五個(gè)點(diǎn)前面講的是正常流程但真正讓人頭疼的是上線后的各種意外。下面這五條是我踩過(guò)或見(jiàn)別人踩過(guò)的按「現(xiàn)象 → 原因 → 解決」寫(xiě)遇到問(wèn)題可以對(duì)著查。4.1 名額顯示還剩 1 個(gè)點(diǎn)進(jìn)去卻報(bào)「名額已滿」現(xiàn)象是列表頁(yè)顯示還有名額點(diǎn)進(jìn)詳情提交卻提示已滿。原因是列表頁(yè)的名額是緩存或延遲數(shù)據(jù)而提交時(shí)是實(shí)時(shí)判斷。解決方法是列表頁(yè)的名額只做展示參考詳情頁(yè)在提交前再拉一次最新數(shù)據(jù)或者干脆在按鈕上不顯示精確剩余數(shù)只顯示「名額充足/緊張」。更徹底的做法是提交接口返回明確的錯(cuò)誤碼前端據(jù)此刷新頁(yè)面數(shù)據(jù)。4.2 用戶取消后重新報(bào)名提示「已報(bào)名」現(xiàn)象是用戶取消報(bào)名后想重新報(bào)系統(tǒng)卻說(shuō)他已報(bào)名。原因是報(bào)名表的唯一索引uk_act_openid還在取消只是改了state記錄沒(méi)刪。解決方法是重新報(bào)名時(shí)不要插入新記錄而是把原來(lái)state2的記錄更新回state1同時(shí)重新扣減名額。更新前要判斷這條記錄確實(shí)存在且狀態(tài)是已取消。4.3 活動(dòng)結(jié)束后簽到發(fā)現(xiàn)有人沒(méi)報(bào)名卻來(lái)簽到現(xiàn)象是簽到名單里混進(jìn)了沒(méi)報(bào)名的人。原因是簽到接口只校驗(yàn)了活動(dòng) ID沒(méi)校驗(yàn)這個(gè)人是否真的報(bào)過(guò)名。解決方法是簽到前先查報(bào)名表確認(rèn)state是 1 或 3 才允許簽到否則提示「未報(bào)名」。如果允許現(xiàn)場(chǎng)補(bǔ)報(bào)那就走補(bǔ)報(bào)流程先報(bào)名再簽到不要繞過(guò)校驗(yàn)。4.4 并發(fā)提交時(shí)數(shù)據(jù)庫(kù)報(bào)唯一鍵沖突現(xiàn)象是高并發(fā)下偶爾報(bào)Duplicate entry錯(cuò)誤。原因是兩個(gè)請(qǐng)求同時(shí)通過(guò)了「查是否已報(bào)名」的檢查然后都去插入。解決方法是不要依賴應(yīng)用層的查詢判斷直接靠唯一索引兜底捕獲唯一鍵沖突異常后返回「請(qǐng)勿重復(fù)報(bào)名」。這樣即使并發(fā)也能保證數(shù)據(jù)正確只是錯(cuò)誤提示要友好。4.5 訂閱消息發(fā)送失敗提示「用戶未訂閱」現(xiàn)象是活動(dòng)前發(fā)通知部分用戶收不到。原因是用戶當(dāng)初沒(méi)授權(quán)或者授權(quán)次數(shù)已用完。解決方法是發(fā)送前先查授權(quán)記錄沒(méi)授權(quán)的跳過(guò)同時(shí)在報(bào)名成功頁(yè)明確告訴用戶「訂閱后才能收到開(kāi)始提醒」提高授權(quán)率。發(fā)送失敗不要重試太多次避免觸發(fā)頻率限制。5. 把統(tǒng)計(jì)和導(dǎo)出做扎實(shí)活動(dòng)結(jié)束后的收尾技巧活動(dòng)辦完組織者最需要的是兩樣?xùn)|西一份能直接用的簽到名單一份能看的數(shù)據(jù)概覽。這兩件事看著簡(jiǎn)單做不好會(huì)讓人加班到半夜。先說(shuō)導(dǎo)出。常見(jiàn)做法是后端生成 CSV 文件前端用wx.downloadFile下載后轉(zhuǎn)發(fā)或保存。CSV 要注意兩點(diǎn)一是中文用 UTF-8 帶 BOM否則 Excel 打開(kāi)亂碼二是字段里如果有逗號(hào)或換行要用雙引號(hào)包起來(lái)。下面是一個(gè)生成 CSV 行的函數(shù)處理了轉(zhuǎn)義。def csv_row(values): cells [] for v in values: s str(v) # 含逗號(hào)、引號(hào)、換行時(shí)用雙引號(hào)包裹內(nèi)部引號(hào)翻倍 if any(c in s for c in [,, , \n]): s s.replace(, ) cells.append(s) return ,.join(cells) # 寫(xiě)入時(shí)加 BOMExcel 才能正確識(shí)別 UTF-8 with open(enrollments.csv, w, encodingutf-8-sig, newline) as f: f.write(csv_row([姓名, 手機(jī)號(hào), 報(bào)名時(shí)間, 簽到狀態(tài)]) \n) for e in enrollments: f.write(csv_row([e.name, e.phone, e.created_at, e.state]) \n)encodingutf-8-sig是關(guān)鍵它會(huì)在文件開(kāi)頭寫(xiě)入 BOMExcel 打開(kāi)中文就不亂碼了。newline是為了避免 Windows 下多出空行。轉(zhuǎn)義邏輯里只要字段含逗號(hào)、引號(hào)或換行就整體加引號(hào)內(nèi)部引號(hào)翻倍這是 CSV 的標(biāo)準(zhǔn)做法。再說(shuō)統(tǒng)計(jì)。報(bào)名系統(tǒng)最??吹闹笜?biāo)是報(bào)名率、簽到率、取消率。報(bào)名率等于報(bào)名人數(shù)除以名額簽到率等于簽到人數(shù)除以報(bào)名人數(shù)取消率等于取消人數(shù)除以報(bào)名總數(shù)。這三個(gè)數(shù)在活動(dòng)列表頁(yè)用進(jìn)度條展示組織者一眼就能看出哪場(chǎng)活動(dòng)熱度高、哪場(chǎng)簽到差。統(tǒng)計(jì)不要實(shí)時(shí)算用定時(shí)任務(wù)每小時(shí)匯總一次避免每次打開(kāi)頁(yè)面都掃全表。最后說(shuō)一個(gè)我自己的習(xí)慣每次活動(dòng)結(jié)束后把報(bào)名數(shù)據(jù)和簽到數(shù)據(jù)做一次對(duì)賬找出「報(bào)名了沒(méi)簽到」和「簽到了沒(méi)報(bào)名」兩類(lèi)人。前者可以用來(lái)做后續(xù)觸達(dá)后者往往是現(xiàn)場(chǎng)補(bǔ)報(bào)沒(méi)走流程說(shuō)明流程有漏洞。這個(gè)對(duì)賬腳本我一般寫(xiě)成一次性任務(wù)跑完把結(jié)果發(fā)到工作群比事后翻記錄高效得多。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取