|
|
@@ -391,6 +391,69 @@ public class MembershipService implements MembershipServiceInterface {
|
|
|
return level.getPriceYearly();
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SC-P-58 重复购买幂等守卫(落单前调用):
|
|
|
+ * <ol>
|
|
|
+ * <li>家庭已有有效付费会员(paid 未过期)且目标档位不高于既有档位 → 抛异常拒绝重复购买
|
|
|
+ * (续费走 SC-P-60 流程、升档购买允许——目标档位更高时不拦截);</li>
|
|
|
+ * <li>家庭已有同等级「待支付」订单 → 复用既有订单,禁止为同一意图重复落单(返回待复用订单)。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * @return 可复用的既有待支付订单;无复用返回 null(可正常创建新订单)。
|
|
|
+ */
|
|
|
+ private PaymentOrder findReusablePendingOrder(Long familyId, String levelCode, String paymentType) {
|
|
|
+ if (familyId == null) {
|
|
|
+ throw new RuntimeException("请先创建或加入家庭后再购买会员");
|
|
|
+ }
|
|
|
+ if ("trial".equals(paymentType)) {
|
|
|
+ return null; // 试用非购买,不走重复购买幂等守卫
|
|
|
+ }
|
|
|
+ // 守卫一:已有有效付费会员(不低于目标档位)→ 拒绝重复购买
|
|
|
+ FamilyMembership active = membershipMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<FamilyMembership>()
|
|
|
+ .eq(FamilyMembership::getFamilyId, familyId)
|
|
|
+ .eq(FamilyMembership::getPaymentStatus, "paid")
|
|
|
+ .gt(FamilyMembership::getEndDate, new Date())
|
|
|
+ .orderByDesc(FamilyMembership::getEndDate)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ if (active != null && active.getLevelCode() != null
|
|
|
+ && getLevelOrder(levelCode) <= getLevelOrder(active.getLevelCode())) {
|
|
|
+ throw new RuntimeException("您已有有效会员,无需重复购买");
|
|
|
+ }
|
|
|
+ // 守卫二:已有同等级待支付订单 → 复用既有订单,不新插入
|
|
|
+ return paymentOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<PaymentOrder>()
|
|
|
+ .eq(PaymentOrder::getFamilyId, familyId)
|
|
|
+ .eq(PaymentOrder::getLevelCode, levelCode)
|
|
|
+ .eq(PaymentOrder::getStatus, "pending")
|
|
|
+ .orderByDesc(PaymentOrder::getCreatedAt)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将既有订单转成下单响应 DTO(重复购买复用既有待支付订单时返回)
|
|
|
+ */
|
|
|
+ private PaymentOrderDTO toOrderDTO(PaymentOrder order) {
|
|
|
+ if (order == null) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ PaymentOrderDTO dto = new PaymentOrderDTO();
|
|
|
+ dto.setOrderNo(order.getOrderNo());
|
|
|
+ dto.setFamilyId(order.getFamilyId());
|
|
|
+ dto.setLevelCode(order.getLevelCode());
|
|
|
+ dto.setPaymentType(order.getPaymentType());
|
|
|
+ dto.setAmount(order.getAmount());
|
|
|
+ dto.setStatus(order.getStatus());
|
|
|
+ dto.setGroupNo(order.getGroupNo());
|
|
|
+ dto.setPeriodNo(order.getPeriodNo());
|
|
|
+ dto.setTotalPeriods(1);
|
|
|
+ dto.setPayMethod(order.getPayMethod());
|
|
|
+ dto.setTransactionId(order.getTransactionId());
|
|
|
+ dto.setPaidAt(order.getPaidAt());
|
|
|
+ dto.setCreatedAt(order.getCreatedAt());
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 创建订单
|
|
|
*/
|
|
|
@@ -398,6 +461,13 @@ public class MembershipService implements MembershipServiceInterface {
|
|
|
if (familyId == null) {
|
|
|
throw new RuntimeException("请先创建或加入家庭后再购买会员");
|
|
|
}
|
|
|
+ // SC-P-58 重复购买幂等:已有有效会员拒绝、已有同类待支付订单复用(不重复落单)
|
|
|
+ PaymentOrder reusable = findReusablePendingOrder(familyId, levelCode, paymentType);
|
|
|
+ if (reusable != null) {
|
|
|
+ log.info("复用既有待支付会员订单,不重复落单: orderNo={}, familyId={}, levelCode={}",
|
|
|
+ reusable.getOrderNo(), familyId, levelCode);
|
|
|
+ return toOrderDTO(reusable);
|
|
|
+ }
|
|
|
MembershipLevel level = levelMapper.selectOne(
|
|
|
new LambdaQueryWrapper<MembershipLevel>()
|
|
|
.eq(MembershipLevel::getLevelCode, levelCode)
|
|
|
@@ -708,6 +778,64 @@ public class MembershipService implements MembershipServiceInterface {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SC-P-58 一段式档位购买:选档位 → 生成待支付订单 + 支付参数(按当前支付模式分流)。
|
|
|
+ *
|
|
|
+ * <p>权限流 fail-fast:无家庭时<b>最先</b>拒绝(与 createOrder 同源文案「请先创建或加入家庭后再购买会员」),
|
|
|
+ * 不触碰任何数据。支付模式与 {@code createOrderWithPayType} 一致:走
|
|
|
+ * {@code member_pay_virtual_enabled} 开关(虚拟支付 / 普通微信支付),返回
|
|
|
+ * {@code payType(virtual|wechat)} + 订单信息 + 支付参数。</p>
|
|
|
+ *
|
|
|
+ * @param userId 当前登录用户
|
|
|
+ * @param familyId 购买家庭(null → fail-fast 拒绝)
|
|
|
+ * @param levelCode 会员档位编码(FAMILY/PREMIUM 等可购档)
|
|
|
+ * @param period 购买周期(monthly/quarterly/yearly;FAMILY 固定 yearly)
|
|
|
+ * @param couponId 使用的优惠券ID(可为 null)
|
|
|
+ */
|
|
|
+ public Map<String, Object> purchasePlan(Long userId, Long familyId, String levelCode, String period, Long couponId) {
|
|
|
+ if (familyId == null || familyId <= 0L) {
|
|
|
+ throw new RuntimeException("请先创建或加入家庭后再购买会员");
|
|
|
+ }
|
|
|
+ // 解析支付所需登录态:虚拟支付签名 sessionKey、普通微信支付 openid
|
|
|
+ String sessionKey = null;
|
|
|
+ String openid = null;
|
|
|
+ if (userId != null) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user != null) {
|
|
|
+ sessionKey = user.getSessionKey();
|
|
|
+ openid = user.getOpenid();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return createOrderWithPayType(userId, familyId, levelCode, "wechat", period, couponId, sessionKey, openid);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SC-P-58 支付失败回滚入口:将会员订单显式置<b>失败态</b>(不置 paid、不生成会员记录)。
|
|
|
+ *
|
|
|
+ * <p>幂等:订单不存在返回 false;已处失败态再次调用返回 true 且不重复变更(收款失败重复推送安全)。</p>
|
|
|
+ *
|
|
|
+ * @return true=已置失败态/本就是失败态;false=订单不存在或参数非法
|
|
|
+ */
|
|
|
+ public boolean markMembershipOrderFailed(String orderNo) {
|
|
|
+ if (orderNo == null || orderNo.isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ PaymentOrder order = paymentOrderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<PaymentOrder>()
|
|
|
+ .eq(PaymentOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if ("failed".equals(order.getStatus())) {
|
|
|
+ return true; // 幂等:已是失败态
|
|
|
+ }
|
|
|
+ order.setStatus("failed");
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ paymentOrderMapper.updateById(order);
|
|
|
+ log.info("支付失败回滚:订单置失败态 orderNo={}", orderNo);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 处理支付回调
|
|
|
*/
|
|
|
@@ -774,20 +902,52 @@ public class MembershipService implements MembershipServiceInterface {
|
|
|
.mapToInt(o -> o.getAmount() != null ? o.getAmount() : 0)
|
|
|
.sum();
|
|
|
mainOrder.setAmount(groupTotal);
|
|
|
- return activateMembership(mainOrder, transactionId, payMethod);
|
|
|
+ boolean groupActivated = activateMembership(mainOrder, transactionId, payMethod);
|
|
|
+ if (!groupActivated) {
|
|
|
+ // SC-P-58 支付失败回滚:组内已置 paid 的订单全部回滚为待支付,不留「paid 但无会员记录」半态
|
|
|
+ for (PaymentOrder o : groupOrders) {
|
|
|
+ if ("paid".equals(o.getStatus())) {
|
|
|
+ rollbackOrderToPending(o);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return groupActivated;
|
|
|
}
|
|
|
|
|
|
// 单期:原有逻辑
|
|
|
if (order.getUserCouponId() != null) {
|
|
|
couponService.markUsed(order.getUserCouponId(), order.getId());
|
|
|
}
|
|
|
- return activateMembership(order, transactionId, payMethod);
|
|
|
+ boolean activated = activateMembership(order, transactionId, payMethod);
|
|
|
+ if (!activated) {
|
|
|
+ // SC-P-58 支付失败回滚:开通失败(如家庭无关联用户)时订单回滚为待支付,不得停留 paid 半态
|
|
|
+ rollbackOrderToPending(order);
|
|
|
+ }
|
|
|
+ return activated;
|
|
|
} catch (Exception e) {
|
|
|
log.warn("处理支付回调异常: orderNo={}", orderNo, e);
|
|
|
return false;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * SC-P-58 支付失败回滚:将已置 paid 的订单回滚为待支付(清空支付凭据),
|
|
|
+ * 避免「订单 paid 但无会员记录」的半态残留;回滚后允许用户重新支付。
|
|
|
+ */
|
|
|
+ private void rollbackOrderToPending(PaymentOrder order) {
|
|
|
+ if (order == null) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ order.setStatus("pending");
|
|
|
+ order.setTransactionId(null);
|
|
|
+ order.setPayMethod(null);
|
|
|
+ order.setPaidAt(null);
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ paymentOrderMapper.updateById(order);
|
|
|
+ log.warn("会员开通失败,订单回滚为待支付: orderNo={}, familyId={}",
|
|
|
+ order.getOrderNo(), order.getFamilyId());
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 会员开通逻辑:插入FamilyMembership + 升级管理员 + 升级记录 + 佣金
|
|
|
*/
|