Эх сурвалжийг харах

fix: 一次点击触发多次上传(监听重复绑定 + 并发竞态)

根因:ops.js 的 bindOnce() 每次进入页面都重新绑定按钮监听,且无防重入;
进过 N 次「店铺运营」后点一次上传 = 触发 N 个请求,并发请求都通过了 sha 预检,
于是写出多份记录。

- bindOnce 加 bound 标志,只绑定一次
- doUpload 加 busy 互斥,上传中忽略重复触发
- 服务端在事务内二次校验 sha(SQLite 写事务串行化),命中返回 409,兜住并发
- 清理了此前产生的 8 条无数据重复导入记录(现存 1 条 / 332 行)
liangzhao 2 долоо хоног өмнө
parent
commit
9afc62fcaf

+ 8 - 0
public/ops.js

@@ -606,6 +606,9 @@
       result.innerHTML = '<span class="ops-error">一次最多 30 个文件</span>';
       return;
     }
+    if (state.upload.busy) return;
+    state.upload.busy = true;
+
     const year = Number($('opsYear').value) || new Date().getFullYear();
     const school = selectedSchool();
     const force = !!(forceOverride || ($('opsForce') && $('opsForce').checked));
@@ -664,6 +667,7 @@
       skipCount && !force ? '<br><small>已存在的文件可勾选下方「覆盖已导入的文件」后重新导入</small>' : ''
     }</div>`;
     $('opsUploadStart').disabled = false;
+    state.upload.busy = false;
     if (okCount) {
       state.from = '';
       state.to = '';
@@ -745,7 +749,11 @@
     if (state.summary && state.summary.enabled && matrixCount(state.summary)) await loadRows();
   }
 
+  let bound = false;
+
   function bindOnce() {
+    if (bound) return;
+    bound = true;
     window.bindMultiSelectDismiss();
     const uploadBtn = $('btnOpsUpload');
     if (uploadBtn) uploadBtn.addEventListener('click', openUpload);

+ 17 - 0
server/routes/ops.js

@@ -643,6 +643,12 @@ function register(app, ctx) {
 
       try {
         await store.withTransaction(async (tx) => {
+          // 事务内二次校验:并发同名文件只允许一个写入
+          if (!body.force && await tx.findUploadBySha(sha256)) {
+            const err = new Error('duplicate');
+            err.code = 'OPS_DUPLICATE';
+            throw err;
+          }
           if (body.force) await tx.deletePlatformRowsBySha(sha256);
           await tx.insertUpload({
             id: uploadId,
@@ -661,6 +667,9 @@ function register(app, ctx) {
           }
         });
       } catch (e) {
+        if (e.code === 'OPS_DUPLICATE') {
+          return sendJson(res, 409, { error: '该文件已导入过', uploadId: await store.findUploadBySha(sha256), duplicate: true });
+        }
         return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
       }
 
@@ -705,6 +714,11 @@ function register(app, ctx) {
 
     try {
       await store.withTransaction(async (tx) => {
+        if (!body.force && await tx.findUploadBySha(sha256)) {
+          const err = new Error('duplicate');
+          err.code = 'OPS_DUPLICATE';
+          throw err;
+        }
         if (body.force) await tx.deleteRowsBySha(sha256);
         await tx.insertUpload({
           id: uploadId,
@@ -723,6 +737,9 @@ function register(app, ctx) {
         await tx.pruneOrphanShops();
       });
     } catch (e) {
+      if (e.code === 'OPS_DUPLICATE') {
+        return sendJson(res, 409, { error: '该文件已导入过', uploadId: await store.findUploadBySha(sha256), duplicate: true });
+      }
       return sendJson(res, 500, { error: '写入失败', detail: e.message || String(e) });
     }
 

+ 4 - 0
server/stores/ops-pg.js

@@ -230,6 +230,10 @@ function createPgStore({ connectionString, ssl = true, max = 5, schema = 'public
       async pruneOrphanShops() {
         await client.query('DELETE FROM ops_shop s WHERE NOT EXISTS (SELECT 1 FROM ops_row r WHERE r.shop_key = s.key)');
       },
+      async findUploadBySha(sha256) {
+        const r = await client.query('SELECT id FROM ops_upload WHERE sha256 = $1 ORDER BY uploaded_at DESC LIMIT 1', [sha256]);
+        return r.rows.length ? r.rows[0].id : null;
+      },
       async updateReport(id, report) {
         await client.query('UPDATE ops_upload SET report = $1 WHERE id = $2', [JSON.stringify(report || {}), id]);
       },

+ 1 - 1
server/stores/ops-sqlite.js

@@ -275,7 +275,7 @@ function createSqliteStore({ file }) {
     const handle = open();
     handle.exec('BEGIN IMMEDIATE');
     try {
-      const result = await fn({ insertUpload, upsertRows, upsertShops, upsertPlatformRows, deleteRowsByUpload, deleteRowsBySha, deletePlatformRowsByUpload, deletePlatformRowsBySha, pruneOrphanShops, updateReport });
+      const result = await fn({ insertUpload, upsertRows, upsertShops, upsertPlatformRows, deleteRowsByUpload, deleteRowsBySha, deletePlatformRowsByUpload, deletePlatformRowsBySha, pruneOrphanShops, updateReport, findUploadBySha });
       handle.exec('COMMIT');
       // 写事务结束后做一次 checkpoint:让 data/ops.db 自包含,备份时不必关心 -wal
       try { handle.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch { /* 有读事务时跳过 */ }