diff --git a/backend/app/api/controller/v1/order/CenterOrderController.php b/backend/app/api/controller/v1/order/CenterOrderController.php index dcfb86237..3da0d1c61 100644 --- a/backend/app/api/controller/v1/order/CenterOrderController.php +++ b/backend/app/api/controller/v1/order/CenterOrderController.php @@ -233,40 +233,8 @@ class CenterOrderController // 活动订单不走优惠券自动匹配,标记前置(优惠券列表构建时使用) $isActivityOrder = $activityType !== 'normal' && $activityId > 0; - // 5. 获取用户可用优惠券 - $couponList = []; - try { - /** @var StoreCouponUserServices $couponServices */ - $couponServices = app()->make(StoreCouponUserServices::class); - $cartGroup = ['valid' => $cartInfo]; - $couponList = $couponServices->getUsableCouponList($uid, $cartGroup); - } catch (\Throwable $e) { - // 优惠券列表获取失败不影响主流程 - } - // 5.1 用户尚无可用券时,自动领取整单最优优惠券并入列表(预览即真实领取,与 computed/create 自动用券同口径) - if (!$isActivityOrder && empty($couponList)) { - try { - $autoCoupon = app()->make(OrderComputedServices::class)->autoMatchCoupon($uid, $cartInfo, true); - if (!empty($autoCoupon['coupon_user_id'])) { - $autoRow = $couponServices->get((int)$autoCoupon['coupon_user_id'], ['*'], ['issue']); - if ($autoRow) { - $autoRow = $autoRow->toArray(); - // 行结构与 getUsableCouponList 对齐(前端优惠券选择器字段) - $autoRow['title'] = $autoRow['coupon_title']; - $autoRow['type'] = $autoRow['applicable_type'] ?? 0; - $autoRow['use_min_price'] = floatval($autoRow['use_min_price'] ?? 0); - $autoRow['coupon_price'] = floatval($autoRow['coupon_price'] ?? 0); - $autoRow['start_time'] = $autoRow['start_time'] ? date('Y/m/d', $autoRow['start_time']) : date('Y/m/d', $autoRow['add_time']); - $autoRow['add_time'] = date('Y/m/d', (int)$autoRow['add_time']); - $autoRow['end_time'] = date('Y/m/d', (int)$autoRow['end_time']); - $autoRow['is_use'] = 0; - $couponList[] = $autoRow; - } - } - } catch (\Throwable $e) { - // 自动领券失败不影响确认页主流程 - } - } + // 5. 获取用户可用优惠券(无可用券时自动领取整单最优券并入列表,与 computed/create 自动用券同口径) + $couponList = $this->buildConfirmCouponList($uid, ['valid' => $cartInfo], $isActivityOrder, $pricingPolicy); // 6. 获取商品详情扩展数据(自定义表单、商品参数) $detailExtend = $handler->getDetailExtend($productId); @@ -492,13 +460,13 @@ class CenterOrderController $integralRatio = sys_config('integral_ratio', 0); $integralOpen = (float)$integralRatio > 0 || $usableIntegral > 0; - // 可用优惠券 - $couponList = []; - try { - $couponList = app()->make(StoreCouponUserServices::class)->getUsableCouponList($uid, $cartGroup); - } catch (\Throwable $e) { - // 优惠券列表获取失败不影响主流程 - } + // 可用优惠券(无可用券时自动领取整单最优券并入列表,保证与 computed 的自动选券结果一致) + $couponList = $this->buildConfirmCouponList( + $uid, + $cartGroup, + false, + method_exists($handler, 'getPricingPolicy') ? $handler->getPricingPolicy() : [] + ); // 支付渠道状态 /** @var PayChannelServices $payChannelServices */ @@ -647,6 +615,49 @@ class CenterOrderController return app('json')->success($data); } + /** + * 构建确认页可用优惠券列表 + * 用户已有可用券时直接返回;没有可用券时自动领取整单最优券并补入列表(预览即真实领取)。 + * 与 computed/create 的自动用券同口径,保证 features.coupon、coupon_list 与后续自动选券结果一致, + * 避免确认页显示已用券但优惠券弹窗列表为空。 + * @param int $uid 用户ID + * @param array $cartGroup 购物车分组(须含 valid 商品条目) + * @param bool $isActivityOrder 活动订单不参与优惠券 + * @param array $pricingPolicy 产品类型计价策略,coupon=false 时不参与优惠券 + * @return array + */ + protected function buildConfirmCouponList(int $uid, array $cartGroup, bool $isActivityOrder, array $pricingPolicy = []): array + { + if ($isActivityOrder || ($pricingPolicy['coupon'] ?? true) !== true) { + return []; + } + /** @var StoreCouponUserServices $couponServices */ + $couponServices = app()->make(StoreCouponUserServices::class); + $couponList = []; + try { + $couponList = $couponServices->getUsableCouponList($uid, $cartGroup); + } catch (\Throwable $e) { + // 优惠券列表获取失败不影响确认页主流程 + $couponList = []; + } + if (!empty($couponList)) { + return $couponList; + } + // 用户尚无可用券:自动领取整单最优优惠券,行结构与 getUsableCouponList 保持一致 + try { + /** @var OrderComputedServices $computedServices */ + $computedServices = app()->make(OrderComputedServices::class); + $autoCoupon = $computedServices->autoMatchCoupon($uid, $cartGroup['valid'] ?? [], true); + $autoRow = $couponServices->getUsableCouponRow($uid, (int)($autoCoupon['coupon_user_id'] ?? 0)); + if ($autoRow) { + $couponList[] = $autoRow; + } + } catch (\Throwable $e) { + // 自动领券失败不影响确认页主流程 + } + return $couponList; + } + /** * 计算订单金额(切换优惠券/积分时重算) */ @@ -901,16 +912,28 @@ class CenterOrderController $result['coupon_id'] = $couponId; $result['coupon_title'] = $couponTitle; - // 自动领取发生在确认接口返回之后时,重新查询可用券并返回给前端。 - // 否则金额和标题已经更新,但优惠券选择弹窗仍使用确认阶段的旧列表。 + // 自动领取发生在确认接口返回之后,或被自动选中并用于计价的券不在列表查询窗口内时, + // 重新查询可用券并返回给前端,按已选中的券补齐,保证金额、入口文案与弹窗列表口径一致。 $result['coupon_list'] = []; if (!$isActivityOrder && ($pricingPolicy['coupon'] ?? true)) { + /** @var StoreCouponUserServices $couponServices */ + $couponServices = app()->make(StoreCouponUserServices::class); try { - /** @var StoreCouponUserServices $couponServices */ - $couponServices = app()->make(StoreCouponUserServices::class); $result['coupon_list'] = $couponServices->getUsableCouponList($uid, ['valid' => $cartInfo]); } catch (\Throwable $e) { // 优惠券列表刷新失败不影响金额计算和下单流程 + $result['coupon_list'] = []; + } + $usableCouponIds = array_map('intval', array_column($result['coupon_list'], 'id')); + if ($couponId > 0 && !in_array($couponId, $usableCouponIds, true)) { + try { + $usedCouponRow = $couponServices->getUsableCouponRow($uid, $couponId); + if ($usedCouponRow) { + $result['coupon_list'][] = $usedCouponRow; + } + } catch (\Throwable $e) { + // 已选券回显失败不影响金额计算 + } } } diff --git a/backend/app/dao/activity/coupon/StoreCouponUserDao.php b/backend/app/dao/activity/coupon/StoreCouponUserDao.php index 8e5b5192e..624f675e6 100644 --- a/backend/app/dao/activity/coupon/StoreCouponUserDao.php +++ b/backend/app/dao/activity/coupon/StoreCouponUserDao.php @@ -132,8 +132,12 @@ class StoreCouponUserDao extends BaseDao */ public function getUserAllCoupon(int $uid) { + // start_time 用 <=:领取后 N 天可用的券在领取当秒 start_time 就等于 time(), + // 用 < 会把“确认页刚自动领取并已用于计价”的券漏查,导致优惠券弹窗列表为空。 + // 该口径与 OrderComputedServices::autoMatchCoupon 的候选券查询保持一致。 + $now = time(); return $this->getModel()->where(['uid' => $uid, 'status' => 0, 'is_fail' => 0]) - ->where('start_time', '<', time())->where('end_time', '>', time()) + ->where('start_time', '<=', $now)->where('end_time', '>', $now) ->order('coupon_price DESC')->with('issue')->select()->hidden(['status', 'is_fail'])->toArray(); } diff --git a/backend/app/services/activity/coupon/StoreCouponUserServices.php b/backend/app/services/activity/coupon/StoreCouponUserServices.php index e304d2e9b..af00a03d7 100644 --- a/backend/app/services/activity/coupon/StoreCouponUserServices.php +++ b/backend/app/services/activity/coupon/StoreCouponUserServices.php @@ -172,22 +172,60 @@ class StoreCouponUserServices extends BaseService break; } if ($count && $coupon['use_min_price'] <= $price) { - $coupon['start_time'] = $coupon['start_time'] ? date('Y/m/d', $coupon['start_time']) : date('Y/m/d', $coupon['add_time']); - $coupon['add_time'] = date('Y/m/d', $coupon['add_time']); - $coupon['end_time'] = date('Y/m/d', $coupon['end_time']); - $coupon['start_use_time'] = $coupon['start_use_time'] > 0 ? date('Y/m/d', $coupon['start_use_time']) : 0; - $coupon['end_use_time'] = $coupon['start_use_time'] > 0 ? date('Y/m/d', $coupon['end_use_time']) : 0; - $coupon['title'] = $coupon['coupon_title']; - $coupon['type'] = $coupon['applicable_type']; - $coupon['use_min_price'] = floatval($coupon['use_min_price']); - $coupon['coupon_price'] = floatval($coupon['coupon_price']); - $result[] = $coupon; + $result[] = $this->formatUsableCouponRow($coupon); } } } return $result; } + /** + * 格式化用户优惠券为下单页可选券行(前端优惠券选择器字段契约) + * @param array $coupon 用户券行,须已绑定 issue 的 applicable_type/start_use_time/end_use_time 等字段 + * @return array + */ + public function formatUsableCouponRow(array $coupon): array + { + $startUseTime = (int)($coupon['start_use_time'] ?? 0); + $endUseTime = (int)($coupon['end_use_time'] ?? 0); + $coupon['start_time'] = $coupon['start_time'] ? date('Y/m/d', (int)$coupon['start_time']) : date('Y/m/d', (int)$coupon['add_time']); + $coupon['add_time'] = date('Y/m/d', (int)$coupon['add_time']); + $coupon['end_time'] = date('Y/m/d', (int)$coupon['end_time']); + $coupon['start_use_time'] = $startUseTime > 0 ? date('Y/m/d', $startUseTime) : 0; + $coupon['end_use_time'] = $startUseTime > 0 ? date('Y/m/d', $endUseTime) : 0; + $coupon['title'] = $coupon['coupon_title']; + $coupon['type'] = $coupon['applicable_type'] ?? 0; + $coupon['use_min_price'] = floatval($coupon['use_min_price']); + $coupon['coupon_price'] = floatval($coupon['coupon_price']); + return $coupon; + } + + /** + * 获取指定用户券的下单页券行(自动领取后回显/补齐弹窗列表用) + * @param int $uid 用户ID + * @param int $couponUserId 用户券记录ID(eb_store_coupon_user.id) + * @return array 券不属于当前用户或不存在时返回空数组 + */ + public function getUsableCouponRow(int $uid, int $couponUserId): array + { + if ($couponUserId <= 0) { + return []; + } + $coupon = $this->dao->get($couponUserId, ['*'], ['issue']); + if (!$coupon) { + return []; + } + if (is_object($coupon)) { + $coupon = $coupon->toArray(); + } + // 仅回显当前用户未使用的券,避免越权读取他人券 + if ((int)($coupon['uid'] ?? 0) !== $uid || (int)($coupon['status'] ?? 0) !== 0) { + return []; + } + unset($coupon['status'], $coupon['is_fail']); + return $this->formatUsableCouponRow($coupon); + } + /** * 下单页面显示可用优惠券 * @param $uid diff --git a/frontend/uni-app/src/dynamic-pages/engines/OrderCreateEngine.vue b/frontend/uni-app/src/dynamic-pages/engines/OrderCreateEngine.vue index 1a1f1f126..fc5885adf 100644 --- a/frontend/uni-app/src/dynamic-pages/engines/OrderCreateEngine.vue +++ b/frontend/uni-app/src/dynamic-pages/engines/OrderCreateEngine.vue @@ -593,6 +593,19 @@ function ChangCoupons(e) { computedPrice(); } +/** + * 判断优惠券列表是否已包含指定用户券 + * @param {Array} list 优惠券列表 + * @param {Number|String} id 用户券ID + * @returns {boolean} + */ +function hasCouponRow(list, id) { + for (var i = 0; i < list.length; i++) { + if (list[i].id == id) return true; + } + return false; +} + /** * 计算价格(调用computed接口) */ @@ -644,8 +657,19 @@ async function computedPrice() { // 自动领券发生在确认接口之后,计算接口返回的列表才是最新可用券列表。 // 同步列表和选中状态,避免入口显示优惠券但弹窗仍显示为空。 if (Array.isArray(data.coupon_list)) { - coupon.value.list = data.coupon_list; - features.value.coupon = data.coupon_list.length > 0; + var nextCouponList = data.coupon_list; + // 兜底:已使用的券不在最新列表中时保留原行(如自动领券与列表查询落在同一秒), + // 否则入口显示已用券、价格已抵扣,弹窗却渲染成空优惠券。 + if (couponId.value > 0 && !hasCouponRow(nextCouponList, couponId.value)) { + for (var usedIndex = 0; usedIndex < coupon.value.list.length; usedIndex++) { + if (coupon.value.list[usedIndex].id == couponId.value) { + nextCouponList = nextCouponList.concat([coupon.value.list[usedIndex]]); + break; + } + } + } + coupon.value.list = nextCouponList; + features.value.coupon = nextCouponList.length > 0 || couponId.value > 0; for (var couponIndex = 0; couponIndex < coupon.value.list.length; couponIndex++) { coupon.value.list[couponIndex].is_use = coupon.value.list[couponIndex].id == couponId.value ? 1 : 0; }