diff --git a/gas/Code.gs b/gas/Code.gs index cc44979..71f9934 100644 --- a/gas/Code.gs +++ b/gas/Code.gs @@ -463,6 +463,8 @@ function _verifyRecaptcha(token, expectedAction) { * 已加入頻率限制:依 clientId 每人每分鐘最多 5 次,全體使用者每分鐘總計最多 20 次 * 已加入 reCAPTCHA v3 驗證:防止 GAS_URL 外洩後遭腳本大量濫用送出報修單 * 已加入後端格式驗證:比照前端 report.js 的驗證規則 + * 已加入去重防護(BUG-DUP-01):120 秒內相同「學號+房號+問題描述」的重複請求 + * 將直接回傳 DUPLICATE_REPORT,防止使用者或腳本在 2 分鐘內重複送出相同報修單 * * @param {object} reportData - 報修資料物件 * @param {string} [clientId] - 前端產生的裝置識別碼(非個資),用於依使用者區分限流 @@ -521,6 +523,22 @@ function writeReport(reportData, clientId, recaptchaToken) { return { success: false, error: 'VALIDATION_DESCRIPTION_REQUIRED' }; } + // ── 去重防護(BUG-DUP-01):120 秒內相同「學號 + 房號 + 描述前 50 字」禁止重複送出 ── + // 目的:防止使用者在送出後不小心(或惡意)在 2 分鐘內重複送出完全相同的報修單。 + // 做法:以學號、房號、描述前 50 字組成字串,使用 MD5 雜湊後轉為 Base64WebSafe 作為 CacheService key, + // 首次成功寫入後存入快取 120 秒;快取命中則直接拒絕。 + // 注意:此檢查在格式驗證通過後、寫入試算表前執行; + // 寫入成功後才真正寫入快取,避免驗證失敗的請求佔用去重配額。 + const _dupCache = CacheService.getScriptCache(); + const _dupRaw = `${studentId}_${roomNumber}_${description.substring(0, 50)}`; + const _dupBytes = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, _dupRaw); + const _dupKey = 'dedup_' + Utilities.base64EncodeWebSafe(_dupBytes); + + if (_dupCache.get(_dupKey)) { + Logger.log(`[writeReport] 攔截重複報修,指紋命中:${_dupKey}`); + return { success: false, error: 'DUPLICATE_REPORT' }; + } + const spreadsheet = SpreadsheetApp.openById(_getSpreadsheetId()); const sheet = spreadsheet.getSheetByName(SHEET_NAME); @@ -583,6 +601,9 @@ function writeReport(reportData, clientId, recaptchaToken) { targetRange.setNumberFormat('@'); // ① 先設成純文字格式 targetRange.setValues([rowValues]); // ② 再寫入(取代 appendRow) + // 寫入成功後,將指紋存入快取 120 秒(2 分鐘),防止重複送出 + _dupCache.put(_dupKey, '1', 120); + Logger.log(`[writeReport] 新增報修:${reportData.name} ${reportData.roomNumber}-${reportData.bedNumber}`); return { success: true, message: '報修資料已成功寫入試算表' }; diff --git a/js/config.js b/js/config.js index 8529bf9..ddd4c83 100644 --- a/js/config.js +++ b/js/config.js @@ -89,7 +89,8 @@ const CONFIG = Object.freeze({ REPAIR_TIME_RANGE: '可維修時間必須在 0–23 時與 0–59 分之間', DESCRIPTION_REQUIRED: '請描述您的網路問題', QUERY_STUDENT_ID_REQUIRED: '請輸入學號', - QUERY_STUDENT_ID_FORMAT: '學號格式錯誤(需為 1 位英文字母 + 7 位數字,例如:D1234567)' + QUERY_STUDENT_ID_FORMAT: '學號格式錯誤(需為 1 位英文字母 + 7 位數字,例如:D1234567)', + DUPLICATE_REPORT: '您剛剛已送出過相同的報修單,請勿在 2 分鐘內重複送出。若確認報修已登錄,可使用「查詢案件」功能查看。' }) }), en: Object.freeze({ @@ -164,7 +165,8 @@ const CONFIG = Object.freeze({ REPAIR_TIME_RANGE: 'Available time must be between 0-23 hours and 0-59 minutes', DESCRIPTION_REQUIRED: 'Please describe your network issue', QUERY_STUDENT_ID_REQUIRED: 'Please enter your Student ID', - QUERY_STUDENT_ID_FORMAT: 'Invalid Student ID (Format: 1 letter + 7 digits, e.g. D1234567)' + QUERY_STUDENT_ID_FORMAT: 'Invalid Student ID (Format: 1 letter + 7 digits, e.g. D1234567)', + DUPLICATE_REPORT: 'You just submitted an identical repair request. Please do not resubmit within 2 minutes. To confirm your request was recorded, use the “Check My Cases” feature.' }) }) }), diff --git a/js/report.js b/js/report.js index 9d4e334..7657dbd 100644 --- a/js/report.js +++ b/js/report.js @@ -169,6 +169,10 @@ const ReportForm = (() => { const _friendlyError = (err) => { if (!_isInternalCode(err)) return err || _R().REPORT_ERROR; if (err === 'RATE_LIMITED') return _R().SYSTEM_ERROR; + // DUPLICATE_REPORT:去重防護觸發,屬使用者操作錯誤而非系統錯誤,顯示友善提示 + if (err === 'DUPLICATE_REPORT' && _R().VALIDATION.DUPLICATE_REPORT) { + return _R().VALIDATION.DUPLICATE_REPORT; + } if (err.startsWith('VALIDATION_')) { const key = err.replace('VALIDATION_', ''); if (_R().VALIDATION[key]) return _R().VALIDATION[key]; diff --git a/test/gas-code.test.js b/test/gas-code.test.js index e1c8b38..08337cb 100644 --- a/test/gas-code.test.js +++ b/test/gas-code.test.js @@ -322,6 +322,47 @@ test('writeReport:超過個人頻率限制時直接拒絕(不觸發 reCAPTCH } }); +// ══════════════════════════════════════════════ +// 3b. 去重防護(BUG-DUP-01)迴歸測試 +// ══════════════════════════════════════════════ +test('writeReport:120 秒內相同內容重複送出應回傳 DUPLICATE_REPORT(BUG-DUP-01 迴歸測試)', () => { + const { exported, mocks, restore } = loadGasCode({ + scriptProperties: { SPREADSHEET_ID: 'sheet-id', RECAPTCHA_SECRET_KEY: 'secret' } + }); + try { + mockPassingRecaptcha(mocks); + // 第一次:應該成功 + const first = exported.writeReport(validReportPayload(), 'user-dup', 'token-1'); + assert.equal(first.success, true, '第一次送出應成功'); + + // 第二次送出完全相同的內容(不同 clientId 也應被攔截,去重依內容不依使用者) + const second = exported.writeReport(validReportPayload(), 'user-dup2', 'token-2'); + assert.equal(second.success, false, '相同內容第二次應被拒絕'); + assert.match(second.error, /DUPLICATE_REPORT/, '錯誤碼應為 DUPLICATE_REPORT'); + } finally { + restore(); + } +}); + +test('writeReport:描述不同時不視為重複,應允許送出', () => { + const { exported, mocks, restore } = loadGasCode({ + scriptProperties: { SPREADSHEET_ID: 'sheet-id', RECAPTCHA_SECRET_KEY: 'secret' } + }); + try { + mockPassingRecaptcha(mocks); + const first = exported.writeReport(validReportPayload(), 'user-diff-1', 'token-a'); + assert.equal(first.success, true, '第一次送出應成功'); + + // 描述不同 → 不視為重複 + const diffPayload = { ...validReportPayload(), description: '不同的問題描述' }; + const second = exported.writeReport(diffPayload, 'user-diff-2', 'token-b'); + assert.equal(second.success, true, '描述不同的報修應允許通過'); + } finally { + restore(); + } +}); + + // ══════════════════════════════════════════════ // 4. classifyIntent:頻率限制 + 未設定 API Key 時的行為 // ══════════════════════════════════════════════ diff --git a/test/gas-mocks.js b/test/gas-mocks.js index 79068bb..295f811 100644 --- a/test/gas-mocks.js +++ b/test/gas-mocks.js @@ -50,7 +50,10 @@ function createGasMocks(overrides = {}) { const Utilities = { getUuid: () => crypto.randomUUID(), sleep: () => {}, // 測試中不需要真的等待 - formatDate: (date) => date.toISOString() + formatDate: (date) => date.toISOString(), + computeDigest: (algo, val) => crypto.createHash('md5').update(String(val)).digest(), + DigestAlgorithm: { MD5: 'MD5' }, + base64EncodeWebSafe: (bytes) => Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') }; const ContentService = {