|
@@ -0,0 +1,160 @@
|
|
|
|
|
+# 订单售后与购买体验改进(取消退款 / 再买一单 / 优惠券最优选择)
|
|
|
|
|
+
|
|
|
|
|
+- 日期:2026-11-14
|
|
|
|
|
+- 分支:`feature/order-refund-coupon-improvements`
|
|
|
|
|
+- worktree:`E:/tmp/family-cart`(严禁触碰主工作树 `E:/cfc`)
|
|
|
|
|
+- 唯一事实源:本文件
|
|
|
|
|
+
|
|
|
|
|
+## 背景(用户原始需求)
|
|
|
|
|
+
|
|
|
|
|
+1. **取消退款**:用户在小程序误点退款后,后台还未处理退款时应能取消退款。
|
|
|
|
|
+2. **再买一单**:用户在订单页应能通过「再买一单」直接重新购买(当前只跳商品详情页)。
|
|
|
|
|
+3. **优惠券最优选择**:当商品可使用优惠券时,应有比较明显的提示,或按最优规则自动帮用户选中。
|
|
|
|
|
+
|
|
|
|
|
+## 一、取消退款
|
|
|
|
|
+
|
|
|
|
|
+### 现状分析
|
|
|
|
|
+- `ProductOrder` 状态流转:`paid / shipped / pending_receipt` → 申请售后后 `status="refunding"`、`refundStatus=1`(申请中)。
|
|
|
|
|
+- `AfterSalesService.create()`:插入 `AfterSalesRequest(status="pending")`,同时把订单置为 `refunding` + `refundStatus=1`,写 `refundReason/refundAmount`。
|
|
|
|
|
+- **缺口**:① 没有取消路径(`AfterSalesRequest.status` 只有 pending→approved→completed / rejected,无 cancelled);② `ProductOrder` 没有保存退款前的订单状态 → 取消后无法回滚。
|
|
|
|
|
+
|
|
|
|
|
+### 改动方案
|
|
|
|
|
+
|
|
|
|
|
+#### 1) 数据库迁移(Flyway)
|
|
|
|
|
+新增 `cfc-backend/src/main/resources/db/migration/V254__add_refund_before_state_to_product_orders.sql`:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+ALTER TABLE `product_orders`
|
|
|
|
|
+ ADD COLUMN `refund_before_status` varchar(32) DEFAULT NULL COMMENT '退款前订单状态(售后取消回滚用)' AFTER `refund_status`,
|
|
|
|
|
+ ADD COLUMN `refund_before_refund_status` tinyint(1) DEFAULT 0 COMMENT '退款前退款状态(售后取消回滚用)' AFTER `refund_before_status`;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 2) 实体 `ProductOrder.java`
|
|
|
|
|
+新增字段:
|
|
|
|
|
+- `private String refundBeforeStatus;`
|
|
|
|
|
+- `private Integer refundBeforeRefundStatus;`
|
|
|
|
|
+
|
|
|
|
|
+#### 3) `AfterSalesService.create()`:写快照
|
|
|
|
|
+在把订单置为 `refunding` 之前:
|
|
|
|
|
+```java
|
|
|
|
|
+order.setRefundBeforeStatus(order.getStatus()); // paid / shipped / pending_receipt
|
|
|
|
|
+order.setRefundBeforeRefundStatus(order.getRefundStatus()); // 通常为 0
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 4) 新增 `AfterSalesService.cancel(...)`
|
|
|
|
|
+签名:`Result<String> cancel(Long requestId, Long userId)`(按售后申请单取消)或 `Result<String> cancelByOrderId(Long orderId, Long userId)`。
|
|
|
|
|
+
|
|
|
|
|
+推荐按 **requestId** 取消 + 前端把 requestId 传过来;若前端只有 orderId,则实现 `cancelByOrderId`。两者可同时提供,核心逻辑一致:
|
|
|
|
|
+
|
|
|
|
|
+```
|
|
|
|
|
+1. 校验售后单存在,且 request.userId == userId,否则「无权操作」;
|
|
|
|
|
+2. 校验 request.status == "pending",否则「后台已处理,无法取消(仅待审核的申请可取消)」;
|
|
|
|
|
+3. 校验订单 status == "refunding" 且 refundStatus == 1,否则返回「当前状态不可取消」;
|
|
|
|
|
+4. 售后单 status → "cancelled",更新 updatedAt;
|
|
|
|
|
+5. 订单回滚:
|
|
|
|
|
+ order.status = order.getRefundBeforeStatus() != null ? refundBeforeStatus : "paid" 兜底;
|
|
|
|
|
+ order.refundStatus = refundBeforeRefundStatus != null ? refundBeforeRefundStatus : 0;
|
|
|
|
|
+ 清空 refundReason / refundAmount / refundTime;更新 updatedAt;
|
|
|
|
|
+6. log.info 记录取消售后单 + 订单回滚。
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+#### 5) `AfterSalesController` 新增端点
|
|
|
|
|
+- `POST /api/shop/after-sales/cancel`
|
|
|
|
|
+- 请求体:`{ "requestId": 123 }`(或 `{ "orderId": 456 }`,取决于 4 的选型)
|
|
|
|
|
+- 从登录态取 userId(与现有 create/list 一致)。
|
|
|
|
|
+- 返回 `Result<String>`:「已取消退款申请,订单已恢复」。
|
|
|
|
|
+
|
|
|
|
|
+#### 6) 前端
|
|
|
|
|
+##### `order-detail.vue`(必要)
|
|
|
|
|
+- 状态提示区:`order.refundStatus === 1` 时(现已有「退款申请处理中,请耐心等待」),在其下方增加按钮 **「取消退款申请」**(`refundStatus===1` 且 `order.status==='refunding'` 时显示)。
|
|
|
|
|
+- 点击 → 确认弹窗(`uni.showModal`:「确定取消退款申请吗?将恢复订单原状态」)→ 调 `cancelRefund(orderId)` API:
|
|
|
|
|
+ - 成功后 `uni.showToast('已取消')` → 重新拉取订单详情刷新;
|
|
|
|
|
+ - 失败(后台已处理)toast 返回错误信息。
|
|
|
|
|
+- 若后端实现按 requestId,则订单详情/售后列表需带出 `AfterSalesRequest.id`;更简单:前端用 orderId 调用 `cancelByOrderId`,后端查该订单最新 pending 售后单。
|
|
|
|
|
+
|
|
|
|
|
+##### `after-sales.vue`(建议,可选)
|
|
|
|
|
+- `status==='pending'` 的申请卡片增加「取消退款」按钮(复用同一接口)。
|
|
|
|
|
+
|
|
|
|
|
+## 二、再买一单
|
|
|
|
|
+
|
|
|
|
|
+### 现状分析
|
|
|
|
|
+- `order-detail.vue onBuyAgain()`:仅 `uni.navigateTo` 商品详情页(`/pages/discover-detail/product-detail/product-detail?id=...`),用户需再点购买 + 进结算,体验断链。
|
|
|
|
|
+- 结算页 `checkout.vue onLoad`:已支持从 storage `checkoutItems` 读商品列表(数组 `[{productId, productName, coverImage, unitPrice, quantity}]`),读后 `calcAmount()`。
|
|
|
|
|
+- 订单详情接口返回 `ProductOrderDTO.from(order)`,含 `productId / productName / coverImage / unitPrice / quantity`(实体 `ProductOrder` 有这些字段)。
|
|
|
|
|
+
|
|
|
|
|
+### 改动方案
|
|
|
|
|
+`order-detail.vue onBuyAgain()` 改为**直接进结算页**:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+onBuyAgain() {
|
|
|
|
|
+ // 构造结算项:与购物车进入结算的 items 结构一致
|
|
|
|
|
+ const items = [{
|
|
|
|
|
+ productId: this.order.productId,
|
|
|
|
|
+ productName: this.order.productName,
|
|
|
|
|
+ coverImage: this.order.coverImage,
|
|
|
|
|
+ unitPrice: this.order.unitPrice || this.order.price || 0, // 分
|
|
|
|
|
+ quantity: this.order.quantity || 1
|
|
|
|
|
+ }]
|
|
|
|
|
+ uni.setStorageSync('checkoutItems', items)
|
|
|
|
|
+ uni.navigateTo({ url: '/pages/shop/checkout/checkout' })
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+注意:
|
|
|
|
|
+- `checkoutItems` 是一次性消费的 storage(checkout onLoad 读取后不影响购物车数据源——购物车路径走的是另一通道,二者不冲突);
|
|
|
|
|
+- 若商品已下架(返回错误),保持现有跳详情页逻辑作为兜底(`try/catch` 或根据订单状态判断);
|
|
|
|
|
+- 按钮展示条件保持现状(`order.status === 'completed'` 显示「再次购买」)——如需对 `paid/shipped/pending_receipt` 也放开,可加判断,但**不在本次范围**,避免与「申请售后」歧义。
|
|
|
|
|
+
|
|
|
|
|
+## 三、优惠券自动选择 / 明显提示
|
|
|
|
|
+
|
|
|
|
|
+### 现状分析
|
|
|
|
|
+- `checkout.vue` 已有:`ownedCoupons` computed(按 商品类型/满减门槛/金额 过滤)、优惠券 picker、`selectCoupon()` → `applyCouponDiscount()`(服务端 `/api/coupon/apply` 重算抵扣,展示与落库一致)。
|
|
|
|
|
+- `autoSelectCoupon()`:**仅**在 `from=coupon&couponId=xxx` 深链场景生效,不覆盖「当前商品本来就可用多张券」的常规场景。
|
|
|
|
|
+
|
|
|
|
|
+### 改动方案(自动最优 + 明显提示,两者都做)
|
|
|
|
|
+
|
|
|
|
|
+`checkout.vue`:
|
|
|
|
|
+
|
|
|
|
|
+1. **自动选最优券**(面额最大优先,同额取门槛低者):
|
|
|
|
|
+ - 在 `loadCoupons()` 成功后调用一次,逻辑:
|
|
|
|
|
+ - `_autoCouponId` 深链优先(现有逻辑保留);
|
|
|
|
|
+ - 否则,若 `ownedCoupons` 非空且用户**本次未手动取消过券**(`data._couponDismissed !== true`),
|
|
|
|
|
+ 从 `ownedCoupons` 中挑 `value` 最大(同 value 取 `minSpend` 最小)的一张,`this.selectCoupon(best)`;
|
|
|
|
|
+ - 自动选中后置标 `_autoSelected = true`,空窗期避免重复触发。
|
|
|
|
|
+ - 不侵入下单逻辑:折扣仍由服务端 `/api/coupon/apply` 计算,与现有一致。
|
|
|
|
|
+2. **明显提示条**(未选中券但存在可用券时):
|
|
|
|
|
+ - `coupon-section` 未选券(`!selectedCoupon`)且 `ownedCoupons.length > 0` 时:
|
|
|
|
|
+ - 若已自动选中 → 正常展示选中态(`-¥X` + 券名),并在券区域标题旁加小字 `已按最优自动选择`;
|
|
|
|
|
+ - 若未自动选(如用户手动取消过)→ placeholder 文案改为 `有 N 张优惠券可抵,点击选择`(突出可用券数),加醒目样式。
|
|
|
|
|
+3. **用户手动取消后不再自动选**:`removeCoupon()` 时置 `_couponDismissed = true`(本次结算会话内生效,页面关掉自然重置——`data` 属性即可)。
|
|
|
|
|
+
|
|
|
|
|
+## 四、改动文件清单(预期 ≤ 8 个)
|
|
|
|
|
+
|
|
|
|
|
+后端:
|
|
|
|
|
+1. `cfc-backend/src/main/resources/db/migration/V254__add_refund_before_state_to_product_orders.sql`(新增)
|
|
|
|
|
+2. `cfc-backend/src/main/java/com/etotem/cfc/entity/ProductOrder.java`(+2 字段)
|
|
|
|
|
+3. `cfc-backend/src/main/java/com/etotem/cfc/service/AfterSalesService.java`(create 快照 + cancel/cancelByOrderId)
|
|
|
|
|
+4. `cfc-backend/src/main/java/com/etotem/cfc/controller/shop/AfterSalesController.java`(+cancel 端点)
|
|
|
|
|
+
|
|
|
|
|
+前端:
|
|
|
|
|
+5. `cfc-frontend/pages/shop/order-detail/order-detail.vue`(取消退款按钮 + 再买一单直进结算)
|
|
|
|
|
+6. `cfc-frontend/pages/shop/after-sales/after-sales.vue`(可选:pending 卡片取消按钮)
|
|
|
|
|
+7. `cfc-frontend/api/shop.js`(或对应 api 文件,新增 cancelRefund 调用)
|
|
|
|
|
+8. `cfc-frontend/pages/shop/checkout/checkout.vue`(自动最优券 + 提示条)
|
|
|
|
|
+
|
|
|
|
|
+## 五、验证与纪律
|
|
|
|
|
+
|
|
|
|
|
+1. **只在本目录**(`E:/tmp/family-cart`)工作;git 只在本分支 `feature/order-refund-coupon-improvements`;禁对主工作树 `E:/cfc` 任何写操作(禁 reset --hard / checkout -- . / clean / stash / add -A)。
|
|
|
|
|
+2. 后端 `cfc-backend` 模块 **mvn clean compile 必须通过**(在 `E:/tmp/family-cart/cfc-backend` 下执行)。
|
|
|
|
|
+3. 前端改动后 `npx eslint <改动的文件>`(PowerShell 环境,不要用 `wc -l` 等 bash 命令;统计用 `Measure-Object -Line`)。
|
|
|
|
|
+4. 提交:精确 `git add <paths>` → `git diff --cached --name-only | Measure-Object -Line` 校验变更数 → `git commit`(消息前缀 `feat(order-refund)`/`feat(shop)`)→ `git push -u origin feature/order-refund-coupon-improvements`。
|
|
|
|
|
+5. 完成后最后一条回复逐条汇报:改的文件、每个文件改了什么、后端起服务/编译是否通过、前端 lint 结果、push 的 commit sha。
|
|
|
|
|
+6. 不要改业务无关代码;不要碰 `.tdd/` 与主工作树。
|
|
|
|
|
+
|
|
|
|
|
+## 六、验收清单(自查)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] 误点退款:`refundStatus===1` 时点「取消退款申请」,售后单变 `cancelled`,订单恢复原 `status`(paid/shipped/pending_receipt)且 `refundStatus=0`,`refundReason/refundAmount/refundTime` 清空;
|
|
|
|
|
+- [ ] 后台已 approve/reject 后,用户取消接口返回「后台已处理,无法取消」;
|
|
|
|
|
+- [ ] 「再买一单」:completed 订单一键直达结算页,商品信息正确、价格可算、可正常提交;
|
|
|
|
|
+- [ ] 优惠券:有可用券时自动选中面额最大者,券区显示「已按最优自动选择」;手动取消后不再自动选且显示「有 N 张优惠券可抵」提示;深链 `from=coupon` 场景不受影响;
|
|
|
|
|
+- [ ] 后端编译通过;前端 lint 通过;commit 已 push。
|