Просмотр исходного кода

fix(frontend): 活动重复报名时业务错误应展示友好提示而非“网络异常”

后端对重复报名已返回明确业务错误(Result.error 「已报名: xxx」/「您已报名此活动」,HTTP 200 + body code),但 utils/api.js 的 request() 对 body code != 200 均会 reject,而 activity-detail.vue 的 doCreateOrder/onCancelRegistration 在 .catch 中一律 toast「网络异常」,导致业务提示被误覆盖。

新增 showApiError() 辅助方法:err 携带 code != 200 且有 message 时按业务错误弹 modal 展示后端提示;仅真实网络失败仍显示「网络异常」。首次报名(isRegistered=false)路径不走 catch,不受影响。未改动后端代码、未触及 BASIC 会员配额文件、未手改 dist/unpackage。
iwt 2 недель назад
Родитель
Сommit
94e0939493

+ 20 - 4
cfc-frontend/pages/activity/activity-detail/activity-detail.vue

@@ -597,6 +597,16 @@ export default {
       self.registrants = []
       self.loadFamilyMembers()
     },
+    // 区分「后端业务错误」与「真实网络错误」:
+    // utils/api.js 的 request() 对任何 Result.code != 200(含业务错误如"您已报名此活动")都会 reject,
+    // 若 catch 一律显示"网络异常",会把业务提示误当成网络故障。此处业务错误返回 true 并弹友好提示。
+    showApiError: function(err) {
+      if (err && err.code !== undefined && err.code !== 200 && err.message) {
+        uni.showModal({ title: '提示', content: err.message, showCancel: false })
+        return true
+      }
+      return false
+    },
     onCancelRegistration: function() {
       var self = this
       // 未登录则跳转登录
@@ -620,9 +630,12 @@ export default {
               } else {
                 uni.showToast({ title: (res && res.message) || '取消失败', icon: 'none' })
               }
-            }).catch(function() {
+            }).catch(function(err) {
               self.registrationLoading = false
-              uni.showToast({ title: '网络异常', icon: 'none' })
+              // 后端业务错误(如退款失败返回"退款处理失败,请联系客服")走 showApiError 弹友好提示
+              if (!self.showApiError(err)) {
+                uni.showToast({ title: '网络异常', icon: 'none' })
+              }
             })
           }
         }
@@ -951,9 +964,12 @@ export default {
         } else {
           uni.showToast({ title: (res && res.message) || '报名失败', icon: 'none' })
         }
-      }).catch(function() {
+      }).catch(function(err) {
         self.regSubmitting = false
-        uni.showToast({ title: '网络异常', icon: 'none' })
+        // 后端业务错误(如重复报名返回"已报名: xxx")走 showApiError 弹友好提示,只有真实网络错误才显示"网络异常"
+        if (!self.showApiError(err)) {
+          uni.showToast({ title: '网络异常', icon: 'none' })
+        }
       })
     }
   },

+ 98 - 0
docs/superpowers/plans/verify/fix-duplicate-signup.md

@@ -0,0 +1,98 @@
+# 修复报告:「活动重复报名时报网络异常」
+
+> 复核对象:cfc-backend / cfc-frontend  
+> 问题:用户对同一活动已报名后再次点击报名时,前端提示“网络异常”,而非友好的“您已报名/已报名:xxx”。  
+> 约束:只改报名链路相关文件;不动 BASIC 会员配额文件(BasicQuotaService 等);不手改 dist/unpackage;禁止手动提交由 CI 完成。
+
+---
+
+## 1. 日志定位说明
+
+本机无可访问的后端应用日志(`cfc-backend/logs/` 为空、无 docker 容器/ compose 日志可探活),故**无法摘录运行时堆栈原文**。已按任务兜底,转为对报名链路的静态代码分析,关键证据摘录见“3. 接口约定与证据摘录”。
+
+---
+
+## 2. 根因分析
+
+### 2.1 后端:已报名时已返回明确业务结果,不抛异常也未返 HTTP 500
+
+重复报名在正常请求路径下不会抛异常,后端三条报名入口均在**插入前做预检并返回业务错误**:
+
+- `ActivityRegistrationService.register()`(单人报名):`if (isRegistered(activityId, memberId)) return Result.error("您已报名此活动");`
+- `ActivityRegistrationService.batchRegister()`(批量报名):`if (isRegistered(activityId, memberId)) { return Result.error("已报名: " + name); }`
+- `ActivityOrderService.create()`(订单/支付报名):`if (memberId != null && isRegistered(activityId, memberId)) { return Result.error("已报名: " + name); }`
+
+`Result.error(String)` 返回 HTTP 200,body 中 `code=500`(业务错误码,非 HTTP 状态 500,见 `common/Result.java`)。`Result.error(Integer code, String message)` 可指定 code。并发极端情况下的唯一键冲突(`activity_registrations` 存在 `UNIQUE KEY uk_activity_child (activity_id, child_id)`)由 `GlobalExceptionHandler.handleDataIntegrityViolation` 拦截,返回 `Result.error(400, …)`,不会直接暴露 HTTP 500。
+
+**结论:后端本身对“已报名”已经返回了明确的业务结果,并未抛异常或返 500。** 问题出在前端对该结果的处理。
+
+### 2.2 前端:业务错误被 `request()` 封装 reject,页面 catch 一律当成“网络异常”
+
+`cfc-frontend/utils/api.js` 的 `request()`(第 319-349 行):
+- HTTP 响应成功但 body `res.data.code !== 200`(含业务错误 code=500/400)时,**reject**  promise(第 328 行);在 reject 前会 `uni.showToast({title: res.data.message})`(第 324-327 行)。
+- 真实网络失败(`fail` 回调)也 reject 并显示“网络请求失败”。
+
+`activity-detail.vue` 的 `doCreateOrder()`(报名入口,第 931-959 行)仅在 `.then` 中判断 `res.code === 200`;**所有业务错误都走 `.catch()`**,而 `.catch()` 原代码一律 `uni.showToast({ title: '网络异常', icon: 'none' })`。于是:
+1. 后端返回 `Result.error("已报名: xxx")` → `request()` reject;
+2. 页面 `.catch()` 显示“网络异常”(后显示的 toast 覆盖前一条业务提示),用户最终只看到“网络异常”。
+
+`.then` 的 `else` 分支(会显示 `res.message`)对业务错误是**不可达代码**,因为 promise 已被 reject。
+
+同问题存在于 `onCancelRegistration()`(取消报名,第 619-625 行)。
+
+### 2.3 为什么报名按钮还会显示(用户能点两次)
+
+`ActivityService.detail()` 的 `registrationStatus` 按 **login `userId`** 计算(`getUserRegistrationStatus`),而提交时重复检查 `isRegistered(activityId, memberId)` 按 `memberId`/子成员查。跨账号/视角切换场景下,详情页返回 `not_registered`(按钮可见),提交时 `isRegistered` 命中同一成员的已通过记录,后端返回业务错误,再走上文前端 catch → “网络异常”。
+
+---
+
+## 3. 接口约定与证据摘录(代码原文)
+
+| 位置 | 代码原文(摘录) |
+|------|-----------------|
+| `ActivityRegistrationService.java:98` | `if (isRegistered(activityId, memberId)) return Result.error("您已报名此活动");` |
+| `ActivityRegistrationService.java:60-62` | `if (isRegistered(activityId, memberId)) { return Result.error("已报名: " + name); }` |
+| `ActivityOrderService.java:78-79` | `if (memberId != null && isRegistered(activityId, memberId)) { return Result.error("已报名: " + name); }` |
+| `common/Result.java:37-42` | `Result.error(String)`:`result.setCode(500); result.setMessage(message);`(HTTP 200,code 为业务码) |
+| `config/GlobalExceptionHandler.java:57-61` | `handleDataIntegrityViolation` → `Result.error(400, "数据不完整或格式错误:…")`,不返 HTTP 500 |
+| `schema.sql:2102` | `UNIQUE KEY uk_activity_child (activity_id, child_id)`(数据库唯一键,防止并发重复插入) |
+| `utils/api.js:319-349` | `res.data.code !== 200` → `reject(res.data)`;`fail` → `reject(err)` 并 toast "网络请求失败" |
+| `activity-detail.vue:953-959`(修复前) | `.catch(function() { … uni.showToast({ title: '网络异常', icon: 'none' }) })`(所有错误一律当网络异常) |
+
+---
+
+## 4. 改动清单
+
+### 4.1 前端(报名链路)
+
+**文件:** `cfc-frontend/pages/activity/activity-detail/activity-detail.vue`
+
+- 新增 `showApiError(err)` 方法:若 `err` 携带 `code !== undefined && code !== 200` 且有 `message`,判定为后端业务错误,用 `uni.showModal({title:'提示', content: err.message})` 弹友好提示并返回 `true`;否则返回 `false`。
+- `doCreateOrder()`(`/api/activity/order/create` 报名入口)的 `.catch`:`showApiError(err)` 为 `false`(真实网络错误)时才显示“网络异常”;业务错误(如“已报名: xxx”/“您已报名此活动”)不再落入“网络异常”。
+- `onCancelRegistration()`(`/api/activity/cancel-registration` 取消报名)的 `.catch`:同理,业务错误弹友好提示,仅真实网络错误显示“网络异常”。
+
+### 4.2 后端
+
+无改动。已满足“返回明确业务结果而非抛异常/500”的要求;重复拦截逻辑与唯一键防御已存在,首次报名(`isRegistered` 为 `false`)流程不受任何影响。
+
+### 4.3 接口约定(后续维护)
+
+- 后端统一返回 `Result`(HTTP 200 + body `code`/`message`);业务错误(含重复报名)**不抛异常、不返回 HTTP 500**。
+- 前端对所有非 200 body-code 响应,在 `.catch` 中必须区分“业务错误(有 `code`/`message`)→ 展示 `message`”与“真实网络错误 → 展示‘网络异常’”,禁止将业务错误一视同仁地归为网络异常。
+
+---
+
+## 5. 建议的回归验证步骤
+
+1. **首次报名成功(免费)**:打开活动详情 → 立即报名 → 提交 → 前端提示“报名成功”,`registrationStatus` 置为 `registered`,页面刷新后报名按钮隐藏/显示“取消报名”。
+2. **首次报名成功(付费)**:提交后跳转支付页并完成支付 → 支付成功返回详情 → 状态为已报名,不再展示“立即报名”按钮。
+3. **再次报名(核心验证)**:对同一活动/同一成员再次点击报名并提交 → 前端应弹出后端返回的业务提示(如“已报名: xxx”或“您已报名此活动”),**必须不是**“网络异常”;首次报名的报名记录未重复。
+4. **真实网络异常**:断网或后端不可达时,报名/取消报名仍应显示“网络异常”,确保 `showApiError` 未误杀真实网络错误。
+5. **编译与自检**:`mvn clean compile`(后端,无改动即无需编译)及前端 `verify-vue-compile.js`/`audit-mp-antipatterns.js` 对 `activity-detail.vue` 通过。
+
+---
+
+## 6. 遗留/建议后续加固(非阻塞)
+
+- 并发极端下两个请求同时绕过 `isRegistered` 预检并插入,会命中唯一键并由 `GlobalExceptionHandler` 返回通用 400 文案“数据不完整或格式错误…”,而非“您已报名此活动”。可在 Service 插入前加 `try-catch DataIntegrityViolationException` 转为业务提示,注意需同步回滚已占用名额,风险较高,建议单独评估。
+- 本次仅修复了报名页面 `activity-detail.vue` 的报名/取消报名 catch;若后续新增报名入口,应复用同一“业务错误 vs 网络错误”分辨约定。