fix(优惠券): 确认页自动领取最优券并统一券列表口径

- confirm/cartConfirm 共用 buildConfirmCouponList,无可用券时自动领取整单最优券
- computed 重算补齐已选券,避免入口显示已用券但弹窗列表为空
- 券查询时间边界改为 start_time <= now,覆盖领取当秒的自动领券
- 同步用户端下单优惠券文档
This commit is contained in:
吴昊天 2026-10-22 10:45:57 +08:00
parent d5e2f6066a
commit 581fe81df7
4 changed files with 147 additions and 58 deletions

View File

@ -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) {
// 已选券回显失败不影响金额计算
}
}
}

View File

@ -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();
}

View File

@ -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

View File

@ -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;
}