Kaynağa Gözat

VIP会员过审单独需求

退款接口

fix
zoujiajian 1 ay önce
ebeveyn
işleme
30342a08f5

+ 22 - 0
netflix-dao/src/main/java/com/cyksj/mapper/special/SpecialMemberEntitlementMapper.java

@@ -0,0 +1,22 @@
+package com.cyksj.mapper.special;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.entity.SpecialMemberEntitlement;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Update;
+
+public interface SpecialMemberEntitlementMapper extends BaseMapper<SpecialMemberEntitlement> {
+
+	@Insert("insert into special_member_entitlement " +
+			"(user_id, expire_time, last_order_id, created_time, update_time) " +
+			"values (#{userId}, date_add(now(), interval #{durationDays} day), #{orderId}, now(), now()) " +
+			"on duplicate key update expire_time = date_add(greatest(expire_time, now()), interval #{durationDays} day), " +
+			"last_order_id = #{orderId}, update_time = now()")
+	int extend(@Param("userId") Long userId,
+			   @Param("durationDays") Integer durationDays,
+			   @Param("orderId") Long orderId);
+
+	@Update("update special_member_entitlement set expire_time = date_sub(expire_time, interval #{durationDays} day), last_order_id = 0, update_time = now() where user_id = #{userId} and last_order_id = #{orderId}")
+	int rollbackIfLastOrder(@Param("userId") Long userId, @Param("durationDays") Integer durationDays, @Param("orderId") Long orderId);
+}

+ 35 - 0
netflix-dao/src/main/java/com/cyksj/mapper/special/SpecialPayOrderMapper.java

@@ -0,0 +1,35 @@
+package com.cyksj.mapper.special;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.entity.SpecialPayOrder;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.Date;
+
+public interface SpecialPayOrderMapper extends BaseMapper<SpecialPayOrder> {
+
+	@Select("select id, order_no as orderNo, user_id as userId, product_code as productCode, " +
+			"subject, amount, duration_days as durationDays, status, shop_id as shopId, " +
+			"alipay_trade_no as alipayTradeNo, buyer_id as buyerId, pay_time as payTime, " +
+			"expire_time as expireTime, fulfill_status as fulfillStatus, fulfill_time as fulfillTime, " +
+			"created_time as createdTime, update_time as updateTime " +
+			"from special_pay_order where order_no = #{orderNo} limit 1 for update")
+	SpecialPayOrder selectByOrderNoForUpdate(@Param("orderNo") String orderNo);
+
+	@Update("update special_pay_order set status = 'PAID', alipay_trade_no = #{tradeNo}, " +
+			"buyer_id = #{buyerId}, pay_time = #{payTime}, update_time = now() " +
+			"where id = #{orderId} and status = 'CREATED'")
+	int markPaid(@Param("orderId") Long orderId,
+				 @Param("tradeNo") String tradeNo,
+				 @Param("buyerId") String buyerId,
+				 @Param("payTime") Date payTime);
+
+	@Update("update special_pay_order set fulfill_status = 'SUCCESS', fulfill_time = now(), " +
+			"update_time = now() where id = #{orderId} and status = 'PAID' and fulfill_status = 'INIT'")
+	int markFulfilled(@Param("orderId") Long orderId);
+
+	@Update("update special_pay_order set status = 'REFUNDED', update_time = now() where id = #{orderId} and status = 'PAID'")
+	int markRefunded(@Param("orderId") Long orderId);
+}

+ 7 - 0
netflix-dao/src/main/java/com/cyksj/mapper/special/SpecialPayProductMapper.java

@@ -0,0 +1,7 @@
+package com.cyksj.mapper.special;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.cyksj.model.entity.SpecialPayProduct;
+
+public interface SpecialPayProductMapper extends BaseMapper<SpecialPayProduct> {
+}

+ 19 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/SpecialMemberEntitlement.java

@@ -0,0 +1,19 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+@Getter
+@Setter
+@TableName("special_member_entitlement")
+public class SpecialMemberEntitlement extends BaseEntity {
+
+	private Long userId;
+
+	private Date expireTime;
+
+	private Long lastOrderId;
+}

+ 56 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/SpecialPayOrder.java

@@ -0,0 +1,56 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+import java.util.Date;
+
+@Getter
+@Setter
+@TableName("special_pay_order")
+public class SpecialPayOrder extends BaseEntity {
+
+	private String orderNo;
+
+	private Long userId;
+
+	private String productCode;
+
+	private String subject;
+
+	/** Order amount in cents. */
+	private BigDecimal amount;
+
+	private Integer durationDays;
+
+	private Status status;
+
+	private String shopId;
+
+	private String alipayTradeNo;
+
+	private String buyerId;
+
+	private Date payTime;
+
+	private Date expireTime;
+
+	private FulfillStatus fulfillStatus;
+
+	private Date fulfillTime;
+
+	public enum Status {
+		CREATED,
+		PAID,
+		CLOSED,
+		REFUNDED
+	}
+
+	public enum FulfillStatus {
+		INIT,
+		SUCCESS,
+		FAILED
+	}
+}

+ 26 - 0
netflix-dao/src/main/java/com/cyksj/model/entity/SpecialPayProduct.java

@@ -0,0 +1,26 @@
+package com.cyksj.model.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.math.BigDecimal;
+
+@Getter
+@Setter
+@TableName("special_pay_product")
+public class SpecialPayProduct extends BaseEntity {
+
+	private String productCode;
+
+	private String productName;
+
+	/** Price in cents. */
+	private BigDecimal amount;
+
+	private Integer durationDays;
+
+	private Boolean enabled;
+
+	private Integer sortBy;
+}

+ 23 - 0
netflix-dao/src/main/java/com/cyksj/model/request/SpecialPayCreateReq.java

@@ -0,0 +1,23 @@
+package com.cyksj.model.request;
+
+import lombok.Getter;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+
+@Getter
+@Setter
+public class SpecialPayCreateReq {
+
+	@NotBlank
+	private String productCode;
+
+	/** 1: mobile web; 2: desktop web. */
+	@NotNull
+	private Integer payType;
+
+	private String payMode;
+
+	private String returnUrl;
+}

+ 30 - 0
netflix-dao/src/main/java/com/cyksj/model/response/SpecialMemberInfoRep.java

@@ -0,0 +1,30 @@
+package com.cyksj.model.response;
+
+import com.cyksj.model.entity.SpecialPayOrder;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+@Getter
+@Setter
+public class SpecialMemberInfoRep {
+
+	private Long userId;
+
+	/** Whether the membership is currently active. */
+	private Boolean member;
+
+	private Date expireTime;
+
+	/** Calendar days remaining, rounded up; zero when inactive. */
+	private Long remainingDays;
+
+	private String lastOrderNo;
+
+	private String productCode;
+
+	private String productName;
+
+	private SpecialPayOrder.Status lastOrderStatus;
+}

+ 21 - 0
netflix-dao/src/main/java/com/cyksj/model/response/SpecialPayCreateRep.java

@@ -0,0 +1,21 @@
+package com.cyksj.model.response;
+
+import com.cyksj.model.entity.SpecialPayOrder;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.Date;
+
+@Getter
+@Setter
+public class SpecialPayCreateRep {
+
+	private String orderNo;
+
+	private SpecialPayOrder.Status status;
+
+	private Date expireTime;
+
+	/** HTML form generated and signed by the Alipay SDK. */
+	private String payBody;
+}

+ 2 - 0
netflix-service/src/main/java/com/cyksj/config/zfb/BaseZfbConfig.java

@@ -60,6 +60,8 @@ public class BaseZfbConfig {
 
 	public static String spotifyUpgradeNotifyUrl = "applets/spotify/prepay/zfb/upgrade/notify";
 
+	public static String specialPayNotifyUrl = "applets/special/member/alipay/notify";
+
 	/**
 	 * qr_pay_mode PC扫码支付的方式。 跳转模式
 	 */

+ 7 - 0
netflix-service/src/main/java/com/cyksj/service/pay/AliPayCommonService.java

@@ -3,6 +3,9 @@ package com.cyksj.service.pay;
 import com.cyksj.model.dto.AliPayParams;
 import com.cyksj.model.request.AlipayOrderDonReq;
 
+import java.math.BigDecimal;
+import java.util.Date;
+
 /*
  *项目名: netflix
  *文件名: AliPayCommonService
@@ -10,6 +13,10 @@ import com.cyksj.model.request.AlipayOrderDonReq;
  *创建时间:2023/6/14 15:19
  */
 public interface AliPayCommonService {
+	AliPayParams builderPayParams(Long orderId, String orderNo, String shopId, BigDecimal money,
+			Date orderCreatedTime, String subject, String productCode, String payMode,
+			String notifyUrl, String returnUrl) throws Exception;
+
 	/**
 	 *
 	 * @param orderId

+ 1 - 0
netflix-service/src/main/java/com/cyksj/service/pay/impl/AliPayCommonServiceImpl.java

@@ -338,6 +338,7 @@ public class AliPayCommonServiceImpl implements AliPayCommonService {
 		return null;
 	}
 
+	@Override
 	public AliPayParams builderPayParams(Long orderId, String orderNo, String shopId, BigDecimal money, Date orderCreatedTime, String subject, String productCode, String payMode, String notifyUrl, String returnUrl) throws Exception {
 		OrderPayBuilderDto orderPayBuilderDto = new OrderPayBuilderDto();
 		orderPayBuilderDto.setOrderId(orderId);

+ 32 - 0
netflix-service/src/main/java/com/cyksj/service/special/SpecialPayService.java

@@ -0,0 +1,32 @@
+package com.cyksj.service.special;
+
+import com.cyksj.model.entity.SpecialPayOrder;
+import com.cyksj.model.entity.SpecialPayProduct;
+import com.cyksj.model.entity.SpecialMemberEntitlement;
+import com.cyksj.model.request.SpecialPayCreateReq;
+import com.cyksj.model.response.SpecialPayCreateRep;
+import com.cyksj.model.response.SpecialMemberInfoRep;
+
+import java.util.List;
+import java.util.Map;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+
+public interface SpecialPayService {
+
+	List<SpecialPayProduct> getEnabledProducts();
+
+	SpecialPayCreateRep createAlipayOrder(Long userId, SpecialPayCreateReq request) throws Exception;
+
+	SpecialPayOrder getUserOrder(Long userId, String orderNo);
+
+	SpecialMemberEntitlement getMemberEntitlement(Long userId);
+
+	SpecialMemberInfoRep getMemberInfo(Long userId);
+
+	void refundOrder(String orderNo) throws Exception;
+
+	Page<SpecialPayOrder> getManageOrderPage(Integer start, Integer limit, String orderNo,
+			Long userId, String productCode, SpecialPayOrder.Status status);
+
+	void handleAlipayNotify(Map<String, String> params) throws Exception;
+}

+ 301 - 0
netflix-service/src/main/java/com/cyksj/service/special/impl/SpecialPayServiceImpl.java

@@ -0,0 +1,301 @@
+package com.cyksj.service.special.impl;
+
+import cn.hutool.core.date.DateUtil;
+import cn.hutool.core.util.StrUtil;
+import com.alipay.api.internal.util.AlipaySignature;
+import com.alipay.api.AlipayClient;
+import com.alipay.api.domain.AlipayTradeRefundModel;
+import com.alipay.api.request.AlipayTradeRefundRequest;
+import com.alipay.api.response.AlipayTradeRefundResponse;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.cyksj.common.EnvCommonService;
+import com.cyksj.common.exception.BusinessRuntimeException;
+import com.cyksj.common.snowflake.Sequence;
+import com.cyksj.common.util.Codec;
+import com.cyksj.common.util.Jsons;
+import com.cyksj.config.zfb.BaseZfbConfig;
+import com.cyksj.config.zfb.AliPayClientFactory;
+import com.cyksj.config.zfb.ZfbProductCode;
+import com.cyksj.mapper.channel.ShopConfigMapper;
+import com.cyksj.mapper.special.SpecialMemberEntitlementMapper;
+import com.cyksj.mapper.special.SpecialPayOrderMapper;
+import com.cyksj.mapper.special.SpecialPayProductMapper;
+import com.cyksj.model.dto.AliPayParams;
+import com.cyksj.model.entity.ShopConfig;
+import com.cyksj.model.entity.SpecialPayOrder;
+import com.cyksj.model.entity.SpecialPayProduct;
+import com.cyksj.model.entity.SpecialMemberEntitlement;
+import com.cyksj.model.request.SpecialPayCreateReq;
+import com.cyksj.model.request.OrderAttach;
+import com.cyksj.model.response.AliPayTradeStatus;
+import com.cyksj.model.response.SpecialPayCreateRep;
+import com.cyksj.model.response.SpecialMemberInfoRep;
+import com.cyksj.service.pay.AliPayCommonService;
+import com.cyksj.service.special.SpecialPayService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class SpecialPayServiceImpl implements SpecialPayService {
+
+	private static final Sequence SEQUENCE = new Sequence(0);
+	private static final int ORDER_EXPIRE_MINUTES = 5;
+	private static final String YING_WANG_FEI_ALIPAY_APP_ID = "2021006115648946";
+
+	private final SpecialPayProductMapper productMapper;
+	private final SpecialPayOrderMapper orderMapper;
+	private final SpecialMemberEntitlementMapper entitlementMapper;
+	private final ShopConfigMapper shopConfigMapper;
+	private final AliPayCommonService aliPayCommonService;
+	private final EnvCommonService envCommonService;
+
+	@Override
+	@Transactional(rollbackFor = Throwable.class)
+	public void refundOrder(String orderNo) throws Exception {
+		SpecialPayOrder order = orderMapper.selectOne(Wrappers.lambdaQuery(SpecialPayOrder.class)
+				.eq(SpecialPayOrder::getOrderNo, orderNo).last("limit 1 for update"));
+		if (order == null) throw BusinessRuntimeException.getInstance("订单不存在");
+		if (order.getStatus() == SpecialPayOrder.Status.REFUNDED) return;
+		if (order.getStatus() != SpecialPayOrder.Status.PAID || StrUtil.isBlank(order.getAlipayTradeNo())) throw BusinessRuntimeException.getInstance("当前订单状态不支持退款");
+		SpecialMemberEntitlement entitlement = entitlementMapper.selectOne(Wrappers.lambdaQuery(SpecialMemberEntitlement.class)
+				.eq(SpecialMemberEntitlement::getUserId, order.getUserId()).last("limit 1 for update"));
+		if (entitlement != null && !order.getId().equals(entitlement.getLastOrderId())) throw BusinessRuntimeException.getInstance("存在后续会员订单,暂不支持自动退款");
+		AlipayTradeRefundModel model = new AlipayTradeRefundModel();
+		model.setTradeNo(order.getAlipayTradeNo());
+		model.setRefundAmount(order.getAmount().divide(BigDecimal.valueOf(100)).setScale(2, RoundingMode.UNNECESSARY).toPlainString());
+		model.setRefundReason("特殊会员订单退款");
+		model.setOutRequestNo("SR" + order.getOrderNo());
+		AlipayTradeRefundRequest refundRequest = new AlipayTradeRefundRequest();
+		refundRequest.setBizModel(model);
+		AlipayTradeRefundResponse response = AliPayClientFactory.getAlipayClient(order.getShopId()).execute(refundRequest);
+		if (response == null || !response.isSuccess()) throw BusinessRuntimeException.getInstance("支付宝退款失败");
+		if (orderMapper.markRefunded(order.getId()) != 1) throw BusinessRuntimeException.getInstance("退款状态更新失败");
+		if (entitlement != null) entitlementMapper.rollbackIfLastOrder(order.getUserId(), order.getDurationDays(), order.getId());
+	}
+
+	@Override
+	public Page<SpecialPayOrder> getManageOrderPage(Integer start, Integer limit, String orderNo,
+			Long userId, String productCode, SpecialPayOrder.Status status) {
+		int pageNo = start == null || start < 1 ? 1 : start;
+		int pageSize = limit == null || limit < 1 ? 10 : Math.min(limit, 100);
+		return orderMapper.selectPage(new Page<>(pageNo, pageSize), Wrappers.lambdaQuery(SpecialPayOrder.class)
+				.like(StrUtil.isNotBlank(orderNo), SpecialPayOrder::getOrderNo, orderNo)
+				.eq(userId != null, SpecialPayOrder::getUserId, userId)
+				.eq(StrUtil.isNotBlank(productCode), SpecialPayOrder::getProductCode, productCode)
+				.eq(status != null, SpecialPayOrder::getStatus, status)
+				.orderByDesc(SpecialPayOrder::getId));
+	}
+
+	@Override
+	public List<SpecialPayProduct> getEnabledProducts() {
+		return productMapper.selectList(Wrappers.lambdaQuery(SpecialPayProduct.class)
+				.eq(SpecialPayProduct::getEnabled, true)
+				.orderByAsc(SpecialPayProduct::getSortBy, SpecialPayProduct::getId));
+	}
+
+	@Override
+	@Transactional(rollbackFor = Throwable.class)
+	public SpecialPayCreateRep createAlipayOrder(Long userId, SpecialPayCreateReq request) throws Exception {
+		SpecialPayProduct product = productMapper.selectOne(Wrappers.lambdaQuery(SpecialPayProduct.class)
+				.eq(SpecialPayProduct::getProductCode, request.getProductCode())
+				.eq(SpecialPayProduct::getEnabled, true)
+				.last("limit 1"));
+		if (product == null) {
+			throw BusinessRuntimeException.getInstance("支付套餐不存在或已下架");
+		}
+		if (product.getAmount() == null || product.getAmount().compareTo(BigDecimal.ZERO) <= 0
+				|| product.getDurationDays() == null || product.getDurationDays() <= 0) {
+			throw BusinessRuntimeException.getInstance("支付套餐配置异常");
+		}
+		String alipayProductCode = getAlipayProductCode(request.getPayType());
+		ShopConfig shopConfig = shopConfigMapper.getByAppId(YING_WANG_FEI_ALIPAY_APP_ID);
+		if (shopConfig == null || !StrUtil.equals("zfb", shopConfig.getType())) {
+			throw BusinessRuntimeException.getInstance("未配置影网飞支付宝商户");
+		}
+		Date now = new Date();
+		SpecialPayOrder order = new SpecialPayOrder();
+		order.setOrderNo("SP" + SEQUENCE.nextId());
+		order.setUserId(userId);
+		order.setProductCode(product.getProductCode());
+		order.setSubject(product.getProductName());
+		order.setAmount(product.getAmount());
+		order.setDurationDays(product.getDurationDays());
+		order.setStatus(SpecialPayOrder.Status.CREATED);
+		order.setFulfillStatus(SpecialPayOrder.FulfillStatus.INIT);
+		order.setShopId(YING_WANG_FEI_ALIPAY_APP_ID);
+		order.setExpireTime(DateUtil.offsetMinute(now, ORDER_EXPIRE_MINUTES));
+		order.setCreatedTime(now);
+		order.setUpdateTime(now);
+		orderMapper.insert(order);
+
+		String notifyUrl = envCommonService.getNotifyHost() + BaseZfbConfig.specialPayNotifyUrl;
+		AliPayParams payParams = aliPayCommonService.builderPayParams(
+				order.getId(), order.getOrderNo(), order.getShopId(), order.getAmount(),
+				order.getCreatedTime(), order.getSubject(), alipayProductCode,
+				request.getPayMode(), notifyUrl, request.getReturnUrl());
+		if (payParams == null || StrUtil.isBlank(payParams.getBody())) {
+			throw BusinessRuntimeException.getInstance("支付宝下单失败");
+		}
+
+		SpecialPayCreateRep response = new SpecialPayCreateRep();
+		response.setOrderNo(order.getOrderNo());
+		response.setStatus(order.getStatus());
+		response.setExpireTime(order.getExpireTime());
+		response.setPayBody(payParams.getBody());
+		return response;
+	}
+
+	@Override
+	public SpecialPayOrder getUserOrder(Long userId, String orderNo) {
+		SpecialPayOrder order = orderMapper.selectOne(Wrappers.lambdaQuery(SpecialPayOrder.class)
+				.eq(SpecialPayOrder::getOrderNo, orderNo)
+				.eq(SpecialPayOrder::getUserId, userId)
+				.last("limit 1"));
+		if (order == null) {
+			throw BusinessRuntimeException.getInstance("订单不存在");
+		}
+		return order;
+	}
+
+	@Override
+	public SpecialMemberEntitlement getMemberEntitlement(Long userId) {
+		return entitlementMapper.selectOne(Wrappers.lambdaQuery(SpecialMemberEntitlement.class)
+				.eq(SpecialMemberEntitlement::getUserId, userId)
+				.last("limit 1"));
+	}
+
+	@Override
+	public SpecialMemberInfoRep getMemberInfo(Long userId) {
+		SpecialMemberEntitlement entitlement = getMemberEntitlement(userId);
+		Date now = new Date();
+		SpecialMemberInfoRep response = new SpecialMemberInfoRep();
+		response.setUserId(userId);
+		response.setMember(entitlement != null && entitlement.getExpireTime() != null
+				&& entitlement.getExpireTime().after(now));
+		response.setExpireTime(entitlement == null ? null : entitlement.getExpireTime());
+		response.setRemainingDays(Boolean.TRUE.equals(response.getMember())
+				? Math.max(1L, (entitlement.getExpireTime().getTime() - now.getTime() + 86400000L - 1) / 86400000L)
+				: 0L);
+		if (entitlement != null && entitlement.getLastOrderId() != null && entitlement.getLastOrderId() > 0) {
+			SpecialPayOrder lastOrder = orderMapper.selectById(entitlement.getLastOrderId());
+			if (lastOrder != null && userId.equals(lastOrder.getUserId())) {
+				response.setLastOrderNo(lastOrder.getOrderNo());
+				response.setProductCode(lastOrder.getProductCode());
+				response.setProductName(lastOrder.getSubject());
+				response.setLastOrderStatus(lastOrder.getStatus());
+			}
+		}
+		return response;
+	}
+
+	@Override
+	@Transactional(rollbackFor = Throwable.class)
+	public void handleAlipayNotify(Map<String, String> params) throws Exception {
+		String appId = params.get("app_id");
+		ShopConfig shopConfig = StrUtil.isBlank(appId) ? null : shopConfigMapper.getByAppId(appId);
+		if (shopConfig == null || !AlipaySignature.rsaCheckV1(params, shopConfig.getPublicKey(),
+				BaseZfbConfig.charset, BaseZfbConfig.signType)) {
+			throw BusinessRuntimeException.getInstance("支付宝回调验签失败");
+		}
+
+		String orderNo = params.get("out_trade_no");
+		SpecialPayOrder order = orderMapper.selectByOrderNoForUpdate(orderNo);
+		if (order == null) {
+			throw BusinessRuntimeException.getInstance("特殊支付订单不存在");
+		}
+		if (!StrUtil.equals(order.getShopId(), appId)) {
+			throw BusinessRuntimeException.getInstance("支付宝回调商户不匹配");
+		}
+		String tradeStatus = params.get("trade_status");
+		if (!AliPayTradeStatus.TRADE_SUCCESS.getStatus().equals(tradeStatus)
+				&& !AliPayTradeStatus.TRADE_FINISHED.getStatus().equals(tradeStatus)) {
+			throw BusinessRuntimeException.getInstance("支付宝交易状态未成功");
+		}
+		verifyOrderIdentity(params, order);
+		verifyAmount(params.get("total_amount"), order.getAmount());
+
+		String tradeNo = params.get("trade_no");
+		if (StrUtil.isBlank(tradeNo)) {
+			throw BusinessRuntimeException.getInstance("支付宝交易号为空");
+		}
+		if (order.getStatus() == SpecialPayOrder.Status.PAID) {
+			if (!StrUtil.equals(order.getAlipayTradeNo(), tradeNo)) {
+				throw BusinessRuntimeException.getInstance("支付宝交易号不匹配");
+			}
+			return;
+		}
+		if (order.getStatus() != SpecialPayOrder.Status.CREATED) {
+			throw BusinessRuntimeException.getInstance("当前订单状态不可支付");
+		}
+
+		Date payTime = new Date();
+		if (StrUtil.isNotBlank(params.get("gmt_payment"))) {
+			payTime = DateUtil.parse(params.get("gmt_payment"), "yyyy-MM-dd HH:mm:ss");
+		}
+		String buyerId = Optional.ofNullable(params.get("buyer_id"))
+				.orElse(params.get("buyer_open_id"));
+		if (orderMapper.markPaid(order.getId(), tradeNo, buyerId, payTime) != 1) {
+			throw BusinessRuntimeException.getInstance("订单支付状态更新失败");
+		}
+
+		if (order.getDurationDays() == null || order.getDurationDays() <= 0) {
+			throw BusinessRuntimeException.getInstance("订单套餐配置异常");
+		}
+		entitlementMapper.extend(order.getUserId(), order.getDurationDays(), order.getId());
+		if (orderMapper.markFulfilled(order.getId()) != 1) {
+			throw BusinessRuntimeException.getInstance("会员权益发放失败");
+		}
+		log.info("特殊支付宝订单支付并履约成功, orderNo:{}, tradeNo:{}", orderNo, tradeNo);
+	}
+
+	private String getAlipayProductCode(Integer payType) {
+		if (Integer.valueOf(1).equals(payType)) {
+			return ZfbProductCode.MOBILE_WEB.getProductCode();
+		}
+		if (Integer.valueOf(2).equals(payType)) {
+			return ZfbProductCode.PC_WEB.getProductCode();
+		}
+		throw BusinessRuntimeException.getInstance("不支持的支付宝支付方式");
+	}
+
+	private void verifyOrderIdentity(Map<String, String> params, SpecialPayOrder order) {
+		try {
+			String body = Codec.DoBase64.custom().setData(params.get("body")).decodeAsText();
+			OrderAttach attach = Jsons.parseObject(body, OrderAttach.class);
+			if (attach == null || !order.getId().equals(attach.getO())
+					|| !StrUtil.equals(order.getOrderNo(), params.get("out_trade_no"))) {
+				throw BusinessRuntimeException.getInstance("支付宝回调订单不匹配");
+			}
+		} catch (BusinessRuntimeException e) {
+			throw e;
+		} catch (Exception e) {
+			throw BusinessRuntimeException.getInstance("支付宝回调订单参数异常");
+		}
+	}
+
+	private void verifyAmount(String totalAmount, BigDecimal expectedCents) {
+		try {
+			BigDecimal callbackCents = new BigDecimal(totalAmount)
+					.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.UNNECESSARY);
+			if (expectedCents == null || callbackCents.compareTo(expectedCents) != 0) {
+				throw BusinessRuntimeException.getInstance("支付宝回调金额不匹配");
+			}
+		} catch (BusinessRuntimeException e) {
+			throw e;
+		} catch (Exception e) {
+			throw BusinessRuntimeException.getInstance("支付宝回调金额异常");
+		}
+	}
+}

+ 33 - 0
netflix-web/src/main/java/com/cyksj/web/controller/manage/SpecialPayManageController.java

@@ -0,0 +1,33 @@
+package com.cyksj.web.controller.manage;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.cyksj.dto.Result;
+import com.cyksj.enums.GatewayResponse;
+import com.cyksj.model.entity.SpecialPayOrder;
+import com.cyksj.service.special.SpecialPayService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/manage/special/member/orders")
+public class SpecialPayManageController {
+
+	private final SpecialPayService specialPayService;
+
+	@GetMapping
+	public Result<Page<SpecialPayOrder>> page(
+			@RequestParam(defaultValue = "1") Integer start,
+			@RequestParam(defaultValue = "10") Integer limit,
+			String orderNo, Long userId, String productCode, SpecialPayOrder.Status status) {
+		return GatewayResponse.SUCCESS.newBuilder().toResult(
+				specialPayService.getManageOrderPage(start, limit, orderNo, userId, productCode, status));
+	}
+
+	@PostMapping("/{orderNo}/refund")
+	public Result refund(@PathVariable String orderNo) throws Exception {
+		specialPayService.refundOrder(orderNo);
+		return GatewayResponse.SUCCESS.newBuilder().toResult();
+	}
+}

+ 79 - 0
netflix-web/src/main/java/com/cyksj/web/controller/special/SpecialPayController.java

@@ -0,0 +1,79 @@
+package com.cyksj.web.controller.special;
+
+import com.cyksj.dto.Result;
+import com.cyksj.enums.GatewayResponse;
+import com.cyksj.model.entity.SpecialPayOrder;
+import com.cyksj.model.entity.SpecialPayProduct;
+import com.cyksj.model.entity.SpecialMemberEntitlement;
+import com.cyksj.model.request.SpecialPayCreateReq;
+import com.cyksj.model.response.SpecialPayCreateRep;
+import com.cyksj.model.response.SpecialMemberInfoRep;
+import com.cyksj.service.special.SpecialPayService;
+import com.cyksj.web.util.StpUserUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.*;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/applets/special/member")
+public class SpecialPayController {
+
+	private final SpecialPayService specialPayService;
+
+	@GetMapping("/products")
+	public Result<List<SpecialPayProduct>> products() {
+		return GatewayResponse.SUCCESS.newBuilder().toResult(specialPayService.getEnabledProducts());
+	}
+
+	@PostMapping("/orders/alipay")
+	public Result<SpecialPayCreateRep> createAlipayOrder(@RequestBody @Validated SpecialPayCreateReq request) throws Exception {
+		long userId = StpUserUtil.getLoginIdAsLong();
+		return GatewayResponse.SUCCESS.newBuilder()
+				.toResult(specialPayService.createAlipayOrder(userId, request));
+	}
+
+	@GetMapping("/orders/{orderNo}")
+	public Result<SpecialPayOrder> getOrder(@PathVariable String orderNo) {
+		long userId = StpUserUtil.getLoginIdAsLong();
+		return GatewayResponse.SUCCESS.newBuilder().toResult(specialPayService.getUserOrder(userId, orderNo));
+	}
+
+	@GetMapping("/entitlement")
+	public Result<SpecialMemberEntitlement> getEntitlement() {
+		long userId = StpUserUtil.getLoginIdAsLong();
+		return GatewayResponse.SUCCESS.newBuilder().toResult(specialPayService.getMemberEntitlement(userId));
+	}
+
+	@GetMapping("/info")
+	public Result<SpecialMemberInfoRep> getMemberInfo() {
+		return GatewayResponse.SUCCESS.newBuilder()
+				.toResult(specialPayService.getMemberInfo(StpUserUtil.getLoginIdAsLong()));
+	}
+
+	@PostMapping("/alipay/notify")
+	public String alipayNotify(HttpServletRequest request) {
+		Map<String, String> params = convertRequestParamsToMap(request);
+		try {
+			specialPayService.handleAlipayNotify(params);
+			return "success";
+		} catch (Exception e) {
+			log.error("特殊支付宝订单回调处理失败, orderNo:{}", params.get("out_trade_no"), e);
+			return "fail";
+		}
+	}
+
+	private Map<String, String> convertRequestParamsToMap(HttpServletRequest request) {
+		Map<String, String> params = new HashMap<>();
+		request.getParameterMap().forEach((name, values) ->
+				params.put(name, values == null ? "" : String.join(",", values)));
+		return params;
+	}
+}