|
|
@@ -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 网络错误”分辨约定。
|