Merge remote-tracking branch 'origin/master'

# Conflicts:
#	pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
This commit is contained in:
zjf committed 2026-08-06 22:21:37 +08:00
commit f7ca3ef396
84 files changed
+9844 -262

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
-- 支付宝与微信聚合订单码支付。
-- 聚合会话与实际第三方支付订单分表保存,避免改变已被多个调用方使用的 third_pay_order 契约。
-- 微信统一使用服务商平台公众号配置和各商户既有 sub_mchid,不新增逐商户公众号配置表。
CREATE TABLE `third_aggregate_pay_session`
(
`id` bigint NOT NULL COMMENT '聚合会话主键',
`aggregate_no` varchar(64) NOT NULL COMMENT '聚合支付会话唯一编号',
`access_token_hash` char(64) NOT NULL COMMENT '收银URL随机访问令牌SHA-256摘要',
`access_token_ciphertext` varchar(512) NOT NULL COMMENT '收银URL随机访问令牌Jasypt密文,仅用于幂等复用',
`biz_type` varchar(32) NOT NULL COMMENT '支付业务类型编码,首版为YXFJ_PAY对应编码',
`biz_id` varchar(128) NOT NULL COMMENT '调用方业务订单唯一标识',
`user_id` bigint NOT NULL COMMENT '付款用户ID,匿名消费机订单为-1',
`mch_id` bigint NOT NULL COMMENT '收款商户ID',
`price` int NOT NULL COMMENT '应付金额,单位为人民币分',
`status` varchar(32) NOT NULL COMMENT '聚合会话状态编码',
`selected_platform` varchar(32) DEFAULT NULL COMMENT '首个有效扫码选定的支付平台编码',
`pay_no` varchar(128) DEFAULT NULL COMMENT '绑定的third_pay_order.pay_no',
`payer_identity_hash` char(64) DEFAULT NULL COMMENT '支付宝用户ID或微信服务商公众号sp_openid的SHA-256摘要',
`expire_time` datetime NOT NULL COMMENT '聚合二维码与第三方子订单服务端失效时间',
`resolved_time` datetime DEFAULT NULL COMMENT '会话进入成功、关闭、过期或创建失败终态的时间',
`create_by` varchar(64) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL COMMENT '创建时间',
`update_by` varchar(64) DEFAULT NULL COMMENT '修改人',
`update_time` datetime NOT NULL COMMENT '修改时间',
`del_flag` char(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记,0有效,1删除',
`tenant_id` bigint NOT NULL COMMENT '租户ID',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_third_aggregate_pay_no` (`tenant_id`, `aggregate_no`),
UNIQUE KEY `uk_third_aggregate_access_token` (`tenant_id`, `access_token_hash`),
UNIQUE KEY `uk_third_aggregate_child_pay_no` (`tenant_id`, `pay_no`),
KEY `idx_third_aggregate_biz` (`tenant_id`, `biz_type`, `biz_id`, `create_time`),
KEY `idx_third_aggregate_job` (`status`, `expire_time`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_general_ci
COMMENT = '支付宝与微信聚合订单码支付会话';
+6
View File
@@ -0,0 +1,6 @@
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DB=pjcloudx_config
MYSQL_USER=root
MYSQL_PWD=root
@@ -23,6 +23,7 @@ package com.bsd.pjcloud.canteen.api.feign;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenOnlineSpuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import org.springframework.cloud.openfeign.FeignClient;
@@ -33,6 +34,7 @@ import org.springframework.web.bind.annotation.RequestParam;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* @author zjf
@@ -73,6 +75,15 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/getSpuListByIds")
R<List<CanteenSpuEntity>> getSpuListByIds(@RequestBody List<Long> ids);
/**
* 按套餐 SPU ID 批量查询套餐组成,用于跨服务补充只读展示数据。
*
* @param spuIds 套餐 SPU ID 列表,空列表返回空映射
* @return 套餐 SPU ID 与已按配置顺序排列的组成菜品映射
*/
@PostMapping("/canteenSpu/ad/getComboItemsBySpuIds")
R<Map<Long, List<OnlineSpuComboItemVO>>> getComboItemsBySpuIds(@RequestBody List<Long> spuIds);
/**
* 购物车效验库存
* @param spuId
@@ -26,9 +26,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.APPOnlineSpuVO;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import io.swagger.v3.oas.annotations.Operation;
@@ -70,17 +70,18 @@ public interface RemoteDailyMenuService {
/**
* 根据菜品日期获取当天的菜品列表
* @param day 日期
* @param hourType 早中晚
* @param categoryId 分类id
* @param merchantId 商户id
* @return
* 查询收银终端指定日期的每日菜谱,返回与 App 分页记录一致的商品及套餐组成结构。
*
* @param day 菜谱生效日期,格式为 yyyy-MM-dd
* @param hourType 餐段编码;为空表示全部餐段
* @param categoryId 菜品分类 ID;为空表示全部分类
* @param merchantId 收银终端所属商户 ID
* @return 指定条件下的每日菜谱列表;无可售菜品时 data 为空列表
*/
@GetMapping("/canteenDailyMenu/ad/getCanteenList")
public R<List<ADCanteenDailyMenuListVO>> getCanteenList(@RequestParam("day") String day,
@RequestParam(value = "hourType", required = false) Integer hourType,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "merchantId") Long merchantId);
R<List<DailyMenuAppVO>> getCanteenList(@RequestParam("day") String day,
@RequestParam(value = "hourType", required = false) Integer hourType,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "merchantId") Long merchantId);
}
@@ -2,59 +2,112 @@ package com.bsd.pjcloud.canteen.vo.app;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* App 每日菜谱展示信息,聚合菜谱日期、餐段和关联菜品的详情字段。
*/
@Data
public class DailyMenuAppVO {
/**
* 每日菜谱记录 ID,是 App 详情接口和页面跳转使用的唯一标识。
*/
private Long id;
/**
* 菜品id
* 菜谱生效日期。
*/
private LocalDate startTime;
/**
* 菜谱所属商户 ID。
*/
private Long merchantId;
/**
* 关联菜品 SPU ID,用于套餐组成查询和下单。
*/
private Long spuId;
/**
* 菜品名称
* 菜品名称。
*/
private String name;
/**
* 商品价格,单位使用:分
* 商品销售价格,单位为分。
*/
private Integer price;
/**
* 菜品分类编号
* 商品市场价格,单位为分;为空时不展示划线价。
*/
private Integer marketPrice;
/**
* 菜品分类编号。
*/
private Long categoryId;
/**
* 菜品封面图
* 菜品分类名称;分类不存在或已删除时为空。
*/
private String categoryName;
/**
* 菜品封面图地址。
*/
private String picUrl;
/**
* 库存
* 商品轮播图地址,多个地址按逗号分隔。
*/
private String sliderPicUrls;
/**
* 商品简介;为空时详情页不展示简介区域。
*/
private String introduction;
/**
* 商品富文本详情;为空或仅包含空标签时详情页不展示图文区域。
*/
private String description;
/**
* 当前可售库存。
*/
private Integer stock;
/**
* 时间类型
* 商品销量,仅用于详情页展示。
*/
private Integer salesCount;
/**
* 餐段类型:1 早餐、2 午餐、3 晚餐。
*/
private Integer hourType;
/**
* 菜品展示排序,数值越小越靠前。
*/
private Integer sort;
/**
* 是否显示
* 是否显示,沿用菜品表协议值;App 查询只返回值为 0 的记录。
*/
private String isShow;
/**
* 商品类型:1单品 2套餐
*/
private Integer spuType;
/**
* 商品类型:1 单品、2 套餐。
*/
private Integer spuType;
/**
* 套餐组成
*/
private List<OnlineSpuComboItemVO> comboItems;
/**
* 套餐组成;单品或套餐未配置明细时返回空列表。
*/
private List<OnlineSpuComboItemVO> comboItems;
}
@@ -14,7 +14,7 @@ import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
@@ -189,14 +189,22 @@ public class CanteenDailyMenuController {
// ---------AD------------
/**
* 查询收银终端指定日期的每日菜谱,并返回套餐类型及其组成明细。
*
* @param day 菜谱生效日期,格式为 yyyy-MM-dd
* @param hourType 餐段编码;为空表示全部餐段
* @param categoryId 菜品分类 ID;为空表示全部分类
* @param merchantId 收银终端所属商户 ID
* @return 与 App 每日菜谱分页记录一致的商品列表;无数据时返回空列表
*/
@GetMapping("/ad/getCanteenList")
@SysLog("查询每日菜品列表")
public R<List<ADCanteenDailyMenuListVO>> getCanteenList(@RequestParam("day") String day,
@RequestParam(value = "hourType", required = false) Integer hourType,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "merchantId") Long merchantId) {
return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId,merchantId));
public R<List<DailyMenuAppVO>> getCanteenList(@RequestParam("day") String day,
@RequestParam(value = "hourType", required = false) Integer hourType,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "merchantId") Long merchantId) {
return R.ok(canteenDailyMenuService.getCanteenMenuListScope(day, hourType, categoryId, merchantId));
}
// --------------APP---------------
@@ -212,6 +220,19 @@ public class CanteenDailyMenuController {
return R.ok(canteenDailyMenuService.selectTimeFoodPage(page, dailyMenuDTO));
}
/**
* 按每日菜谱记录 ID 查询 App 详情。
*
* @param id 每日菜谱记录 ID
* @return 每日菜谱详情;记录不存在、已删除或菜品已隐藏时 data 为空
*/
@Operation(summary = "查询每日菜谱详情", description = "按每日菜谱记录 ID 查询 App 展示详情")
@ApiCryptoAes
@GetMapping("/app/{id}")
public R<DailyMenuAppVO> getAppDetail(@PathVariable("id") Long id) {
return R.ok(canteenDailyMenuService.getAppDetail(id));
}
/**
* 获取当天指定菜品
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenStockEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenStockMapper;
import com.bsd.pjcloud.canteen.service.CanteenStockService;
import com.bsd.pjcloud.canteen.vo.ADCanteenSpuListVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuSimpleVO;
import com.bsd.pjcloud.canteen.vo.erp.CanteenSpuStockVO;
import com.bsd.pjcloud.common.core.util.BeanUtils;
@@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
@@ -290,6 +292,17 @@ public class CanteenSpuController {
return R.ok(list);
}
/**
* 按套餐 SPU ID 批量查询套餐组成,供订单等内部业务补充只读展示信息。
*
* @param spuIds 套餐 SPU ID 列表,空列表返回空映射
* @return 套餐 SPU ID 与组成菜品列表的映射
*/
@PostMapping("/ad/getComboItemsBySpuIds")
public R<Map<Long, List<OnlineSpuComboItemVO>>> getComboItemsBySpuIds(@RequestBody List<Long> spuIds) {
return R.ok(canteenSpuService.loadComboItemVos(spuIds));
}
@ApiCryptoAes
@GetMapping("/app/checkSpuStock")
@@ -2,7 +2,6 @@ package com.bsd.pjcloud.canteen.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
@@ -22,16 +21,27 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
/**
* ad查询菜品列表
* @param day
* @param hourType
* @param categoryId
* @return
* 查询收银终端每日菜谱展示数据,字段与 App 分页查询保持一致。
*
* @param day 菜谱生效日期,格式为 yyyy-MM-dd
* @param hourType 餐段编码;为空表示全部餐段
* @param categoryId 菜品分类 ID;为空表示全部分类
* @param merchantId 商户 ID
* @param dataScope 当前数据权限范围
* @return 菜谱与菜品展示字段列表,套餐明细由 Service 批量补充
*/
List<ADCanteenDailyMenuListVO> getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType,
@Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId,DataScope dataScope);
List<DailyMenuAppVO> getCanteenMenuList(@Param("day") String day, @Param("hourType") Integer hourType,
@Param("categoryId") Long categoryId, @Param("merchantId") Long merchantId, DataScope dataScope);
Page<DailyMenuAppVO> selectTimeFoodPage(@Param("page")Page page, DailyMenuDTO dailyMenuDTO);
}
/**
* 按每日菜谱记录 ID 查询 App 详情,结果聚合菜谱、菜品和分类的展示字段。
*
* @param id 每日菜谱记录 ID
* @return 可展示的每日菜谱详情;记录不存在、已删除或菜品已隐藏时返回 null
*/
DailyMenuAppVO selectAppDetail(@Param("id") Long id);
}
@@ -6,7 +6,7 @@ import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO;
import com.bsd.pjcloud.canteen.dto.DailyMenuAll;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
@@ -42,17 +42,26 @@ public interface CanteenDailyMenuService extends IService<CanteenDailyMenuEntity
/**
* ad机获取每次菜品
* 查询收银终端指定日期的每日菜谱,并补充与 App 分页接口一致的套餐组成数据。
*
* @param day
* @param hourType
* @param categoryId
* @return
* @param day 菜谱生效日期,格式为 yyyy-MM-dd
* @param hourType 餐段编码;为空表示全部餐段
* @param categoryId 菜品分类 ID;为空表示全部分类
* @param merchantId 商户 ID,用于隔离终端所属商户的数据
* @return 与 App 每日菜谱分页记录一致的展示字段列表
*/
List<ADCanteenDailyMenuListVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId);
List<DailyMenuAppVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId);
Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO);
/**
* 按每日菜谱记录 ID 查询 App 详情,并补充套餐组成数据。
*
* @param id 每日菜谱记录 ID
* @return 每日菜谱详情;记录不存在、已删除或菜品已隐藏时返回 null
*/
DailyMenuAppVO getAppDetail(Long id);
/**
* 一键复制
@@ -60,4 +69,4 @@ public interface CanteenDailyMenuService extends IService<CanteenDailyMenuEntity
* @return
*/
R cv(CanteenDailyMenuCVDTO creenDailyMenuCVDTO);
}
}
@@ -11,7 +11,6 @@ import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuMapper;
@@ -138,9 +137,20 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
return true;
}
/**
* 查询收银终端每日菜谱,并批量补充套餐组成以避免逐条查询。
*
* @param day 菜谱生效日期,格式为 yyyy-MM-dd
* @param hourType 餐段编码;为空表示全部餐段
* @param categoryId 菜品分类 ID;为空表示全部分类
* @param merchantId 收银终端所属商户 ID
* @return 可售菜谱列表;单品的套餐组成固定为空列表
*/
@Override
public List<ADCanteenDailyMenuListVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
return baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of());
public List<DailyMenuAppVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
List<DailyMenuAppVO> list = baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of());
fillDailyMenuAppCombo(list);
return list;
}
@Override
@@ -150,6 +160,21 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
return pageData;
}
/**
* 按每日菜谱记录 ID 查询 App 详情,并使用关联 SPU 补齐套餐组成。
*
* @param id 每日菜谱记录 ID
* @return 可展示的每日菜谱详情;无有效记录时返回 null
*/
@Override
public DailyMenuAppVO getAppDetail(Long id) {
DailyMenuAppVO detail = baseMapper.selectAppDetail(id);
if (detail != null) {
fillDailyMenuAppCombo(Collections.singletonList(detail));
}
return detail;
}
private void fillDailyMenuCombo(List<DailyMenuVO> list) {
if (CollectionUtils.isEmpty(list)) {
return;
@@ -249,4 +274,4 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
if (ObjectUtil.isEmpty(mchId)) return;
Optional.of(remoteMchService.getById(mchId)).map(R::getData).map(MchManageVo::getDeptId).ifPresent(remoteMqttEventService::sendCanteenProductChangeMsg);
}
}
}
@@ -17,17 +17,26 @@
<result property="tenantId" column="tenant_id"/>
</resultMap>
<resultMap id="adCanteenDailyMenuListVO" type="com.bsd.pjcloud.canteen.vo.ADCanteenDailyMenuListVO">
<resultMap id="adDailyMenuAppVO" type="com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO">
<id property="id" column="id"/>
<result property="name" column="name"/>
<result property="startTime" column="start_time"/>
<result property="merchantId" column="merchant_id"/>
<result property="spuId" column="spu_id"/>
<result property="hourType" column="hour_type"/>
<result property="name" column="name"/>
<result property="price" column="price"/>
<result property="marketPrice" column="market_price"/>
<result property="categoryId" column="category_id"/>
<result property="categoryName" column="category_name"/>
<result property="picUrl" column="pic_url"/>
<result property="sliderPicUrls" column="slider_pic_urls"/>
<result property="introduction" column="introduction"/>
<result property="description" column="description"/>
<result property="stock" column="stock"/>
<result property="sort" column="sort"/>
<result property="salesCount" column="sales_count"/>
<result property="hourType" column="hour_type"/>
<result property="sort" column="sort"/>
<result property="isShow" column="is_show"/>
<result property="spuType" column="spu_type"/>
</resultMap>
<select id="selectTimeFood" resultType="com.bsd.pjcloud.canteen.vo.DailyMenuVO">
SELECT
@@ -54,24 +63,37 @@
</where>
order by sp.sort asc
</select>
<select id="getCanteenMenuList" resultMap="adCanteenDailyMenuListVO">
<select id="getCanteenMenuList" resultMap="adDailyMenuAppVO">
SELECT
sp.id as spu_id,
t.id,
t.start_time,
t.merchant_id,
sp.id AS spu_id,
sp.name,
sp.pic_url,
t.hour_type,
sp.price,
sp.market_price,
sp.pic_url,
sp.slider_pic_urls,
sp.introduction,
sp.description,
sp.stock,
sp.category_id,
sp.sales_count,
sp.sort,
sp.is_show,
c.id AS category_id,
c.name AS category_name,
IFNULL(sp.spu_type, 1) AS spu_type,
t.hour_type,
<!-- 数据权限拦截器会在当前查询外层按部门和创建人过滤,必须在临时结果集中透传这两个字段。 -->
t.dept_id,
t.create_by
FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp
on t.spu_id=sp.id
LEFT JOIN canteen_category c
on sp.category_id = c.id AND c.del_flag = 0
<where>
sp.del_flag = 0 and t.del_flag = 0
sp.del_flag = 0 and t.del_flag = 0 and sp.is_show = 0
<if test="categoryId != null">
AND sp.category_id = #{categoryId}
</if>
@@ -85,6 +107,7 @@
AND t.merchant_id = #{merchantId}
</if>
</where>
order by sp.sort asc
</select>
<select id="selectTimeFoodPage" resultType="com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO"
parameterType="com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO">
@@ -118,4 +141,34 @@
</where>
order by s.sort asc
</select>
</mapper>
<select id="selectAppDetail" resultType="com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO">
SELECT m.id,
m.start_time,
m.hour_type,
m.merchant_id,
s.id AS spu_id,
s.name,
s.price,
s.market_price,
s.stock,
s.sales_count,
s.pic_url,
s.slider_pic_urls,
s.introduction,
s.description,
s.sort,
s.is_show,
c.id AS category_id,
c.name AS category_name,
IFNULL(s.spu_type, 1) AS spu_type
FROM canteen_daily_menu m
INNER JOIN canteen_spu s ON m.spu_id = s.id
LEFT JOIN canteen_category c ON s.category_id = c.id AND c.del_flag = 0
WHERE m.id = #{id}
AND m.del_flag = 0
AND s.del_flag = 0
AND s.is_show = 0
LIMIT 1
</select>
</mapper>
@@ -3,7 +3,6 @@ package com.bsd.pjcloud.hard.api.constant.order;
import cn.hutool.core.util.ObjectUtil;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
@@ -12,36 +11,60 @@ import java.util.List;
/**
* 硬件订单支付方式
*/
@AllArgsConstructor
@Getter
public enum HardPayTypeEnum {
/** 默认钱包余额支付。 */
WALLET_DEFAULT("1", "钱包支付"),
/** 用户向终端出示自有钱包二维码支付。 */
WALLET_QR_CODE("2", "钱包二维码支付"),
/** 旧版终端展示自有钱包订单二维码,由移动端扫码确认支付。 */
WALLET_QR_CODE_SCAN("22", "钱包扫一扫支付"),
/** 终端展示支付宝与微信共用的 HTTPS 聚合收银二维码。 */
AGGREGATE_ORDER_PAY("23", "支付宝微信聚合码支付"),
/** 实体饭卡钱包支付。 */
WALLET_CARD("3", "钱包饭卡支付"),
/** 人脸识别钱包支付。 */
FACE("4", "人脸识别支付"),
/** 跨单位业务二维码支付。 */
STRIDE_DEPT_QR_CODE("5", "跨单位二维码支付"),
/** 外来人员票券二维码支付。 */
FOREIGN_TICKET_QR_CODE("6", "外来人员二维码支付"),
/** 线上预约订单二维码核销支付。 */
RESERVE_QR_CODE("7", "线上二维码支付"),
/** 终端线下现金支付。 */
CASH("8", "现金支付"),
/** 智能柜免密支付。 */
HARD_DOOR_FREE_PAY("9", "智能柜免密支付"),
/** 微信用户付款码支付。 */
WECHANT_SELF_QR_CODE_PAY("10", "微信付款码支付"),
/** 微信 App 支付。 */
WECHANT_APP_PAY("100", "微信App支付"),
/** 支付宝用户付款码支付。 */
ALI_SELF_QR_CODE_PAY("11", "支付宝付款码支付"),
/** 支付宝 App 支付。 */
ALI_APP_PAY("110", "支付宝App支付"),
/** 中医院渠道支付。 */
ZYY("12", "中医院"),
/** 人民医院渠道支付。 */
RMYY("13", "人民医院"),
/** 超市消费卡渠道支付。 */
CSXFK("14", "超市消费卡"),
/** 活动订单核销。 */
ACT_CANCEL("50", "活动核销"),
;
/** 数据库及跨端协议使用的支付类型编码。 */
private final String type;
/** 支付类型的中文业务说明。 */
private final String description;
// 支付方式交易记录
/** 订单统计和管理端支付方式汇总需要展示的类型集合。 */
public static final List<HardPayTypeEnum> payTypeTotalDisplayList = List.of(
WALLET_DEFAULT,
WALLET_QR_CODE,
WALLET_QR_CODE_SCAN,
AGGREGATE_ORDER_PAY,
WALLET_CARD,
FACE,
CASH,
@@ -52,22 +75,24 @@ public enum HardPayTypeEnum {
CSXFK
);
/** 不允许进入钱包扣款逻辑的外部渠道支付类型集合。 */
public static final List<HardPayTypeEnum> isNotWalletPay = List.of(
STRIDE_DEPT_QR_CODE,
FOREIGN_TICKET_QR_CODE,
AGGREGATE_ORDER_PAY,
WECHANT_SELF_QR_CODE_PAY,
ALI_SELF_QR_CODE_PAY,
WECHANT_APP_PAY,
ALI_APP_PAY
);
// 可以核销的订单
/** 支付完成后仍需要执行业务核销的二维码类型集合。 */
public static final List<HardPayTypeEnum> isNeedCancel = List.of(
STRIDE_DEPT_QR_CODE,
FOREIGN_TICKET_QR_CODE
);
// 线下支付的订单类型(现金支付,其他渠道支付如中医院、人民医院)
/** 无需在线支付平台确认的线下支付类型集合。 */
public static final List<HardPayTypeEnum> isOffline = List.of(
CASH,
ZYY,
@@ -75,13 +100,30 @@ public enum HardPayTypeEnum {
CSXFK
);
// 简单支付(没有购物车)
/** 不要求完整购物车业务链路的简单支付类型集合。 */
public static final List<HardPayTypeEnum> isSimplePayList = List.of(
WALLET_QR_CODE_SCAN,
ACT_CANCEL,
WALLET_CARD
);
/**
* 创建一个 Hardware 支付方式枚举值。
*
* @param type 数据库和接口使用的稳定编码
* @param description 中文业务说明
*/
HardPayTypeEnum(String type, String description) {
this.type = type;
this.description = description;
}
/**
* 将 App 支付平台转换为 Hardware 订单支付方式。
*
* @param appPlusPlatformTypeEnum App 支付平台
* @return 对应支付方式;不支持的平台返回 {@code null}
*/
public static HardPayTypeEnum transformHardPayTypeEnum(AppPlusPlatformTypeEnum appPlusPlatformTypeEnum) {
switch (appPlusPlatformTypeEnum) {
case WECHAT:
@@ -95,6 +137,12 @@ public enum HardPayTypeEnum {
}
}
/**
* 按持久化编码解析支付方式。
*
* @param type 数据库或接口支付类型编码
* @return 匹配枚举;为空或未知时返回 {@code null}
*/
public static HardPayTypeEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) return null;
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null);
@@ -0,0 +1,24 @@
package com.bsd.pjcloud.hard.api.dto.app;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* App 批量新增或更新购物车请求。
*
* <p>每个分组会生成独立父购物车,用于保留多日期、多餐段订餐时各组菜品的边界。</p>
*/
@Data
public class HardCarBatchSaveOrUpdateDto {
/**
* 购物车分组列表;同一次请求中的分组必须属于同一用户、商户和硬件业务类型。
*/
@Valid
@NotEmpty(message = "购物车分组不能为空")
private List<@NotNull(message = "购物车分组不能为空") HardCarSaveOrUpdateDto> groups;
}
@@ -0,0 +1,88 @@
package com.bsd.pjcloud.hard.api.dto.order.app;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* App 每日菜谱多日期、多餐段批量订餐请求。
*
* <p>一个分组对应一个父购物车和一个预约日期餐段,服务端会为每个分组下的每位实际用餐人
* 创建独立订单,并统一从当前付款人选择的钱包扣款。</p>
*/
@Data
public class HardAppCreateDailyMenuReserveOrdersDto {
/**
* 实际用餐人 ID 列表,只允许当前付款人本人及其有效被监护人,最多同时为 20 人订餐。
*/
@NotEmpty(message = "用餐人不能为空")
@Size(max = 20, message = "一次最多选择20位用餐人")
private List<Long> dinerUserIds;
/**
* 支付平台类型,当前接口只支持 App 钱包编码 30,避免把微信或支付宝选择误处理为钱包直扣。
*/
@NotNull(message = "支付方式不能为空")
@Pattern(regexp = "30", message = "每日菜谱批量订餐暂仅支持钱包支付")
private String payType;
/**
* 付款人本次选择的钱包类型 ID,服务端会按当前登录用户再次校验钱包归属和余额。
*/
@NotNull(message = "钱包类型不能为空")
private Long walletTypeId;
/**
* 按日期餐段拆分的购物车及预约上下文,最多提交 31 个分组。
*/
@Valid
@NotEmpty(message = "订餐分组不能为空")
@Size(max = 31, message = "一次最多提交31个日期餐段")
private List<Group> groups;
/**
* 单个日期餐段的购物车和预约参数。
*/
@Data
public static class Group {
/**
* 每日菜谱批量购物车接口返回的父购物车 ID,必须属于当前付款人且尚未创建订单。
*/
@NotNull(message = "购物车不能为空")
private Long carParentId;
/**
* 动态预约 ID;周期预约场景可为空,由预约规则 ID 定位配置。
*/
private Long apptId;
/**
* 周期预约规则 ID;动态预约场景可为空,由动态预约 ID 定位配置。
*/
private Long apptRuleId;
/**
* 餐段协议编码,服务端会与预约配置解析结果保持一致。
*/
private String hourType;
/**
* 预约时间类型,沿用预约列表返回的协议值并参与预约有效性校验。
*/
private String apptTimeType;
/**
* 实际用餐日期,精确到日,并用于同日同餐段重复预约校验。
*/
@NotNull(message = "预约日期不能为空")
private LocalDate apptDate;
}
}
@@ -106,8 +106,17 @@ public class HardParentCarEntity extends Model<HardParentCarEntity> {
@Schema(description = "租户ID")
private Long tenantId;
/**
* 父购物车扩展配置,统一序列化到现有 JSON 列;字段均为可选以兼容历史购物车数据。
*/
@Data
public static class TakeOutSetting {
/**
* 购物车业务场景;每日菜谱写入 DAILY_MENU,历史外卖购物车为空。
* 该值保存在现有 JSON 列中,不新增数据库字段。
*/
private String cartScene;
/**
* 起送价格(分)
*/
@@ -124,4 +133,4 @@ public class HardParentCarEntity extends Model<HardParentCarEntity> {
private Integer userTakeOutAmt;
}
}
}
@@ -73,9 +73,13 @@ public class ReservationMenuQueryVo {
private List<OrderItem> items;
}
/** 单笔预约订单中的商品及其套餐展示信息。 */
@Data
public static class OrderItem {
/** 菜品库商品 SPU ID;历史订单数据缺失时可能为空。 */
private Long productId;
/** 该笔预约订单购买的商品名称。 */
private String productName;
@@ -84,5 +88,34 @@ public class ReservationMenuQueryVo {
/** 该笔预约订单购买的商品数量;历史异常数据缺失时可能为空。 */
private Integer productCount;
/** 商品类型:1 为单品,2 为套餐;商品元数据不可用时按单品返回。 */
private Integer spuType;
/** 套餐组成明细;单品或套餐元数据暂不可用时返回空列表。 */
private List<ComboItem> comboItems;
}
/** 套餐内单个组成菜品的只读展示信息。 */
@Data
public static class ComboItem {
/** 套餐组成菜品的 SPU ID。 */
private Long childSpuId;
/** 套餐组成菜品名称;历史关联菜品缺失时可能为空。 */
private String name;
/** 套餐组成菜品封面地址;未配置时可能为空。 */
private String picUrl;
/** 套餐内该组成菜品的份数。 */
private Integer quantity;
/** 套餐组成的显示顺序。 */
private Integer sortOrder;
/** 套餐组成菜品的展示单价,单位为分。 */
private Integer price;
}
}
@@ -0,0 +1,52 @@
package com.bsd.pjcloud.hard.api.vo.order;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 收银终端聚合订单码支付状态。
*
* <p>状态由 Hardware 服务核对聚合编号与业务订单绑定关系后返回,支付成功时已同步完成
* Hardware 订单和库存的幂等更新。</p>
*/
@Data
@Builder
@Schema(description = "收银终端聚合订单码支付状态")
public class HardAggregateOrderCodePayStatusVo {
/** Hardware 服务业务订单 ID。 */
@Schema(description = "硬件业务订单ID")
private Long orderId;
/** 与硬件订单 {@code order_no} 严格绑定的聚合会话编号。 */
@Schema(description = "聚合支付会话编号")
private String aggregateNo;
/** 聚合支付会话状态编码。 */
@Schema(description = "聚合支付会话状态编码")
private String status;
/** 首个有效扫码选定的平台,尚未扫码时为空。 */
@Schema(description = "选定支付平台")
private AppPlusPlatformTypeEnum selectedPlatform;
/** 聚合会话创建的支付宝或微信实际支付订单号,尚未创建时为空。 */
@Schema(description = "第三方支付子订单号")
private String payNo;
/** 是否已由支付平台确认成功并同步完成 Hardware 订单。 */
@Schema(description = "是否支付成功")
private Boolean paySuccess;
/** 聚合二维码服务端失效时间,使用服务端本地时区。 */
@Schema(description = "聚合二维码失效时间")
private LocalDateTime expireTime;
/** Hardware 业务订单当前状态编码。 */
@Schema(description = "硬件业务订单状态编码")
private String orderStatus;
}
@@ -0,0 +1,42 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 收银终端支付宝与微信聚合订单码创建结果。
*
* <p>该模型只承载聚合收银地址,不得用于自有钱包二维码或平台原生支付二维码。</p>
*/
@Data
@Builder
@Schema(description = "收银终端聚合订单码创建结果")
public class HardAggregateOrderCodePayVo {
/** Hardware 服务创建的业务订单 ID,用于后续聚合支付状态查询。 */
@Schema(description = "硬件业务订单ID")
private Long orderId;
/** 收款商户 ID,与创建请求中的终端绑定商户一致。 */
@Schema(description = "收款商户ID")
private Long mchId;
/** Thirdparty 服务生成并绑定到硬件订单的聚合会话编号。 */
@Schema(description = "聚合支付会话编号")
private String aggregateNo;
/** 支付宝和微信均可扫码访问的 HTTPS 聚合收银地址。 */
@Schema(description = "聚合二维码内容串")
private String qrCode;
/** 二维码协议类型,固定为 {@code AGGREGATE_ORDER_PAY}。 */
@Schema(description = "二维码协议类型")
private String qrCodeType;
/** Thirdparty 服务确定的聚合会话过期时间,使用服务端本地时区。 */
@Schema(description = "聚合二维码失效时间")
private LocalDateTime expireTime;
}
@@ -21,6 +21,8 @@ import com.bsd.pjcloud.hard.api.vo.android.HardAdUserVo;
import com.bsd.pjcloud.hard.api.vo.android.ReservationMenuQueryVo;
import com.bsd.pjcloud.hard.api.vo.android.VerificationRecordVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.service.*;
@@ -134,6 +136,45 @@ public class HardApiController {
return R.ok(hardwareOrderService.createHardQrCodeScanOrder(dto));
}
/**
* 创建支付宝与微信共用的聚合订单码,同时保留旧自有钱包扫码接口的原有语义。
*
* @param dto 终端商品、设备和商户订单参数
* @return Hardware 订单及其绑定的聚合二维码
*/
@PostMapping("/create-aggregate-order-code-pay")
@Operation(summary = "创建支付宝微信聚合订单码")
@Idempotent(expireTime = 10, info = "正在创建聚合支付订单,请勿重复操作")
public R<HardAggregateOrderCodePayVo> createAggregateOrderCodePay(@Valid @RequestBody HardCreateOrderDto dto) {
// 商品性质必须继续使用 Hardware 现有规范化规则,避免聚合支付订单和普通订单价格口径不同。
if (ObjectUtil.isNotEmpty(dto.getList())) {
for (HardCreateCarDto item : dto.getList()) {
if (ObjectUtil.isEmpty(item.getProductNature())) continue;
if (ObjectUtil.isNotEmpty(item.getProductWeight()) && item.getProductWeight().compareTo(BigDecimal.ZERO) != 0) {
item.setProductNature(HardProductNatureEnum.WEIGHT);
} else if (ObjectUtil.isNotEmpty(item.getProductCount())) {
item.setProductNature(HardProductNatureEnum.DEFAULT);
}
}
}
dto.setOrderPayType(HardPayTypeEnum.AGGREGATE_ORDER_PAY);
return R.ok(hardwareOrderService.createAggregateOrderCodePay(dto));
}
/**
* 查询聚合订单码状态,支付成功时由 Service 在订单锁内完成业务订单结算。
*
* @param orderId Hardware 业务订单 ID
* @param aggregateNo 创建接口返回的聚合会话编号
* @return 聚合会话及 Hardware 订单状态
*/
@GetMapping("/aggregate-order-code-pay/status")
@Operation(summary = "查询支付宝微信聚合订单码状态")
public R<HardAggregateOrderCodePayStatusVo> getAggregateOrderCodePayStatus(
@RequestParam Long orderId, @RequestParam String aggregateNo) {
return R.ok(hardwareOrderService.getAggregateOrderCodePayStatus(orderId, aggregateNo));
}
@PostMapping("/done-hard-qr-code-order")
@Operation(summary = "用户确认设备二维码扫一扫订单")
@Idempotent(expireTime = 10, info = "订单支付中,请勿重复操作")
@@ -13,6 +13,7 @@ import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleCarEntity;
@@ -32,6 +33,7 @@ import org.springframework.http.HttpHeaders;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -95,6 +97,43 @@ public class HardCarController {
hardCarService.saveOrUpload(dto);
return R.ok(true);
}
/**
* 一次上传同一商户下按日期和餐段拆分的多组购物车,并按请求顺序返回各组父购物车 ID。
*/
@ApiCryptoAes
@PostMapping("/app/batch-save-or-upload")
@Operation(summary = "批量新增或者更新购物车")
public R<List<Long>> batchSaveOrUpload(@Validated @RequestBody HardCarBatchSaveOrUpdateDto dto) {
PjcloudUser user = SecurityUtils.getUser();
dto.getGroups().forEach(group -> {
group.setUserId(user.getId());
group.setOrderType(HardOrderTypeEnum.RESERVE);
if (ObjectUtil.isEmpty(group.getHardType())) {
group.setHardType(HardTypeEnum.SUPERMARKET);
}
});
return R.ok(hardCarService.batchSaveOrUpload(dto));
}
/**
* 批量上传每日菜谱购物车;接口层固定使用食堂预约类型,商品 ID 对应 {@code canteen_spu.id}。
*
* @param dto 按日期餐段拆分的购物车分组,用户和业务类型由服务端覆盖
* @return 与请求分组顺序一致的父购物车 ID 列表
*/
@ApiCryptoAes
@PostMapping("/app/daily-menu/batch-save-or-upload")
@Operation(summary = "批量上传每日菜谱购物车")
public R<List<Long>> batchSaveDailyMenu(@Validated @RequestBody HardCarBatchSaveOrUpdateDto dto) {
PjcloudUser user = SecurityUtils.getUser();
dto.getGroups().forEach(group -> {
group.setUserId(user.getId());
group.setHardType(HardTypeEnum.CANTEEN);
group.setOrderType(HardOrderTypeEnum.RESERVE);
});
return R.ok(hardCarService.batchSaveDailyMenu(dto));
}
/* ************************************* end app end ************************************* */
@GetMapping("/order-id")
@@ -38,6 +38,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderPreferencesDto;
import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto;
import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateDailyMenuReserveOrdersDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto;
@@ -343,6 +344,19 @@ public class HardOrderController {
return R.ok(hardwareOrderService.appCreateReserveOrders(SecurityUtils.getUser().getId(), body));
}
/**
* 使用每日菜谱批量购物车一次创建多个日期餐段的预约订单,避免 App 逐组提交产生部分成功。
*/
@Operation(summary = "app创建每日菜谱批量预定订单", description = "一次处理多个日期餐段,并为每位用餐人创建独立订单")
@ApiCryptoAes
@PostMapping("/app/create-daily-menu-reserve-orders")
@Idempotent(expireTime = 30,
key = "'/app/hard-order/app/create-daily-menu-reserve-orders:' + #body.getGroups().get(0).getCarParentId() + ':' + T(com.bsd.pjcloud.common.security.util.SecurityUtils).getUser().getId()",
info = "正在创建订单中,请勿重复操作", delKey = true)
public R<HardAppCreateReserveOrdersVo> appCreateDailyMenuReserveOrders(@Validated @RequestBody HardAppCreateDailyMenuReserveOrdersDto body) {
return R.ok(hardwareOrderService.appCreateDailyMenuReserveOrders(SecurityUtils.getUser().getId(), body));
}
@Operation(summary = "app创建食堂预定订单", description = "app创建食堂预定订单")
@ApiCryptoAes
@GetMapping("/app/create-order")
@@ -0,0 +1,69 @@
package com.bsd.pjcloud.hard.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.service.HardOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 消费机聚合订单码支付业务结算补偿任务。
*
* <p>终端主动轮询仍是实时结算入口;本任务负责覆盖收银弹窗提前关闭、终端断网或进程退出后,
* 第三方已经支付但 Hardware 业务订单尚未结算的场景。</p>
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class HardAggregateOrderPayJob {
/**
* 单轮最多处理的待结算订单数,避免跨服务查单长期占用调度线程。
*/
private static final int BATCH_SIZE = 100;
/**
* Hardware 订单服务,复用带订单锁和事务的聚合支付状态确认及结算流程。
*/
private final HardOrderService hardOrderService;
/**
* 每五秒补偿仍处于创建状态且已绑定聚合会话的 Hardware 订单。
*
* <p>调度线程无租户上下文时先跨租户读取候选记录,再按记录持久化的租户 ID 恢复上下文;
* 具体结算继续由业务服务的分布式订单锁和状态条件保证幂等。</p>
*/
@Scheduled(fixedDelay = 5000L, initialDelay = 5000L)
public void settlePendingAggregateOrders() {
List<HardOrderEntity> orders = hardOrderService.list(new LambdaQueryWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
.isNotNull(HardOrderEntity::getOrderNo)
.orderByAsc(HardOrderEntity::getCreateTime)
.last("limit " + BATCH_SIZE));
if (ObjectUtil.isEmpty(orders)) {
return;
}
for (HardOrderEntity order : orders) {
if (order.getTenantId() == null) {
log.warn("聚合支付Hardware订单缺少持久化租户ID,orderId:{}", order.getId());
continue;
}
try {
TenantBroker.runAs(order.getTenantId(), tenantId ->
hardOrderService.getAggregateOrderCodePayStatus(order.getId(), order.getOrderNo()));
} catch (Exception exception) {
log.warn("补偿聚合支付Hardware订单失败,orderId:{},tenantId:{},exceptionType:{}",
order.getId(), order.getTenantId(), exception.getClass().getSimpleName());
}
}
}
}
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
@@ -35,6 +36,22 @@ public interface HardCarService extends IService<HardCarEntity> {
*/
void saveOrUpload(HardCarSaveOrUpdateDto dto);
/**
* 批量替换同一用户、商户和业务类型下尚未绑定订单的购物车,并为每个分组创建独立父购物车。
*
* @param dto 按业务场景拆分的购物车分组,调用前需由接口层写入可信用户与订单类型
* @return 与请求分组顺序一致的父购物车 ID 列表
*/
List<Long> batchSaveOrUpload(HardCarBatchSaveOrUpdateDto dto);
/**
* 批量保存每日菜谱购物车,所有商品 ID 均按 {@code canteen_spu.id} 解析。
*
* @param dto 按日期餐段拆分的食堂预约购物车分组
* @return 与请求分组顺序一致的父购物车 ID 列表
*/
List<Long> batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto);
/**
* 创建购物车
*/
@@ -49,4 +66,4 @@ public interface HardCarService extends IService<HardCarEntity> {
* 生成购买商品的描述
*/
String computerCarSubject(HardTypeEnum sysType, HardOrderTypeEnum orderType, List<HardCreateCarDto> list);
}
}
@@ -18,6 +18,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto;
import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateDailyMenuReserveOrdersDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto;
@@ -28,6 +29,8 @@ import com.bsd.pjcloud.hard.api.dto.order.xfj.HardXfjFastCreateOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
@@ -119,6 +122,23 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
HardCreateHardQrCodeScanOrderVo createHardQrCodeScanOrder(@Valid HardCreateOrderDto dto);
/**
* 创建与 Hardware 业务订单绑定的支付宝、微信聚合订单码。
*
* @param dto 终端商品、商户、设备及订单上下文
* @return Hardware 订单 ID、聚合会话编号、HTTPS 二维码内容和过期时间
*/
HardAggregateOrderCodePayVo createAggregateOrderCodePay(@Valid HardCreateOrderDto dto);
/**
* 查询聚合码状态,并在第三方确认成功时幂等完成 Hardware 订单。
*
* @param orderId Hardware 业务订单 ID
* @param aggregateNo 创建接口返回的聚合会话编号
* @return 聚合会话和 Hardware 订单的最小状态
*/
HardAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(Long orderId, String aggregateNo);
/**
* 用户确认设备二维码扫一扫订单
*/
@@ -134,6 +154,11 @@ public interface HardOrderService extends IService<HardOrderEntity> {
*/
HardAppCreateReserveOrdersVo appCreateReserveOrders(Long payerUserId, @Valid HardAppCreateReserveOrdersDto dto);
/**
* 当前付款人使用每日菜谱购物车,为多位用餐人批量创建多个日期餐段的独立预约订单。
*/
HardAppCreateReserveOrdersVo appCreateDailyMenuReserveOrders(Long payerUserId, @Valid HardAppCreateDailyMenuReserveOrdersDto dto);
/**
* 校验并规范化当前付款人提交的用餐人。
*/
@@ -6,6 +6,9 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.ReservationRecognitionTypeEnum;
@@ -30,6 +33,7 @@ import com.bsd.pjcloud.hard.service.ReservationMenuQueryService;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
@@ -44,6 +48,7 @@ import java.util.TreeMap;
@Service
@RequiredArgsConstructor
@Slf4j
public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryService {
private final HardManagerService hardManagerService;
@@ -53,6 +58,10 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
private final RemoteDeptService remoteDeptService;
private final RemoteMchService remoteMchService;
/** 食堂菜品远程服务,用于批量补充订单商品类型和套餐组成。 */
private final RemoteCanteenSpuService remoteCanteenSpuService;
/** {@inheritDoc} */
@Override
public ReservationMenuQueryVo query(ReservationMenuQueryDto dto) {
TerminalMerchant terminalMerchant = resolveTerminalMerchant(dto.getHardId());
@@ -71,7 +80,8 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
// 身份与商户条件只在服务端生成,终端无法借此接口查询其他用户或其他食堂。
List<HardOrderVo> orders = hardOrderService.scopeList(query, null);
return aggregate(recognizedUser, terminalMerchant, orders, dto.getMaskName());
Map<Long, ProductMetadata> productMetadata = loadProductMetadata(orders);
return aggregate(recognizedUser, terminalMerchant, orders, dto.getMaskName(), productMetadata);
}
@Override
@@ -155,7 +165,94 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return new RecognizedUser(user.getUserId(), user.getName(), user.getDeptId());
}
private ReservationMenuQueryVo aggregate(RecognizedUser user, TerminalMerchant terminalMerchant, List<HardOrderVo> sourceOrders, Boolean maskNameEnabled) {
/**
* 批量加载预约订单涉及的菜品类型与套餐组成。
*
* <p>商品元数据仅用于核销前展示,远程服务失败时必须降级为空映射,不能阻断预约查询和核销主流程。</p>
*
* @param sourceOrders 当前用户在本食堂的有效预约订单
* @return 商品 SPU ID 与展示元数据的映射;无法加载时返回空映射
*/
private Map<Long, ProductMetadata> loadProductMetadata(List<HardOrderVo> sourceOrders) {
if (sourceOrders == null || sourceOrders.isEmpty()) return Map.of();
List<Long> productIds = sourceOrders.stream()
.filter(Objects::nonNull)
.flatMap(order -> order.getCarList() == null ? java.util.stream.Stream.empty() : order.getCarList().stream())
.filter(Objects::nonNull)
.map(HardCarVo::getProductId)
.filter(Objects::nonNull)
.distinct()
.toList();
if (productIds.isEmpty()) return Map.of();
R<List<CanteenSpuEntity>> spuResult;
try {
spuResult = remoteCanteenSpuService.getSpuListByIds(productIds);
} catch (Exception exception) {
log.warn("预约菜单商品元数据查询异常,按单品降级展示,productIds={}", productIds, exception);
return Map.of();
}
if (spuResult == null || !spuResult.isOk() || spuResult.getData() == null) {
log.warn("预约菜单商品元数据查询失败,按单品降级展示,productIds={}, message={}", productIds,
spuResult == null ? null : spuResult.getMsg());
return Map.of();
}
List<CanteenSpuEntity> spus = spuResult.getData().stream()
.filter(spu -> spu != null && spu.getId() != null)
.toList();
List<Long> comboSpuIds = spus.stream()
.filter(spu -> Objects.equals(spu.getSpuType(), 2))
.map(CanteenSpuEntity::getId)
.toList();
Map<Long, List<OnlineSpuComboItemVO>> comboItemsBySpuId = loadComboItems(comboSpuIds);
Map<Long, ProductMetadata> result = new LinkedHashMap<>();
for (CanteenSpuEntity spu : spus) {
int spuType = Objects.equals(spu.getSpuType(), 2) ? 2 : 1;
List<OnlineSpuComboItemVO> sourceComboItems = comboItemsBySpuId.get(spu.getId());
List<OnlineSpuComboItemVO> availableComboItems = sourceComboItems == null
? List.of()
: sourceComboItems;
List<OnlineSpuComboItemVO> comboItems = availableComboItems.stream()
.filter(Objects::nonNull)
.toList();
result.put(spu.getId(), new ProductMetadata(spuType, comboItems));
}
return result;
}
/**
* 加载指定套餐的组成明细;失败时保留已识别的套餐类型并返回空明细。
*
* @param comboSpuIds 套餐 SPU ID 列表
* @return 套餐 SPU ID 与组成明细映射
*/
private Map<Long, List<OnlineSpuComboItemVO>> loadComboItems(List<Long> comboSpuIds) {
if (comboSpuIds.isEmpty()) return Map.of();
try {
R<Map<Long, List<OnlineSpuComboItemVO>>> result = remoteCanteenSpuService.getComboItemsBySpuIds(comboSpuIds);
if (result != null && result.isOk() && result.getData() != null) return result.getData();
log.warn("预约菜单套餐组成查询失败,保留套餐标识并隐藏明细,comboSpuIds={}, message={}", comboSpuIds,
result == null ? null : result.getMsg());
} catch (Exception exception) {
log.warn("预约菜单套餐组成查询异常,保留套餐标识并隐藏明细,comboSpuIds={}", comboSpuIds, exception);
}
return Map.of();
}
/**
* 将预约订单聚合为终端按日期、餐段和订单展示的数据结构。
*
* @param user 已识别用户
* @param terminalMerchant 当前终端绑定食堂
* @param sourceOrders 可核销预约订单
* @param maskNameEnabled 是否对用户姓名脱敏
* @param productMetadata 商品类型与套餐组成映射
* @return 终端预约菜单查询结果
*/
private ReservationMenuQueryVo aggregate(RecognizedUser user, TerminalMerchant terminalMerchant, List<HardOrderVo> sourceOrders,
Boolean maskNameEnabled, Map<Long, ProductMetadata> productMetadata) {
List<HardOrderVo> orders = (sourceOrders == null ? List.<HardOrderVo>of() : sourceOrders).stream()
.filter(order -> order != null && order.getApptUseDate() != null && StrUtil.isNotBlank(resolveHourType(order)))
.sorted(Comparator.comparing(HardOrderVo::getApptUseDate)
@@ -180,7 +277,11 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
String productName = car.getProductName().trim();
meal.items().add(productName);
if (orderItems != null) {
orderItems.add(new OrderItemAccumulator(productName, car.getProductPic(), car.getProductCount()));
ProductMetadata metadata = productMetadata.get(car.getProductId());
orderItems.add(new OrderItemAccumulator(
car.getProductId(), productName, car.getProductPic(), car.getProductCount(),
metadata == null ? 1 : metadata.spuType(),
metadata == null ? List.of() : metadata.comboItems()));
}
}
}
@@ -208,6 +309,12 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return result;
}
/**
* 将单个餐段聚合结果转换为终端响应,并保持订单及商品的稳定顺序。
*
* @param source 餐段聚合数据
* @return 终端餐段响应
*/
private static ReservationMenuQueryVo.Meal toMeal(MealAccumulator source) {
ReservationMenuQueryVo.Meal meal = new ReservationMenuQueryVo.Meal();
meal.setHourType(source.hourType());
@@ -219,9 +326,12 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
order.setOrderId(entry.getKey());
order.setItems(entry.getValue().stream().map(sourceItem -> {
ReservationMenuQueryVo.OrderItem item = new ReservationMenuQueryVo.OrderItem();
item.setProductId(sourceItem.productId());
item.setProductName(sourceItem.productName());
item.setProductPic(sourceItem.productPic());
item.setProductCount(sourceItem.productCount());
item.setSpuType(sourceItem.spuType());
item.setComboItems(sourceItem.comboItems().stream().map(ReservationMenuQueryServiceImpl::toComboItem).toList());
return item;
}).toList());
return order;
@@ -229,6 +339,23 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
return meal;
}
/**
* 将食堂套餐组成转换为硬件服务对终端公开的稳定响应模型。
*
* @param source 食堂服务套餐组成
* @return 终端核销弹框使用的套餐组成
*/
private static ReservationMenuQueryVo.ComboItem toComboItem(OnlineSpuComboItemVO source) {
ReservationMenuQueryVo.ComboItem item = new ReservationMenuQueryVo.ComboItem();
item.setChildSpuId(source.getChildSpuId());
item.setName(source.getName());
item.setPicUrl(source.getPicUrl());
item.setQuantity(source.getQuantity());
item.setSortOrder(source.getSortOrder());
item.setPrice(source.getPrice());
return item;
}
private static String resolveHourType(HardOrderVo order) {
return StrUtil.blankToDefault(order.getHourType(), order.getApptHourType());
}
@@ -285,9 +412,19 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
}
}
/** 聚合阶段保存单笔订单商品与已补充套餐元数据的临时模型。 */
private record OrderItemAccumulator(
/** 菜品库商品 SPU ID。 */ Long productId,
/** 商品名称。 */ String productName,
/** 商品图片地址。 */ String productPic,
/** 购买数量。 */ Integer productCount) {
/** 购买数量。 */ Integer productCount,
/** 商品类型:1 单品,2 套餐。 */ Integer spuType,
/** 套餐组成;单品或元数据不可用时为空列表。 */ List<OnlineSpuComboItemVO> comboItems) {
}
/** 按商品 SPU ID 缓存的商品类型与套餐组成。 */
private record ProductMetadata(
/** 商品类型:1 单品,2 套餐。 */ Integer spuType,
/** 套餐组成;单品或组成查询失败时为空列表。 */ List<OnlineSpuComboItemVO> comboItems) {
}
}
@@ -11,10 +11,12 @@ import com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.dto.app.HardCarBatchSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.app.HardCarSaveOrUpdateDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
@@ -47,7 +49,12 @@ import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
/**
@@ -62,6 +69,9 @@ import java.util.concurrent.atomic.AtomicInteger;
@Service
public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity> implements HardCarService {
/** 每日菜谱父购物车写入现有扩展 JSON 的场景值,用于下单、退款时区分 {@code canteen_spu} 商品来源。 */
private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU";
private final HardOrderMapper hardCarMapper;
private final HardParentCarMapper hardParentCarMapper;
private final HardSpuService hardSpuService;
@@ -130,6 +140,10 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
@Transactional(rollbackFor = Exception.class)
@Lock4j(keys = {"#dto.userId"})
public void saveOrUpload(HardCarSaveOrUpdateDto dto) {
// cartScene 是服务端商品来源凭证,通用接口必须清空客户端传值,只有每日菜谱专用接口可以写入。
if (dto.getTakeOutSetting() != null) {
dto.getTakeOutSetting().setCartScene(null);
}
List<HardCreateCarDto> carList = dto.getList().stream().filter(v -> v.getProductCount() > 0).toList();
HardParentCarEntity parentCar = getParentCar(dto.getMerchantId(), dto.getUserId(), dto.getHardType());
if (ObjectUtil.isNotEmpty(parentCar)) {
@@ -153,6 +167,182 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
getSelf().appCreateCard(parentCarObj.getId(), dto.getHardType(), dto.getOrderType(), carList);
}
/**
* 批量上传通用购物车,商品来源继续由原有硬件类型和订单类型共同决定。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@Lock4j(keys = {"#dto.groups[0].userId"})
public List<Long> batchSaveOrUpload(HardCarBatchSaveOrUpdateDto dto) {
// 通用批量接口不得由客户端伪造每日菜谱商品来源,避免同 ID 商品跨表错配。
dto.getGroups().stream()
.filter(Objects::nonNull)
.map(HardCarSaveOrUpdateDto::getTakeOutSetting)
.filter(Objects::nonNull)
.forEach(setting -> setting.setCartScene(null));
return saveBatchCarts(dto, false);
}
/**
* 批量上传每日菜谱购物车,固定将商品 ID 解释为 {@code canteen_spu.id}。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@Lock4j(keys = {"#dto.groups[0].userId"})
public List<Long> batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto) {
// 场景标记必须由服务端覆盖,不能信任 App 自报的商品来源;现有 JSON 字段可随克隆购物车完整保留。
dto.getGroups().forEach(group -> {
HardParentCarEntity.TakeOutSetting setting = new HardParentCarEntity.TakeOutSetting();
setting.setCartScene(DAILY_MENU_CART_SCENE);
group.setTakeOutSetting(setting);
});
return saveBatchCarts(dto, true);
}
/**
* 清理旧草稿后创建多个父购物车,并根据调用场景选择通用商品或每日菜谱商品保存逻辑。
*/
private List<Long> saveBatchCarts(HardCarBatchSaveOrUpdateDto dto, boolean useDailyMenuProducts) {
if (dto == null || ObjectUtil.isEmpty(dto.getGroups())) {
throw new RuntimeException("购物车分组不能为空");
}
HardCarSaveOrUpdateDto firstGroup = dto.getGroups().get(0);
if (firstGroup == null || !ObjectUtil.isAllNotEmpty(firstGroup.getUserId(), firstGroup.getMerchantId(), firstGroup.getHardType(), firstGroup.getOrderType())) {
throw new RuntimeException("购物车业务信息不完整");
}
boolean containsDifferentOwner = dto.getGroups().stream().anyMatch(group -> group == null
|| !Objects.equals(group.getUserId(), firstGroup.getUserId())
|| !Objects.equals(group.getMerchantId(), firstGroup.getMerchantId())
|| !Objects.equals(group.getHardType(), firstGroup.getHardType())
|| !Objects.equals(group.getOrderType(), firstGroup.getOrderType()));
if (containsDifferentOwner) {
throw new RuntimeException("批量购物车必须属于同一用户、商户和业务类型");
}
clearUnorderedCreateCarts(firstGroup.getUserId(), firstGroup.getMerchantId(), firstGroup.getHardType(),
useDailyMenuProducts ? DAILY_MENU_CART_SCENE : null);
List<Long> parentCarIds = new ArrayList<>(dto.getGroups().size());
for (int groupIndex = 0; groupIndex < dto.getGroups().size(); groupIndex++) {
HardCarSaveOrUpdateDto group = dto.getGroups().get(groupIndex);
if (ObjectUtil.isEmpty(group.getList())) {
throw new RuntimeException("第" + (groupIndex + 1) + "组购物车不能为空");
}
List<HardCreateCarDto> carList = group.getList().stream()
.filter(Objects::nonNull)
.filter(item -> item.getProductId() != null)
.filter(item -> item.getProductCount() != null && item.getProductCount() > 0)
.toList();
if (carList.size() != group.getList().size()) {
throw new RuntimeException("第" + (groupIndex + 1) + "组购物车商品信息不完整或数量无效");
}
HardParentCarEntity parentCar = HardParentCarEntity.builder()
.userId(group.getUserId())
.merchantId(group.getMerchantId())
.hardType(group.getHardType().getType())
.orderStatus(HardOrderStatusEnum.CREATE.getType())
.takeOutSettingJson(group.getTakeOutSetting())
.build();
if (hardParentCarMapper.insert(parentCar) != 1) {
throw new RuntimeException("第" + (groupIndex + 1) + "组父购物车创建失败");
}
if (useDailyMenuProducts) {
createDailyMenuCartItems(parentCar.getId(), group.getMerchantId(), carList);
} else {
getSelf().appCreateCard(parentCar.getId(), group.getHardType(), group.getOrderType(), carList);
}
if (getByParentCarId(parentCar.getId()).size() != carList.size()) {
throw new RuntimeException("第" + (groupIndex + 1) + "组存在无效商品");
}
parentCarIds.add(parentCar.getId());
}
return parentCarIds;
}
/**
* 根据每日菜谱提交的 {@code canteen_spu.id} 生成购物车快照,非法商品整组拒绝而不是静默跳过。
*/
private void createDailyMenuCartItems(Long parentCarId, Long merchantId, List<HardCreateCarDto> productList) {
List<Long> productIds = productList.stream().map(HardCreateCarDto::getProductId).distinct().toList();
R<List<CanteenSpuEntity>> response = canteenSpuService.getSpuListByIds(productIds);
if (response == null || !response.isOk() || response.getData() == null) {
String message = response == null || response.getMsg() == null ? "查询每日菜谱商品失败" : response.getMsg();
throw new RuntimeException(message);
}
Map<Long, CanteenSpuEntity> productMap = new HashMap<>(response.getData().size());
response.getData().stream()
.filter(Objects::nonNull)
.filter(spu -> spu.getId() != null)
.forEach(spu -> productMap.putIfAbsent(spu.getId(), spu));
List<HardCarEntity> cartItems = new ArrayList<>(productList.size());
for (HardCreateCarDto product : productList) {
CanteenSpuEntity spu = productMap.get(product.getProductId());
if (spu == null || !Objects.equals(spu.getMerchantId(), merchantId)) {
throw new RuntimeException("每日菜谱商品不存在或不属于当前商户");
}
if (spu.getPrice() == null || spu.getPrice() < 0) {
throw new RuntimeException(spu.getName() + "价格异常,请联系商户");
}
long totalPrice = Math.multiplyExact((long) spu.getPrice(), product.getProductCount().longValue());
String productNature = ObjectUtil.isNotEmpty(product.getProductNature())
? product.getProductNature().getType()
: HardProductNatureEnum.DEFAULT.getType();
cartItems.add(HardCarEntity.builder()
.parentCarId(parentCarId)
.productId(spu.getId())
.productName(spu.getName())
.productPic(spu.getPicUrl())
.productPrice(spu.getPrice())
.productNature(productNature)
.productCount(product.getProductCount())
.productWeight(BigDecimal.ZERO)
.totalPrice(totalPrice)
.build());
}
insertCartItemsInCurrentTransaction(cartItems);
}
/**
* 按商品来源场景清理未绑定订单的旧草稿购物车;已有订单引用的父购物车必须保留,
* 每日菜谱与普通食堂购物车也必须相互隔离,避免一个页面结算时删除另一个页面的草稿。
*
* @param userId 草稿所属用户 ID
* @param merchantId 草稿所属商户 ID
* @param hardType 草稿业务类型
* @param cartScene 服务端购物车场景;为空时仅清理没有场景标记的普通购物车
*/
private void clearUnorderedCreateCarts(Long userId, Long merchantId, HardTypeEnum hardType, String cartScene) {
List<HardParentCarEntity> candidates = hardParentCarMapper.selectList(new LambdaQueryWrapper<HardParentCarEntity>()
.eq(HardParentCarEntity::getUserId, userId)
.eq(HardParentCarEntity::getMerchantId, merchantId)
.eq(HardParentCarEntity::getHardType, hardType.getType())
.eq(HardParentCarEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType()))
.stream()
.filter(parentCar -> {
String existingScene = parentCar.getTakeOutSettingJson() == null
? null
: parentCar.getTakeOutSettingJson().getCartScene();
return cartScene == null ? ObjectUtil.isEmpty(existingScene) : Objects.equals(cartScene, existingScene);
})
.toList();
if (ObjectUtil.isEmpty(candidates)) {
return;
}
List<Long> candidateIds = candidates.stream().map(HardParentCarEntity::getId).toList();
Set<Long> orderedParentCarIds = new HashSet<>(hardCarMapper.selectList(new LambdaQueryWrapper<HardOrderEntity>()
.select(HardOrderEntity::getParentCarId)
.in(HardOrderEntity::getParentCarId, candidateIds))
.stream()
.map(HardOrderEntity::getParentCarId)
.filter(Objects::nonNull)
.toList());
List<Long> removableParentCarIds = candidateIds.stream().filter(id -> !orderedParentCarIds.contains(id)).toList();
if (ObjectUtil.isEmpty(removableParentCarIds)) {
return;
}
baseMapper.delete(new LambdaQueryWrapper<HardCarEntity>().in(HardCarEntity::getParentCarId, removableParentCarIds));
hardParentCarMapper.deleteBatchIds(removableParentCarIds);
}
@Override
public Integer computerCarTotalPrice(HardTypeEnum sysType, HardOrderTypeEnum orderType, List<HardCreateCarDto> list) {
List<Long> productIds = list.stream().map(HardCreateCarDto::getProductId).toList();
@@ -308,7 +498,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
HardCarEntity item = HardCarEntity.builder().parentCarId(parentCarObj.getId()).productName(v.getName()).productPic(v.getPic()).productPrice(v.getPrice()).productNature(HardProductNatureEnum.DEFAULT.getType()).productCount(v.getCount()).totalPrice(totalPrice).build();
carObjectList.add(item);
}
baseMapper.insert(carObjectList);
insertCartItemsInCurrentTransaction(carObjectList);
return parentCarObj.getId();
}
@@ -360,7 +550,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
});
}
}
baseMapper.insert(carObjectList);
insertCartItemsInCurrentTransaction(carObjectList);
return;
}
// 超市
@@ -421,7 +611,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
});
}
}
baseMapper.insert(carObjectList);
insertCartItemsInCurrentTransaction(carObjectList);
return;
}
// 智能柜
@@ -454,7 +644,7 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
carObjectList.add(item);
});
}
baseMapper.insert(carObjectList);
insertCartItemsInCurrentTransaction(carObjectList);
return;
}
// 共富商城
@@ -500,11 +690,24 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
}
}
baseMapper.insert(carObjectList);
insertCartItemsInCurrentTransaction(carObjectList);
return;
}
}
/**
* 逐条写入购物车明细以复用 Spring 当前事务绑定的 SqlSession,避免集合插入自行提交导致外层事务不可见。
*
* @param cartItems 已完成商品校验和价格快照的购物车明细
*/
private void insertCartItemsInCurrentTransaction(List<HardCarEntity> cartItems) {
for (HardCarEntity cartItem : cartItems) {
if (baseMapper.insert(cartItem) != 1) {
throw new RuntimeException("购物车商品保存失败");
}
}
}
@Override
public String computerCarSubject(HardTypeEnum sysType, HardOrderTypeEnum orderType, List<HardCreateCarDto> list) {
StringBuffer str = new StringBuffer();
@@ -608,4 +811,4 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
private HardCarServiceImpl getSelf() {
return SpringUtil.getBean(getClass());
}
}
}
@@ -9,6 +9,7 @@ import com.baomidou.lock.LockInfo;
import com.baomidou.lock.LockTemplate;
import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
@@ -21,10 +22,12 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenDeliveryOrderService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.api.feign.RemoteOnlineCanteenSpuService;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryCartItemDTO;
import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuChoiceGroupsVO;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
@@ -55,6 +58,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardCreateCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardShipmentDto;
import com.bsd.pjcloud.hard.api.dto.order.act.HardCreateActOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateDailyMenuReserveOrdersDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateFarmerOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto;
@@ -74,6 +78,8 @@ import com.bsd.pjcloud.hard.api.utils.HardUtil;
import com.bsd.pjcloud.hard.api.vo.HardDoorFlowVo;
import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.hard.api.vo.order.HardAggregateOrderCodePayVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCreateHardQrCodeScanOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
@@ -94,16 +100,21 @@ import com.bsd.pjcloud.supermarket.dto.SupermarketSpuUpdateStockReqDTO;
import com.bsd.pjcloud.thirdparty.api.feign.RemoterThirdAppPlusPayService;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.ks.KsRecognitionCallbackDto;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.constant.WalletBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletCodeBizEnum;
import com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.act.ActCancelStatusEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchRulePriceTypeEnum;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchSupType;
import com.bsd.pjcloud.wallet.api.constant.mch.WalletMchTableReservationMode;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.WalletBalanceTransactionDto;
import com.bsd.pjcloud.wallet.api.dto.act.ActCancelItemDto;
@@ -156,6 +167,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
static private final String TAG = "订单";
private static final long RESERVATION_SLOT_LOCK_EXPIRE_MILLIS = 60_000L;
private static final long RESERVATION_SLOT_LOCK_WAIT_MILLIS = 3_000L;
/** 每日菜谱父购物车在扩展 JSON 中持久化的场景值,作为忽略库存和商品来源分流依据。 */
private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU";
/** 聚合订单码跨服务协议类型,禁止与自有钱包码或平台原生码混用。 */
private static final String AGGREGATE_ORDER_PAY_QR_CODE_TYPE = "AGGREGATE_ORDER_PAY";
/** 聚合收银地址必须包含的独立公网路径片段。 */
private static final String AGGREGATE_ORDER_PAY_CASHIER_PATH = "/aggregate-order-pay/cashier/";
private final HardManagerService hardManagerService;
private final HardCarService hardCarService;
@@ -172,6 +191,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final HardUserMapAddressService hardUserMapAddressService;
private final HardTakeOutApptService hardTakeOutApptService;
/** 每日菜谱商品远程服务,批量下单时按 {@code canteen_spu.id} 复核购物车价格快照。 */
private final RemoteCanteenSpuService remoteCanteenSpuService;
private final RemoteOnlineCanteenSpuService remoteOnlineCanteenSpuService;
private final RemoteMchService remoteMchService;
private final RemoteMchPickUpService remoteMchPickUpService;
@@ -773,6 +794,254 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return HardCreateHardQrCodeScanOrderVo.builder().orderId(orderObj.getId()).mchId(orderObj.getMerchantId()).qrCodeData(qrCodeData).build();
}
/**
* 创建与 Hardware 订单绑定的支付宝、微信聚合订单码。
*
* <p>聚合会话编号持久化到现有 {@code hard_order.order_no},既保证服务重启后仍可核对绑定,
* 又不把自有钱包二维码的 URL 或状态复用到第三方聚合支付。</p>
*
* @param dto 终端商品、商户和设备订单参数
* @return Hardware 订单 ID、聚合会话编号、HTTPS 收银地址和过期时间
*/
@Override
@Lock4j(keys = {"#dto.androidOrderCode"})
@Transactional(rollbackFor = Exception.class)
public HardAggregateOrderCodePayVo createAggregateOrderCodePay(HardCreateOrderDto dto) {
MchManageVo mch = validateCreateOrderDto(dto);
if (ObjectUtil.isEmpty(mch)) {
throw new RuntimeException("机器绑定的商户不存在");
}
String subject = hardCarService.computerCarSubject(dto.getSysType(), dto.getOrderType(), dto.getList());
int totalPrice = hardCarService.computerCarTotalPrice(dto.getSysType(), dto.getOrderType(), dto.getList());
int extraPrice = isExtraPrice(dto.getSysType()) ? computeExtraPrice(totalPrice, mch, null) : 0;
int amount = totalPrice + extraPrice;
if (amount <= 0) {
throw new RuntimeException("聚合支付金额必须大于0");
}
HardOrderEntity reusableOrder = getReusableAggregateOrder(dto);
if (reusableOrder != null) {
validateReusableAggregateOrder(reusableOrder, dto, totalPrice, extraPrice, subject);
return createOrReuseAggregateSession(reusableOrder, amount);
}
Long parentCarId = hardCarService.createCard(dto.getUserId(), dto.getMerchantId(), dto.getSysType(), dto.getOrderType(), dto.getList());
HardOrderEntity order = HardOrderEntity.builder()
.androidOrderCode(dto.getAndroidOrderCode())
.sysType(dto.getSysType().getType())
.hourType(String.valueOf(dto.getHourType()))
.parentCarId(parentCarId)
.hardId(dto.getHardwareId())
.orderStatus(HardOrderStatusEnum.CREATE.getType())
.merchantId(dto.getMerchantId())
.merchantType(mch.getTypes())
.merchantDeptId(mch.getDeptId())
.deptId(dto.getDeptId())
.orderType(dto.getOrderType().getType())
.payType(HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
.orderSourceTypeId(dto.getOrderSourceTypeId())
.totalPrice(totalPrice)
.extraPrice(extraPrice)
.payPrice(0)
.isOfflineOrder(YesNoEnum.NO.getCode())
.apptUseDate(LocalDate.now())
.productDesc(subject)
.userId(-1L)
.build();
if (baseMapper.insert(order) <= 0 || order.getId() == null) {
throw new RuntimeException("创建聚合支付业务订单失败");
}
return createOrReuseAggregateSession(order, amount);
}
/**
* 查询同一终端本地订单号尚未结束的聚合支付订单,供创建响应丢失后的重试复用。
*
* @param dto 当前收银端创建请求
* @return 同设备、同商户的待支付或已支付聚合订单;不存在时返回 {@code null}
*/
private HardOrderEntity getReusableAggregateOrder(HardCreateOrderDto dto) {
return getOne(new LambdaQueryWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getAndroidOrderCode, dto.getAndroidOrderCode())
.eq(HardOrderEntity::getHardId, dto.getHardwareId())
.eq(HardOrderEntity::getMerchantId, dto.getMerchantId())
.eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
.in(HardOrderEntity::getOrderStatus,
HardOrderStatusEnum.CREATE.getType(), HardOrderStatusEnum.PAY.getType())
.orderByDesc(HardOrderEntity::getCreateTime)
.last("limit 1"));
}
/**
* 校验可复用 Hardware 订单与当前请求的业务、商品摘要和金额完全一致。
*
* @param order 已存在的聚合支付 Hardware 订单
* @param dto 当前收银端创建请求
* @param totalPrice 当前购物车商品总金额,单位为分
* @param extraPrice 当前额外金额,单位为分
* @param subject 当前购物车商品摘要
*/
private void validateReusableAggregateOrder(HardOrderEntity order, HardCreateOrderDto dto, int totalPrice,
int extraPrice, String subject) {
if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) {
throw new RuntimeException("该终端订单已支付,请勿重复收款");
}
if (!Objects.equals(order.getSysType(), dto.getSysType().getType())
|| !Objects.equals(order.getOrderType(), dto.getOrderType().getType())
|| !Objects.equals(order.getTotalPrice(), totalPrice)
|| !Objects.equals(order.getExtraPrice(), extraPrice)
|| !Objects.equals(order.getProductDesc(), subject)) {
throw new RuntimeException("该终端订单号已存在不同内容的待支付订单");
}
}
/**
* 为已持久化的 Hardware 订单创建或复用 Thirdparty 聚合会话,并原子更新会话编号绑定。
*
* @param order 待支付 Hardware 业务订单
* @param amount 应付金额,单位为分
* @return Hardware 订单及其聚合收银二维码
*/
private HardAggregateOrderCodePayVo createOrReuseAggregateSession(HardOrderEntity order, int amount) {
ThirdAggregateOrderCodePayDto aggregateDto = new ThirdAggregateOrderCodePayDto();
aggregateDto.setBizType(ThirdPayOrderBizTypeEnum.XFJ);
aggregateDto.setBizId(String.valueOf(order.getId()));
aggregateDto.setMchId(order.getMerchantId());
aggregateDto.setAmount(amount);
aggregateDto.setUserId(null);
R<ThirdAggregateOrderCodePayVo> aggregateResult = thirdAppPlusPayService.aggregateOrderCodePay(aggregateDto);
if (aggregateResult == null || !aggregateResult.isOk() || aggregateResult.getData() == null) {
String message = aggregateResult == null ? "第三方支付服务无响应" : aggregateResult.getMsg();
throw new RuntimeException("创建聚合订单码失败:" + message);
}
ThirdAggregateOrderCodePayVo aggregate = aggregateResult.getData();
if (!ObjectUtil.isAllNotEmpty(aggregate.getAggregateNo(), aggregate.getQrCode(), aggregate.getQrCodeType(), aggregate.getExpireTime())
|| !Objects.equals(aggregate.getQrCodeType(), AGGREGATE_ORDER_PAY_QR_CODE_TYPE)
|| !StrUtil.startWithIgnoreCase(aggregate.getQrCode(), "https://")
|| !aggregate.getQrCode().contains(AGGREGATE_ORDER_PAY_CASHIER_PATH)) {
throw new RuntimeException("创建聚合订单码失败:第三方支付返回不完整");
}
if (!Objects.equals(order.getOrderNo(), aggregate.getAggregateNo())) {
LambdaUpdateWrapper<HardOrderEntity> bindWrapper = new LambdaUpdateWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getId, order.getId())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
.eq(HardOrderEntity::getPayType, HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType());
if (StrUtil.isBlank(order.getOrderNo())) {
bindWrapper.isNull(HardOrderEntity::getOrderNo);
} else {
bindWrapper.eq(HardOrderEntity::getOrderNo, order.getOrderNo());
}
if (!update(bindWrapper.set(HardOrderEntity::getOrderNo, aggregate.getAggregateNo()))) {
throw new RuntimeException("绑定聚合支付会话失败");
}
}
return HardAggregateOrderCodePayVo.builder()
.orderId(order.getId())
.mchId(order.getMerchantId())
.aggregateNo(aggregate.getAggregateNo())
.qrCode(aggregate.getQrCode())
.qrCodeType(aggregate.getQrCodeType())
.expireTime(aggregate.getExpireTime())
.build();
}
/**
* 查询聚合支付状态,并在支付平台确认成功后幂等完成 Hardware 订单。
*
* <p>订单 ID 和聚合编号必须同时匹配持久化绑定;订单锁与未支付条件共同防止终端轮询、
* 第三方回调同步和重复请求造成库存重复扣减。</p>
*
* @param orderId Hardware 业务订单 ID
* @param aggregateNo 创建接口返回的聚合会话编号
* @return 聚合会话和 Hardware 订单的最小状态
*/
@Override
@Lock4j(keys = {"#orderId"})
@Transactional(rollbackFor = Exception.class)
public HardAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(Long orderId, String aggregateNo) {
if (!ObjectUtil.isAllNotEmpty(orderId, aggregateNo)) {
throw new RuntimeException("聚合支付订单参数不完整");
}
HardOrderEntity order = baseMapper.selectById(orderId);
if (order == null
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.AGGREGATE_ORDER_PAY.getType())
|| !Objects.equals(order.getOrderNo(), aggregateNo)) {
throw new RuntimeException("聚合支付订单不存在或绑定关系不匹配");
}
if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())) {
return HardAggregateOrderCodePayStatusVo.builder()
.orderId(order.getId())
.aggregateNo(order.getOrderNo())
.status(ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS.getType())
.paySuccess(Boolean.TRUE)
.orderStatus(order.getOrderStatus())
.build();
}
if (Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.UNFOLLOW.getType())) {
// 终态响应丢失后重复查询时仍返回明确关闭结果,避免终端把业务终态当作临时网络异常持续轮询。
return HardAggregateOrderCodePayStatusVo.builder()
.orderId(order.getId())
.aggregateNo(order.getOrderNo())
.status(ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType())
.paySuccess(Boolean.FALSE)
.orderStatus(order.getOrderStatus())
.build();
}
if (!Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) {
throw new RuntimeException("聚合支付业务订单已关闭");
}
R<ThirdAggregateOrderCodePayStatusVo> statusResult = thirdAppPlusPayService.getAggregateOrderCodePayStatus(aggregateNo);
if (statusResult == null || !statusResult.isOk() || statusResult.getData() == null) {
String message = statusResult == null ? "第三方支付服务无响应" : statusResult.getMsg();
throw new RuntimeException("查询聚合订单码失败:" + message);
}
ThirdAggregateOrderCodePayStatusVo aggregateStatus = statusResult.getData();
if (!Objects.equals(aggregateNo, aggregateStatus.getAggregateNo())) {
throw new RuntimeException("聚合支付状态绑定关系不匹配");
}
if (Boolean.TRUE.equals(aggregateStatus.getPaySuccess())) {
// 只有支付平台明确成功后才扣库存;订单锁防止正常重复轮询,库存远程调用仍沿用现有跨服务一致性边界。
getSelf().fastenerInventory(order, YesNoEnum.YES);
getSelf().orderSuccess(order, Boolean.TRUE);
order.setOrderStatus(HardOrderStatusEnum.PAY.getType());
order.setPayPrice(order.getTotalPrice() + order.getExtraPrice());
if (TransactionSynchronizationManager.isSynchronizationActive()) {
Long paidOrderId = order.getId();
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 事务提交后再触发现有打印/MQ流程,避免数据库回滚但外部副作用已经发生。 */
@Override
public void afterCommit() {
createOrderAfterSendMqMsg(paidOrderId, null);
}
});
}
} else if (List.of(
ThirdAggregatePaySessionStatusEnum.PAY_CLOSED.getType(),
ThirdAggregatePaySessionStatusEnum.EXPIRED.getType(),
ThirdAggregatePaySessionStatusEnum.CREATE_FAILED.getType()).contains(aggregateStatus.getStatus())) {
// 终态失败订单不再保留为“未支付”,终端重试会创建新的 Hardware 订单和聚合会话。
boolean closed = update(new LambdaUpdateWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getId, order.getId())
.eq(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.CREATE.getType())
.set(HardOrderEntity::getOrderStatus, HardOrderStatusEnum.UNFOLLOW.getType())
.set(HardOrderEntity::getUnfollowTime, LocalDateTime.now()));
if (closed) {
order.setOrderStatus(HardOrderStatusEnum.UNFOLLOW.getType());
}
}
return HardAggregateOrderCodePayStatusVo.builder()
.orderId(order.getId())
.aggregateNo(aggregateStatus.getAggregateNo())
.status(aggregateStatus.getStatus())
.selectedPlatform(aggregateStatus.getSelectedPlatform())
.payNo(aggregateStatus.getPayNo())
.paySuccess(aggregateStatus.getPaySuccess())
.expireTime(aggregateStatus.getExpireTime())
.orderStatus(order.getOrderStatus())
.build();
}
/**
* 用户确认设备二维码扫一扫订单
*/
@@ -1026,6 +1295,412 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
return result;
}
/**
* 每日菜谱批量下单在一个本地事务中处理全部日期餐段。购物车来自 {@code canteen_spu},
* 因此不复用会查询 {@code canteen_online_spu} 的旧预约接口;该场景按业务规则忽略库存,
* 下单和退款均不修改在线菜品库存,远程钱包扣款失败时按订单业务号幂等补偿。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public HardAppCreateReserveOrdersVo appCreateDailyMenuReserveOrders(Long payerUserId, @Valid HardAppCreateDailyMenuReserveOrdersDto dto) {
List<Long> dinerUserIds = validateReserveDinerUserIds(payerUserId, dto.getDinerUserIds());
List<HardAppCreateDailyMenuReserveOrdersDto.Group> groups = validateDailyMenuReserveGroups(dto.getGroups());
List<LockInfo> cartLocks = acquireDailyMenuCartLocks(groups);
try {
registerDailyMenuCartLockRelease(cartLocks);
} catch (RuntimeException exception) {
releaseDailyMenuCartLocks(cartLocks);
throw exception;
}
List<DailyMenuReserveGroupContext> contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups);
Long merchantId = contexts.get(0).parentCar().getMerchantId();
MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId);
validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant);
validateDailyMenuCartProducts(merchantId, contexts);
validateDailyMenuSingleProductLimit(merchant, contexts);
WalletManageEntity wallet = getDailyMenuPaymentWallet(payerUserId, dto.getWalletTypeId());
List<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
.map(R::getData)
.orElseThrow(() -> new RuntimeException("查询用餐人失败"));
Map<Long, SysUser> dinerUserMap = dinerUsers.stream()
.filter(Objects::nonNull)
.filter(user -> user.getUserId() != null)
.collect(Collectors.toMap(SysUser::getUserId, Function.identity(), (first, second) -> first));
if (dinerUserMap.size() != dinerUserIds.size()) {
throw new RuntimeException("用餐人不存在");
}
for (Long dinerUserId : dinerUserIds) {
Long reserveCount = isHaveCanteenReserve(dinerUserId);
if (Math.addExact(reserveCount, (long) contexts.size()) > 20L) {
throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,本次订餐后将超过20笔上限");
}
}
int singleDinerTotalPrice = contexts.stream()
.mapToInt(DailyMenuReserveGroupContext::singlePrice)
.reduce(0, Math::addExact);
int totalPayPrice = Math.multiplyExact(singleDinerTotalPrice, dinerUserIds.size());
if (totalPayPrice <= 0) {
throw new RuntimeException("购物车金额异常");
}
if (wallet.getBalance() == null || wallet.getBalance() < totalPayPrice) {
throw new RuntimeException("钱包余额不足");
}
// 固定按日期和餐段顺序获取冲突锁,避免两个批量请求以相反分组顺序互相等待。
contexts.stream()
.sorted(Comparator.comparing((DailyMenuReserveGroupContext context) -> context.reservation().apptDate())
.thenComparing(context -> context.reservation().hourType(), Comparator.nullsFirst(String::compareTo)))
.forEach(context -> validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation()));
List<HardOrderEntity> createdOrders = new ArrayList<>();
try {
for (DailyMenuReserveGroupContext context : contexts) {
String subject = buildDailyMenuReserveOrderSubject(context.carList());
for (int dinerIndex = 0; dinerIndex < dinerUserIds.size(); dinerIndex++) {
Long dinerUserId = dinerUserIds.get(dinerIndex);
Long parentCarId = dinerIndex == 0
? context.parentCar().getId()
: cloneReserveCart(context.parentCar(), context.carList(), payerUserId);
HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant,
wallet, context.reservation(), context.singlePrice(), subject);
if (baseMapper.insert(order) != 1) {
throw new RuntimeException("创建订餐订单失败");
}
createdOrders.add(order);
}
}
for (HardOrderEntity order : createdOrders) {
reduceReserveOrderBalance(order);
markReserveOrderPaid(order);
}
// 方法体成功后仍可能在数据库提交阶段失败,回滚回调负责补偿已经完成的跨服务钱包扣款。
registerDailyMenuWalletRollbackCompensation(createdOrders);
} catch (Exception exception) {
compensateReserveOrderWallets(createdOrders);
throw exception;
}
registerReserveOrderAfterCommitTasks(createdOrders, dinerUserMap);
HardAppCreateReserveOrdersVo result = new HardAppCreateReserveOrdersVo();
result.setOrderIds(createdOrders.stream().map(HardOrderEntity::getId).toList());
result.setOrderCount(createdOrders.size());
result.setTotalPayPrice(totalPayPrice);
return result;
}
/**
* 将每日菜谱钱包补偿绑定到本地事务回滚,覆盖方法返回后发生的数据库提交失败。
* 钱包补偿接口按订单业务号幂等,方法体异常路径即使已经补偿,再次调用也不会重复入账。
*
* @param createdOrders 已完成远程钱包扣款、等待本地事务提交的订单
*/
private void registerDailyMenuWalletRollbackCompensation(List<HardOrderEntity> createdOrders) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new RuntimeException("订餐事务状态异常,请稍后重试");
}
List<HardOrderEntity> rollbackOrders = List.copyOf(createdOrders);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/**
* 本地事务明确回滚时补偿远程钱包;状态未知时保留流水,避免数据库实际已提交却误退款。
*
* @param status Spring 事务完成状态
*/
@Override
public void afterCompletion(int status) {
if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
compensateReserveOrderWallets(rollbackOrders);
}
}
});
}
/**
* 规范化每日菜谱分组并拒绝重复父购物车,避免同一购物车在一个批次内被创建多次订单。
*
* @param groups 接口提交的日期餐段分组
* @return 保持请求顺序的独立列表
*/
private List<HardAppCreateDailyMenuReserveOrdersDto.Group> validateDailyMenuReserveGroups(
List<HardAppCreateDailyMenuReserveOrdersDto.Group> groups) {
if (ObjectUtil.isEmpty(groups) || groups.stream().anyMatch(Objects::isNull)) {
throw new RuntimeException("订餐分组不能为空");
}
Set<Long> parentCarIds = groups.stream()
.map(HardAppCreateDailyMenuReserveOrdersDto.Group::getCarParentId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (parentCarIds.size() != groups.size()) {
throw new RuntimeException("请勿重复提交同一购物车");
}
return new ArrayList<>(groups);
}
/**
* 按父购物车 ID 排序获取批量下单锁,锁在当前事务完成后统一释放。
*
* @param groups 已完成基础校验的日期餐段分组
* @return 当前请求持有的购物车锁
*/
private List<LockInfo> acquireDailyMenuCartLocks(List<HardAppCreateDailyMenuReserveOrdersDto.Group> groups) {
List<LockInfo> locks = new ArrayList<>();
try {
for (Long parentCarId : groups.stream()
.map(HardAppCreateDailyMenuReserveOrdersDto.Group::getCarParentId)
.sorted()
.toList()) {
String lockKey = "hard:daily-menu-reserve:cart:" + parentCarId;
LockInfo lock = lockTemplate.lock(lockKey, RESERVATION_SLOT_LOCK_EXPIRE_MILLIS,
RESERVATION_SLOT_LOCK_WAIT_MILLIS);
if (lock == null) {
throw new RuntimeException("订餐订单正在处理中,请勿重复提交");
}
locks.add(lock);
}
return locks;
} catch (RuntimeException exception) {
releaseDailyMenuCartLocks(locks);
throw exception;
}
}
/**
* 将每日菜谱购物车锁绑定到当前事务生命周期,确保订单回滚前其他请求不能再次消费同一购物车。
*
* @param locks 当前请求已经获取的购物车锁
*/
private void registerDailyMenuCartLockRelease(List<LockInfo> locks) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new RuntimeException("订餐事务状态异常,请稍后重试");
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/**
* 无论事务提交还是回滚,都在本地事务完成后逆序释放全部父购物车锁。
*
* @param status Spring 事务完成状态
*/
@Override
public void afterCompletion(int status) {
releaseDailyMenuCartLocks(locks);
}
});
}
/**
* 逆序释放已获取的每日菜谱购物车锁,单个锁释放失败不阻断其他锁的清理。
*
* @param locks 需要释放的购物车锁
*/
private void releaseDailyMenuCartLocks(List<LockInfo> locks) {
ListIterator<LockInfo> iterator = locks.listIterator(locks.size());
while (iterator.hasPrevious()) {
LockInfo lock = iterator.previous();
try {
if (!lockTemplate.releaseLock(lock)) {
log.warn("|{}|每日菜谱购物车锁释放失败,lockKey:|{}|", TAG, lock.getLockKey());
}
} catch (Exception exception) {
log.error("|{}|每日菜谱购物车锁释放异常,lockKey:|{}|", TAG, lock.getLockKey(), exception);
}
}
}
/**
* 加载并校验每个父购物车及其预约上下文,所有分组必须属于同一付款人和同一食堂商户。
*
* @param payerUserId 当前登录付款人 ID
* @param groups 每日菜谱日期餐段分组
* @return 后续创建订单所需的服务端可信上下文
*/
private List<DailyMenuReserveGroupContext> loadDailyMenuReserveGroupContexts(Long payerUserId,
List<HardAppCreateDailyMenuReserveOrdersDto.Group> groups) {
List<DailyMenuReserveGroupContext> contexts = new ArrayList<>(groups.size());
Long merchantId = null;
Set<String> mealSlots = new HashSet<>();
for (HardAppCreateDailyMenuReserveOrdersDto.Group group : groups) {
HardParentCarEntity parentCar = hardCarService.getParentCarById(group.getCarParentId());
if (parentCar == null || !Objects.equals(parentCar.getUserId(), payerUserId)) {
throw new RuntimeException("购物车不存在或不属于当前用户");
}
if (!Objects.equals(parentCar.getHardType(), HardTypeEnum.CANTEEN.getType())) {
throw new RuntimeException("仅食堂购物车可以提交每日菜谱订单");
}
if (!isDailyMenuParentCar(parentCar)) {
throw new RuntimeException("购物车不是每日菜谱场景,请重新点击去支付");
}
if (!Objects.equals(parentCar.getOrderStatus(), HardOrderStatusEnum.CREATE.getType())) {
throw new RuntimeException("购物车状态已变更,请重新选择商品");
}
if (baseMapper.selectCount(new LambdaQueryWrapper<HardOrderEntity>()
.eq(HardOrderEntity::getParentCarId, parentCar.getId())) > 0) {
throw new RuntimeException("购物车已创建订单,请勿重复提交");
}
if (merchantId == null) {
merchantId = parentCar.getMerchantId();
} else if (!Objects.equals(merchantId, parentCar.getMerchantId())) {
throw new RuntimeException("批量订餐必须属于同一商户");
}
List<HardCarEntity> carList = hardCarService.getByParentCarId(parentCar.getId());
if (ObjectUtil.isEmpty(carList)) {
throw new RuntimeException("购物车不存在");
}
validateReserveCartItems(carList);
HardApptReservationService.ReservationValidation reservation = hardApptReservationService
.validateReservation(parentCar.getMerchantId(), group.getApptId(), group.getApptRuleId(),
group.getHourType(), group.getApptDate(), group.getApptTimeType());
String mealSlot = reservation.apptDate() + "::" + reservation.hourType();
if (!mealSlots.add(mealSlot)) {
throw new RuntimeException("请勿重复提交同一天同一餐段");
}
int singlePrice = Math.toIntExact(carList.stream().mapToLong(HardCarEntity::getTotalPrice).sum());
if (singlePrice <= 0) {
throw new RuntimeException("购物车金额异常");
}
contexts.add(new DailyMenuReserveGroupContext(parentCar, carList, reservation, singlePrice));
}
return contexts;
}
/**
* 查询并校验每日菜谱商户配置,防止其他食堂购物车绕过专用商品来源约束。
*
* @param merchantId 批量购物车所属商户 ID
* @return 已确认开启每日菜谱订餐模式的食堂商户
*/
private MchManageVo validateDailyMenuReserveMerchant(Long merchantId) {
R<MchManageVo> result = remoteMchService.getById(merchantId);
MchManageVo merchant = result == null || !result.isOk() ? null : result.getData();
if (merchant == null) {
throw new RuntimeException(result == null ? "查询商户无响应" : StrUtil.blankToDefault(result.getMsg(), "商户不存在"));
}
if (!Objects.equals(merchant.getTypes(), MerchantTypeEnum.CANTEEN.getType())) {
throw new RuntimeException("购物车商户不是食堂商户");
}
MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf();
if (conf == null || !Objects.equals(conf.getMode(), WalletMchTableReservationMode.DAILY_MENU.getCode())) {
throw new RuntimeException("当前商户未开启每日菜谱订餐模式");
}
return merchant;
}
/**
* 按当前付款人和支付抽屉选择的钱包类型查询钱包,拒绝空响应、越权钱包和异常余额。
*
* @param payerUserId 当前登录付款人 ID
* @param walletTypeId 支付抽屉选择的钱包类型 ID
* @return 归属于当前付款人的可用钱包
*/
private WalletManageEntity getDailyMenuPaymentWallet(Long payerUserId, Long walletTypeId) {
R<WalletManageEntity> result = remoteWalletService.getWalletByWalletTypeId(payerUserId, walletTypeId);
WalletManageEntity wallet = result == null || !result.isOk() ? null : result.getData();
if (wallet == null) {
throw new RuntimeException(result == null ? "查询用户钱包无响应" : StrUtil.blankToDefault(result.getMsg(), "用户钱包不存在"));
}
if (!Objects.equals(wallet.getUserId(), payerUserId)) {
throw new RuntimeException("钱包不属于当前付款人");
}
return wallet;
}
/**
* 使用 {@code canteen_spu} 当前数据复核购物车商品归属、可见状态和价格快照,价格变化时要求重新结算。
*
* @param merchantId 每日菜谱所属商户 ID
* @param contexts 已加载的日期餐段购物车上下文
*/
private void validateDailyMenuCartProducts(Long merchantId, List<DailyMenuReserveGroupContext> contexts) {
List<Long> productIds = contexts.stream()
.flatMap(context -> context.carList().stream())
.map(HardCarEntity::getProductId)
.distinct()
.toList();
R<List<CanteenSpuEntity>> result = remoteCanteenSpuService.getSpuListByIds(productIds);
if (result == null || !result.isOk() || result.getData() == null) {
throw new RuntimeException(result == null ? "查询每日菜谱商品无响应"
: StrUtil.blankToDefault(result.getMsg(), "查询每日菜谱商品失败"));
}
Map<Long, CanteenSpuEntity> productMap = result.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
for (DailyMenuReserveGroupContext context : contexts) {
for (HardCarEntity car : context.carList()) {
CanteenSpuEntity product = productMap.get(car.getProductId());
if (product == null || !Objects.equals(product.getMerchantId(), merchantId)
|| !Objects.equals(product.getIsShow(), "0")) {
throw new RuntimeException("每日菜谱商品不存在、已下架或不属于当前商户");
}
if (product.getPrice() == null || product.getPrice() < 0) {
throw new RuntimeException(product.getName() + "价格异常,请联系商户");
}
long currentTotalPrice = Math.multiplyExact((long) product.getPrice(), car.getProductCount().longValue());
if (!Objects.equals(product.getPrice(), car.getProductPrice())
|| !Objects.equals(currentTotalPrice, car.getTotalPrice())) {
throw new RuntimeException(product.getName() + "价格已变化,请重新选择菜品");
}
}
}
}
/**
* 商户开启单商品限制时,服务端按商品份数再次拦截每个日期餐段超过一份的购物车。
*
* @param merchant 当前每日菜谱商户
* @param contexts 已加载的日期餐段购物车上下文
*/
private void validateDailyMenuSingleProductLimit(MchManageVo merchant, List<DailyMenuReserveGroupContext> contexts) {
MchManageEntity.TableReservationConf conf = merchant.getTableReservationConf();
if (conf == null || !Boolean.TRUE.equals(conf.getSingleProductOnlyInCart())) {
return;
}
boolean exceedsLimit = contexts.stream().anyMatch(context -> context.carList().stream()
.mapToLong(HardCarEntity::getProductCount)
.sum() > 1L);
if (exceedsLimit) {
throw new RuntimeException("该商户每个日期餐段仅允许选择一份商品");
}
}
/**
* 使用购物车内服务端生成的商品快照拼接每日菜谱订单摘要,避免再次走在线商品查询分支。
*
* @param carList 一个日期餐段的购物车明细
* @return 订单列表和详情使用的商品摘要
*/
private String buildDailyMenuReserveOrderSubject(List<HardCarEntity> carList) {
return carList.stream()
.map(car -> car.getProductName() + "x" + car.getProductCount())
.collect(Collectors.joining(";", "", ";"));
}
/**
* 从父购物车现有扩展 JSON 读取服务端写入的每日菜谱场景标记,格式异常时按非每日菜谱处理。
*
* @param parentCar 待判断的父购物车
* @return {@code true} 表示商品来自 {@code canteen_spu} 每日菜谱且不参与在线库存变更
*/
private boolean isDailyMenuParentCar(HardParentCarEntity parentCar) {
return parentCar != null
&& parentCar.getTakeOutSettingJson() != null
&& Objects.equals(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE);
}
/**
* 每个每日菜谱日期餐段在服务端完成校验后的下单上下文。
*
* @param parentCar 当前付款人尚未下单的父购物车
* @param carList 由服务端生成价格快照的购物车明细
* @param reservation 预约服务规范化后的日期、餐段和预约规则
* @param singlePrice 单位用餐人当前餐段应付金额,单位为分
*/
private record DailyMenuReserveGroupContext(HardParentCarEntity parentCar,
List<HardCarEntity> carList,
HardApptReservationService.ReservationValidation reservation,
int singlePrice) {
}
/**
* 核心订单提交后再触发可幂等重试的下游任务;无事务调用时直接执行,便于复用和测试。
*/
@@ -1365,6 +2040,16 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
} catch (Exception compensationException) {
log.error("|{}|多人订餐库存补偿失败", TAG, compensationException);
}
compensateReserveOrderWallets(createdOrders);
// 克隆购物车与订单处于同一本地事务中,会随订单一并回滚;原购物车刻意保留,便于整批失败后重试。
}
/**
* 对整批已生成订单发起幂等钱包补偿;未产生 PAYMENT 流水的订单由钱包服务安全忽略。
*
* @param createdOrders 当前事务内已经分配订单 ID 的预约订单
*/
private void compensateReserveOrderWallets(List<HardOrderEntity> createdOrders) {
// 对所有已创建订单发起幂等补偿;钱包服务只会退回实际存在的 PAYMENT 流水。
ListIterator<HardOrderEntity> walletIterator = createdOrders.listIterator(createdOrders.size());
while (walletIterator.hasPrevious()) {
@@ -1384,7 +2069,6 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
log.error("|{}|多人订餐钱包补偿失败,orderId:|{}|", TAG, order.getId(), compensationException);
}
}
// 克隆购物车与订单处于同一本地事务中,会随订单一并回滚;原购物车刻意保留,便于整批失败后重试。
}
/**
@@ -2597,6 +3281,12 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
int base = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1;
// 食堂扣减库存
if (ObjectUtil.equals(hardTypeEnum, HardTypeEnum.CANTEEN) && ObjectUtil.equals(orderTypeEnum, HardOrderTypeEnum.RESERVE)) {
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
// 每日菜谱页面明确忽略库存;退款或取消时也必须保持对称,不能误恢复同 ID 的在线菜品库存。
if (isDailyMenuParentCar(parentCar)) {
log.info("|{}|每日菜谱订单忽略库存变更,orderId:|{}|,isReduce:|{}|", TAG, order.getId(), isReduce);
return;
}
// 查询购物车
List<HardCarEntity> carList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(carList)) throw new RuntimeException(CarErrorMsg + "错误,购物车不存在");
@@ -5,6 +5,9 @@ import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.canteen.api.feign.RemoteCanteenSpuService;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.ReservationRecognitionTypeEnum;
@@ -34,6 +37,7 @@ import org.mockito.ArgumentCaptor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
@@ -54,8 +58,10 @@ class ReservationMenuQueryServiceImplTest {
private final RemoteUserService remoteUserService = mock(RemoteUserService.class);
private final RemoteDeptService remoteDeptService = mock(RemoteDeptService.class);
private final RemoteMchService remoteMchService = mock(RemoteMchService.class);
private final RemoteCanteenSpuService remoteCanteenSpuService = mock(RemoteCanteenSpuService.class);
private final ReservationMenuQueryServiceImpl service = new ReservationMenuQueryServiceImpl(
hardManagerService, hardApiService, hardOrderService, remoteUserService, remoteDeptService, remoteMchService);
hardManagerService, hardApiService, hardOrderService, remoteUserService, remoteDeptService, remoteMchService,
remoteCanteenSpuService);
@Test
void cardQueryUsesTerminalMerchantAndSelfOnlyFilters() {
@@ -161,6 +167,58 @@ class ReservationMenuQueryServiceImplTest {
assertEquals(2, result.getDays().get(0).getMeals().get(0).getOrders().get(1).getItems().get(0).getProductCount());
}
/** 验证预约订单商品会批量补充套餐类型与按配置排序的组成明细。 */
@Test
void comboMetadataIsMappedToReservationOrderItems() {
stubTerminal(100L, 200L, "第一食堂");
stubFaceUser(300L, "王小华", 400L);
when(hardOrderService.scopeList(any(), isNull())).thenReturn(List.of(
order(1L, LocalDate.of(2026, 8, 6), "2", "午餐预约", LocalDateTime.of(2026, 8, 5, 8, 0),
car(501L, "小黄鱼套餐", "/menu/combo.jpg", 1))));
CanteenSpuEntity comboSpu = new CanteenSpuEntity();
comboSpu.setId(501L);
comboSpu.setSpuType(2);
when(remoteCanteenSpuService.getSpuListByIds(List.of(501L))).thenReturn(R.ok(List.of(comboSpu)));
OnlineSpuComboItemVO comboItem = new OnlineSpuComboItemVO();
comboItem.setChildSpuId(601L);
comboItem.setName("小黄鱼");
comboItem.setPicUrl("/menu/fish.jpg");
comboItem.setQuantity(1);
comboItem.setSortOrder(0);
comboItem.setPrice(1000);
when(remoteCanteenSpuService.getComboItemsBySpuIds(List.of(501L)))
.thenReturn(R.ok(Map.of(501L, List.of(comboItem))));
ReservationMenuQueryVo.OrderItem item = service.query(faceQuery(100L, 300L))
.getDays().get(0).getMeals().get(0).getOrders().get(0).getItems().get(0);
assertEquals(501L, item.getProductId());
assertEquals(2, item.getSpuType());
assertEquals(1, item.getComboItems().size());
assertEquals(601L, item.getComboItems().get(0).getChildSpuId());
assertEquals("小黄鱼", item.getComboItems().get(0).getName());
assertEquals(1000, item.getComboItems().get(0).getPrice());
}
/** 验证商品元数据服务不可用时只降级展示,不阻断预约菜单查询。 */
@Test
void productMetadataFailureDoesNotBlockReservationQuery() {
stubTerminal(100L, 200L, "第一食堂");
stubFaceUser(300L, "王小华", 400L);
when(hardOrderService.scopeList(any(), isNull())).thenReturn(List.of(
order(1L, LocalDate.of(2026, 8, 6), "2", "午餐预约", LocalDateTime.of(2026, 8, 5, 8, 0),
car(501L, "小黄鱼套餐", "/menu/combo.jpg", 1))));
when(remoteCanteenSpuService.getSpuListByIds(List.of(501L)))
.thenThrow(new RuntimeException("canteen unavailable"));
ReservationMenuQueryVo.OrderItem item = service.query(faceQuery(100L, 300L))
.getDays().get(0).getMeals().get(0).getOrders().get(0).getItems().get(0);
assertEquals(501L, item.getProductId());
assertEquals(1, item.getSpuType());
assertTrue(item.getComboItems().isEmpty());
}
@Test
void invalidTerminalBindingIsRejectedBeforeIdentityLookup() {
HardManagerEntity hard = new HardManagerEntity();
@@ -312,8 +370,15 @@ class ReservationMenuQueryServiceImplTest {
return order;
}
/** 构造不依赖商品元数据的历史订单商品。 */
private static HardCarVo car(String productName, String productPic, int productCount) {
return car(null, productName, productPic, productCount);
}
/** 构造带菜品 SPU ID 的订单商品,供套餐元数据用例复用。 */
private static HardCarVo car(Long productId, String productName, String productPic, int productCount) {
HardCarVo car = new HardCarVo();
car.setProductId(productId);
car.setProductName(productName);
car.setProductPic(productPic);
car.setProductCount(productCount);
@@ -5,14 +5,25 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import jakarta.validation.Valid;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 第三方支付服务内部 Feign 契约,复用统一内部调用头和服务发现配置。
*/
@FeignClient(contextId = "remoterThirdAppPlusPayService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoterThirdAppPlusPayService {
@@ -30,6 +41,46 @@ public interface RemoterThirdAppPlusPayService {
@PostMapping("/pay-remoter/micropay")
R<String> remoterMicropay(@RequestBody ThirdMicropayDto dto);
/**
* 创建支付宝或微信订单码支付订单。
*
* @param dto 可信内部调用方提供的业务、商户和金额信息
* @return 支付订单号、二维码内容串和服务端过期时间
*/
@NoToken
@PostMapping("/pay-remoter/order-code-pay")
R<ThirdOrderCodePayVo> orderCodePay(@Valid @RequestBody ThirdOrderCodePayDto dto);
/**
* 查询订单码支付的最小状态信息。
*
* @param payNo 第三方支付服务生成的支付订单号
* @return 内部支付状态、成功标识和过期时间
*/
@NoToken
@GetMapping("/pay-remoter/order-code-pay/status")
R<ThirdOrderCodePayStatusVo> getOrderCodePayStatus(@RequestParam("payNo") String payNo);
/**
* 创建支付宝与微信共用的聚合订单二维码。
*
* @param dto 业务订单、商户和金额参数,不指定支付平台
* @return 聚合会话编号、HTTPS 二维码内容和失效时间
*/
@PostMapping("/pay-remoter/aggregate-order-code-pay")
@NoToken
R<ThirdAggregateOrderCodePayVo> aggregateOrderCodePay(@Valid @RequestBody ThirdAggregateOrderCodePayDto dto);
/**
* 按聚合会话编号查询最小支付状态。
*
* @param aggregateNo 聚合支付会话编号
* @return 聚合状态、选定平台和实际第三方子订单号
*/
@GetMapping("/pay-remoter/aggregate-order-code-pay/status")
@NoToken
R<ThirdAggregateOrderCodePayStatusVo> getAggregateOrderCodePayStatus(@RequestParam("aggregateNo") String aggregateNo);
/**
* app支付
*/
@@ -0,0 +1,101 @@
package com.bsd.pjcloud.thirdparty.constant.pay;
import cn.hutool.core.util.ObjectUtil;
import java.util.Arrays;
/**
* 聚合订单码支付会话状态。
*
* <p>状态编码持久化到 {@code third_aggregate_pay_session.status},用于约束扫码选平台、
* 第三方子订单创建以及最终支付结果同步的单向状态流转。</p>
*/
public enum ThirdAggregatePaySessionStatusEnum {
/**
* 聚合码已经创建,尚未有支付宝或微信完成付款人授权。
*/
WAIT_SCAN("10", "等待扫码"),
/**
* 首个扫码平台已经锁定,正在创建对应平台的 JSAPI 子订单。
*/
PLATFORM_CREATING("15", "平台订单创建中"),
/**
* 第三方 JSAPI 子订单创建成功,正在等待付款结果。
*/
PAYING("20", "支付中"),
/**
* 绑定的第三方子订单已经确认支付成功。
*/
PAY_SUCCESS("30", "支付成功"),
/**
* 绑定的第三方子订单已被支付平台关闭或撤销。
*/
PAY_CLOSED("40", "交易已关闭"),
/**
* 聚合会话在未产生成功支付结果前超过服务端有效期。
*/
EXPIRED("50", "已过期"),
/**
* 选定平台后创建第三方子订单失败,当前聚合码不可继续支付。
*/
CREATE_FAILED("60", "创建失败");
/**
* 持久化到聚合支付会话表并通过状态接口返回的协议编码。
*/
private final String type;
/**
* 用于管理端和诊断信息展示的中文状态说明。
*/
private final String description;
/**
* 创建一个聚合支付会话状态枚举值。
*
* @param type 持久化状态编码
* @param description 中文状态说明
*/
ThirdAggregatePaySessionStatusEnum(String type, String description) {
this.type = type;
this.description = description;
}
/**
* 获取持久化状态编码。
*
* @return 聚合支付会话状态编码
*/
public String getType() {
return type;
}
/**
* 获取中文状态说明。
*
* @return 聚合支付会话状态说明
*/
public String getDescription() {
return description;
}
/**
* 按持久化编码解析聚合支付会话状态。
*
* @param type 数据库或接口中的状态编码,允许为空
* @return 匹配状态;编码为空或未知时返回 {@code null}
*/
public static ThirdAggregatePaySessionStatusEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) {
return null;
}
return Arrays.stream(values()).filter(value -> ObjectUtil.equals(value.type, type)).findFirst().orElse(null);
}
}
@@ -17,6 +17,7 @@ public enum ThirdPayOrderBizTypeEnum {
YXFJ_PAY("20", "云消费机付款码支付"),
APP_SHOP_PAY("30", "APP支付-购买商品(有订单)"),
GUARDIAN_WARD_WALLET_RECHARGE("40", "监护人代被监护人饭卡充值"),
XFJ("50", "食堂或者超市的消费机支付"),
;
private final String type;
@@ -1,32 +1,100 @@
package com.bsd.pjcloud.thirdparty.constant.pay;
import cn.hutool.core.util.ObjectUtil;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
* 第三方支付订单状态
* 第三方支付订单状态。
*
* <p>状态编码会持久化到 {@code third_pay_order.status},不得随意修改已有编码。</p>
*/
@AllArgsConstructor
@Getter
public enum ThirdPayOrderStatusEnum {
/**
* 支付订单已创建,尚未确认买家付款。
*/
PAY_UNPAID("10", "未支付"),
/**
* 支付平台已经确认买家付款成功。
*/
PAY_SUCCESS("20", "支付成功"),
/**
* 创建支付或支付处理明确失败,不能继续使用当前支付订单。
*/
PAY_FAILED("30", "支付失败"),
/**
* 支付平台已关闭交易,包括二维码超时后撤销成功的场景。
*/
PAY_CLOSED("40", "交易已关闭"),
/**
* 退款请求已受理,最终退款结果尚未确认。
*/
REFUNDING("100", "退款中"),
/**
* 退款已经成功完成。
*/
REFUND_SUCCESS("200", "退款成功"),
/**
* 退款处理明确失败。
*/
REFUND_FAILED("300", "退款失败"),
;
/**
* 持久化到支付订单表的状态编码,也是对外状态查询返回的协议值。
*/
private final String type;
/**
* 用于管理端和诊断信息展示的中文状态说明。
*/
private final String description;
/**
* 创建一个支付订单状态枚举值。
*
* @param type 持久化状态编码
* @param description 中文状态说明
*/
ThirdPayOrderStatusEnum(String type, String description) {
this.type = type;
this.description = description;
}
/**
* 获取持久化状态编码。
*
* @return 支付订单状态编码
*/
public String getType() {
return type;
}
/**
* 获取状态的中文说明。
*
* @return 中文状态说明
*/
public String getDescription() {
return description;
}
/**
* 按持久化状态编码解析枚举。
*
* @param type 支付订单状态编码,允许为空
* @return 匹配的状态;编码为空或未知时返回 {@code null}
*/
public static ThirdPayOrderStatusEnum valueOfByType(String type) {
if (ObjectUtil.isEmpty(type)) return null;
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().get();
return Arrays.stream(values()).filter(v -> ObjectUtil.equals(type, v.getType())).findFirst().orElse(null);
}
}
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.thirdparty.dto.appplus;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
/**
* 支付宝与微信聚合订单码创建请求。
*
* <p>请求不指定支付平台,实际平台由付款人使用支付宝或微信扫码后在服务端原子选定。</p>
*/
@Data
@Schema(description = "支付宝与微信聚合订单码创建请求")
public class ThirdAggregateOrderCodePayDto {
/**
* 支付业务类型,首版必须为 {@link ThirdPayOrderBizTypeEnum#YXFJ_PAY}。
*/
@NotNull(message = "支付业务类型不能为空")
@Schema(description = "支付业务类型", requiredMode = Schema.RequiredMode.REQUIRED)
private ThirdPayOrderBizTypeEnum bizType;
/**
* 调用方业务订单唯一标识,用于同一租户内的支付幂等和跨二维码互斥。
*/
@NotBlank(message = "支付业务id不能为空")
@Schema(description = "支付业务id", requiredMode = Schema.RequiredMode.REQUIRED)
private String bizId;
/**
* 收款商户ID,用于读取商户支付宝授权和微信支付子商户号配置。
*/
@NotNull(message = "商户id不能为空")
@Schema(description = "收款商户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long mchId;
/**
* 应付金额,单位为人民币分,必须为正整数。
*/
@NotNull(message = "支付金额不能为空")
@Positive(message = "支付金额必须大于0")
@Schema(description = "支付金额,单位为分", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer amount;
/**
* 付款用户ID;匿名消费机订单可不传,服务端统一按 {@code -1L} 保存。
*/
@Schema(description = "付款用户ID,匿名订单可为空")
private Long userId;
}
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.thirdparty.dto.appplus;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.Data;
/**
* 订单码支付创建请求。
*
* <p>接受云消费机支付宝或微信订单码,金额由可信内部调用方按人民币分传入。</p>
*/
@Data
@Schema(description = "订单码支付创建请求")
public class ThirdOrderCodePayDto {
/**
* 支付业务类型,必须为 {@link ThirdPayOrderBizTypeEnum#YXFJ_PAY}。
*/
@NotNull(message = "支付业务类型不能为空")
@Schema(description = "支付业务类型", requiredMode = Schema.RequiredMode.REQUIRED)
private ThirdPayOrderBizTypeEnum bizType;
/**
* 调用方业务订单唯一标识,用于同一租户内的支付幂等控制。
*/
@NotBlank(message = "支付业务id不能为空")
@Schema(description = "支付业务id", requiredMode = Schema.RequiredMode.REQUIRED)
private String bizId;
/**
* 扫码支付平台,必须为 {@link AppPlusPlatformTypeEnum#ALI_PAY} 或
* {@link AppPlusPlatformTypeEnum#WECHAT}。
*/
@NotNull(message = "扫码支付平台不能为空")
@Schema(description = "扫码支付平台", requiredMode = Schema.RequiredMode.REQUIRED)
private AppPlusPlatformTypeEnum miniPlatformType;
/**
* 应付金额,单位为人民币分,必须为正整数。
*/
@NotNull(message = "支付金额不能为空")
@Positive(message = "支付金额必须大于0")
@Schema(description = "支付金额,单位为分", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer amount;
/**
* 付款用户ID;匿名消费机订单可不传,服务端统一按 {@code -1L} 保存。
*/
@Schema(description = "付款用户ID,匿名订单可为空")
private Long userId;
/**
* 收款商户ID,服务端仅按该标识读取商户及其启用的目标平台配置。
*/
@NotNull(message = "商户id不能为空")
@Schema(description = "收款商户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long mchId;
}
@@ -0,0 +1,162 @@
package com.bsd.pjcloud.thirdparty.entity.pay;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 支付宝与微信聚合订单码会话。
*
* <p>会话只负责跨平台扫码选择、访问令牌和第三方子订单绑定,实际支付报文继续由
* {@code third_pay_order} 保存。</p>
*/
@Data
@TenantTable
@TableName("third_aggregate_pay_session")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "支付宝与微信聚合订单码会话")
public class ThirdAggregatePaySessionEntity extends Model<ThirdAggregatePaySessionEntity> {
/**
* 聚合会话主键,由 MyBatis-Plus 分布式ID生成器写入。
*/
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键")
private Long id;
/**
* 聚合会话唯一编号,供内部创建和状态查询接口使用。
*/
@Schema(description = "聚合会话编号")
private String aggregateNo;
/**
* 收银 URL 随机访问令牌的 SHA-256 十六进制摘要,用于扫码入口等值查询。
*/
@Schema(description = "访问令牌SHA-256摘要")
private String accessTokenHash;
/**
* 收银 URL 随机访问令牌的 Jasypt 密文,仅用于幂等请求恢复原二维码。
*/
@Schema(description = "访问令牌密文")
private String accessTokenCiphertext;
/**
* 支付业务类型编码,首版固定为云消费机支付业务。
*/
@Schema(description = "支付业务类型编码")
private String bizType;
/**
* 调用方业务订单唯一标识,同一租户内用于支付幂等。
*/
@Schema(description = "支付业务id")
private String bizId;
/**
* 付款用户ID;匿名消费机订单保存为 {@code -1L}。
*/
@Schema(description = "付款用户ID")
private Long userId;
/**
* 收款商户ID,对应钱包服务中的商户主键。
*/
@Schema(description = "收款商户ID")
private Long mchId;
/**
* 应付金额,单位为人民币分,和绑定的第三方子订单金额保持一致。
*/
@Schema(description = "支付金额,单位为分")
private Integer price;
/**
* 聚合会话状态编码,对应 {@code ThirdAggregatePaySessionStatusEnum}。
*/
@Schema(description = "聚合会话状态编码")
private String status;
/**
* 首个有效 OAuth 回调原子选定的支付平台编码;尚未扫码时为空。
*/
@Schema(description = "选定支付平台编码")
private String selectedPlatform;
/**
* 绑定的 {@code third_pay_order.pay_no};未创建第三方子订单时为空。
*/
@Schema(description = "第三方支付子订单号")
private String payNo;
/**
* 支付宝用户ID或微信服务商公众号 sp_openid 的 SHA-256 摘要,用于阻止不同付款人并发复用会话。
*/
@Schema(description = "付款人身份SHA-256摘要")
private String payerIdentityHash;
/**
* 聚合二维码和第三方子订单的服务端失效时间,使用服务端本地时区。
*/
@Schema(description = "失效时间")
private LocalDateTime expireTime;
/**
* 聚合会话进入成功、关闭、过期或创建失败终态的服务端时间。
*/
@Schema(description = "终态确认时间")
private LocalDateTime resolvedTime;
/**
* 创建人,由公共字段填充器写入;匿名扫码场景允许为空。
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建人")
private String createBy;
/**
* 会话创建时间,由公共字段填充器写入。
*/
@TableField(fill = FieldFill.INSERT)
@Schema(description = "创建时间")
private LocalDateTime createTime;
/**
* 最近修改人,由公共字段填充器写入;定时任务更新时允许为空。
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改人")
private String updateBy;
/**
* 最近状态或绑定信息更新时间,由公共字段填充器写入。
*/
@TableField(fill = FieldFill.INSERT_UPDATE)
@Schema(description = "修改时间")
private LocalDateTime updateTime;
/**
* 逻辑删除标记,{@code 0} 表示有效,{@code 1} 表示已删除。
*/
@TableLogic
@TableField(fill = FieldFill.INSERT)
@Schema(description = "逻辑删除标记")
private String delFlag;
/**
* 会话所属租户ID,由多租户拦截器和公共字段填充逻辑维护。
*/
@Schema(description = "租户ID")
private Long tenantId;
}
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 聚合订单码最小支付状态。
*
* <p>不返回访问令牌、付款人标识、商户配置或第三方原始报文。</p>
*/
@Data
@Schema(description = "聚合订单码最小支付状态")
public class ThirdAggregateOrderCodePayStatusVo {
/**
* 聚合支付服务生成的会话编号。
*/
@Schema(description = "聚合支付会话编号")
private String aggregateNo;
/**
* 聚合会话内部状态编码,对应 {@code ThirdAggregatePaySessionStatusEnum}。
*/
@Schema(description = "聚合会话状态编码")
private String status;
/**
* 首个有效扫码后选定的支付平台;尚未扫码时为空。
*/
@Schema(description = "选定支付平台")
private AppPlusPlatformTypeEnum selectedPlatform;
/**
* 实际第三方 JSAPI 子订单号;尚未选定平台时为空。
*/
@Schema(description = "第三方支付子订单号")
private String payNo;
/**
* 是否已经由第三方支付平台确认支付成功。
*/
@Schema(description = "是否支付成功")
private Boolean paySuccess;
/**
* 服务端认定的聚合二维码失效时间,使用服务端本地时区。
*/
@Schema(description = "聚合二维码失效时间")
private LocalDateTime expireTime;
}
@@ -0,0 +1,38 @@
package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 支付宝与微信聚合订单码创建结果。
*/
@Data
@Schema(description = "支付宝与微信聚合订单码创建结果")
public class ThirdAggregateOrderCodePayVo {
/**
* 聚合支付服务生成的会话编号,用于内部状态查询和问题定位。
*/
@Schema(description = "聚合支付会话编号")
private String aggregateNo;
/**
* 同时供支付宝和微信扫码访问的 HTTPS 收银地址,不是图片或 Base64。
*/
@Schema(description = "聚合二维码内容串")
private String qrCode;
/**
* 二维码协议类型,固定为 {@code AGGREGATE_ORDER_PAY},不得与自有钱包码混用。
*/
@Schema(description = "二维码协议类型")
private String qrCodeType;
/**
* 服务端认定的聚合二维码失效时间,使用服务端本地时区。
*/
@Schema(description = "聚合二维码失效时间")
private LocalDateTime expireTime;
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 订单码支付状态查询结果。
*
* <p>仅返回调用方确认付款所需的最小状态,不包含商户配置和第三方平台原始报文。</p>
*/
@Data
@Schema(description = "订单码支付状态查询结果")
public class ThirdOrderCodePayStatusVo {
/**
* 第三方支付服务生成的唯一商户订单号。
*/
@Schema(description = "支付订单号")
private String payNo;
/**
* third_pay_order 中保存的内部支付状态编码。
*/
@Schema(description = "内部支付状态编码")
private String status;
/**
* 是否已经确认支付成功,仅内部状态为支付成功时返回 {@code true}。
*/
@Schema(description = "是否支付成功")
private Boolean paySuccess;
/**
* 服务端认定的二维码失效时间,使用服务端本地时区。
*/
@Schema(description = "二维码失效时间")
private LocalDateTime expireTime;
}
@@ -0,0 +1,34 @@
package com.bsd.pjcloud.thirdparty.vo.appplus.pay;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 订单码支付创建结果。
*
* <p>二维码字段是支付宝 {@code qr_code} 或微信 {@code code_url} 内容串,调用方负责将其渲染为二维码图片。</p>
*/
@Data
@Schema(description = "订单码支付创建结果")
public class ThirdOrderCodePayVo {
/**
* 第三方支付服务生成的唯一商户订单号,用于后续查询支付状态。
*/
@Schema(description = "支付订单号")
private String payNo;
/**
* 支付宝 {@code qr_code} 或微信 {@code code_url} 内容串,不是图片、Base64 或二进制数据。
*/
@Schema(description = "第三方支付平台二维码内容串")
private String qrCode;
/**
* 服务端认定的二维码失效时间,使用服务端本地时区。
*/
@Schema(description = "二维码失效时间")
private LocalDateTime expireTime;
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.thirdparty.config;
import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 微信服务商 JSAPI 聚合支付 SDK 配置。
*
* <p>单独提供 Bean,避免改变现有 App、Native、退款和分账 SDK Bean 的创建方式。</p>
*/
@Configuration
@RequiredArgsConstructor
public class AggregateWechatPayConfiguration {
/**
* 现有微信服务商证书和 APIv3 配置来源。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 构建微信支付服务商 JSAPI 扩展服务,供聚合码创建、查单和关单复用。
*
* @return 使用现有服务商证书初始化的 JSAPI 支付服务
*/
@Bean
public JsapiServiceExtension aggregateWechatJsapiServiceExtension() {
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
Config config = new RSAAutoCertificateConfig.Builder()
.merchantId(wechatConfig.getMerchantId())
.privateKeyFromPath(wechatConfig.getMchPrivatePath())
.merchantSerialNumber(wechatConfig.getMerchantSerialNumber())
.apiV3Key(wechatConfig.getApiV3Key())
.build();
return new JsapiServiceExtension.Builder().config(config).build();
}
}
@@ -10,6 +10,7 @@ import com.wechat.pay.java.core.RSAAutoCertificateConfig;
import com.wechat.pay.java.core.notification.NotificationConfig;
import com.wechat.pay.java.core.notification.NotificationParser;
import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension;
import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.refund.RefundService;
import lombok.extern.slf4j.Slf4j;
@@ -55,6 +56,23 @@ public class WechatV3Configuration {
return new AppServiceExtension.Builder().config(config).build();
}
/**
* 构建微信支付服务商 Native SDK 服务,供订单码下单、查单和关单复用。
*
* @return 使用现有服务商证书、商户号和 APIv3 密钥初始化的 Native 支付服务
*/
@Bean
public NativePayService nativePayService() {
AppPlusPayConfig.WechatConfig wechatConfig = properties.getWechatConfig();
Config config = new RSAAutoCertificateConfig.Builder()
.merchantId(wechatConfig.getMerchantId())
.privateKeyFromPath(wechatConfig.getMchPrivatePath())
.merchantSerialNumber(wechatConfig.getMerchantSerialNumber())
.apiV3Key(wechatConfig.getApiV3Key())
.build();
return new NativePayService.Builder().config(config).build();
}
@Bean
public NotificationParser notificationParser() {
AppPlusPayConfig.WechatConfig wechatConfig = properties.getWechatConfig();
@@ -0,0 +1,172 @@
package com.bsd.pjcloud.thirdparty.controller.appplus;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateCashierHtmlRenderer;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.URI;
/**
* 支付宝与微信聚合订单码匿名收银入口。
*
* <p>所有入口先用租户唯一编码恢复租户上下文,不接收租户ID或任意回跳地址。</p>
*/
@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/aggregate-order-pay")
public class ThirdAggregateOrderPayController {
/**
* 聚合支付编排服务,负责访问令牌、OAuth state、付款人互斥和状态查询。
*/
private final ThirdAggregateOrderPayService aggregateOrderPayService;
/**
* 固定收银 HTML 渲染器,负责 CSP nonce 和 JSBridge 页面输出。
*/
private final ThirdAggregateCashierHtmlRenderer htmlRenderer;
/**
* 识别支付宝或微信扫码客户端并跳转对应 OAuth 授权入口。
*
* @param tenantCode 聚合二维码中的租户唯一编码
* @param token 聚合二维码中的随机访问令牌
* @param userAgent 扫码客户端 User-Agent
* @return OAuth 重定向;普通浏览器或失效请求返回固定提示页
*/
@GetMapping(value = "/cashier/{tenantCode}/{token}", produces = MediaType.TEXT_HTML_VALUE)
@Inner(value = false)
public ResponseEntity<String> cashier(@NotBlank @PathVariable String tenantCode, @NotBlank @PathVariable String token,
@RequestHeader(value = HttpHeaders.USER_AGENT, required = false) String userAgent) {
try {
String oauthUrl = aggregateOrderPayService.getCashierOauthUrl(tenantCode, token, userAgent);
return ResponseEntity.status(HttpStatus.FOUND).headers(baseHeaders(null)).location(URI.create(oauthUrl)).build();
} catch (RuntimeException exception) {
return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
}
}
/**
* 接收服务商统一公众号 OAuth 回调并返回微信 JSBridge 收银页。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param code 微信一次性授权码
* @param state 服务端创建的一次性 OAuth state
* @return 带 CSP nonce 的固定微信收银页
*/
@GetMapping(value = "/oauth/wechat/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE)
@Inner(value = false)
public ResponseEntity<String> wechatOauth(@NotBlank @PathVariable String tenantCode,
@NotBlank @RequestParam("code") String code,
@NotBlank @RequestParam("state") String state) {
try {
ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleWechatOauth(tenantCode, code, state);
return payPageResponse(pageData);
} catch (RuntimeException exception) {
return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
}
}
/**
* 接收支付宝 OAuth 回调并返回 AlipayJSBridge 收银页。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param authCode 支付宝一次性授权码
* @param state 服务端创建的一次性 OAuth state
* @return 带 CSP nonce 的固定支付宝收银页
*/
@GetMapping(value = "/oauth/alipay/{tenantCode}", produces = MediaType.TEXT_HTML_VALUE)
@Inner(value = false)
public ResponseEntity<String> alipayOauth(@NotBlank @PathVariable String tenantCode,
@NotBlank @RequestParam("auth_code") String authCode,
@NotBlank @RequestParam("state") String state) {
try {
ThirdAggregateCashierPageData pageData = aggregateOrderPayService.handleAlipayOauth(tenantCode, authCode, state);
return payPageResponse(pageData);
} catch (RuntimeException exception) {
return messageResponse(exception.getMessage(), HttpStatus.BAD_REQUEST);
}
}
/**
* 使用短期 ticket 返回收银页轮询所需的最小支付状态。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param ticket 收银页短期状态票据
* @return 聚合状态、选定平台和支付成功标识
*/
@GetMapping("/status/{tenantCode}/{ticket}")
@Inner(value = false)
public ResponseEntity<R<ThirdAggregateOrderCodePayStatusVo>> status(@NotBlank @PathVariable String tenantCode,
@NotBlank @PathVariable String ticket) {
R<ThirdAggregateOrderCodePayStatusVo> body;
try {
body = R.ok(aggregateOrderPayService.getStatusByTicket(tenantCode, ticket));
} catch (RuntimeException exception) {
body = R.failed(exception.getMessage());
}
return ResponseEntity.ok().headers(baseHeaders(null)).body(body);
}
/**
* 构造支付页响应并设置严格 CSP、禁止缓存和禁止来源泄漏响应头。
*
* @param pageData 平台调起参数和短期状态地址
* @return HTML 收银页响应
*/
private ResponseEntity<String> payPageResponse(ThirdAggregateCashierPageData pageData) {
String nonce = htmlRenderer.createNonce();
return ResponseEntity.ok().headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML)
.body(htmlRenderer.renderPayPage(pageData, nonce));
}
/**
* 构造安全提示页响应,提示文本由渲染器执行 HTML 转义。
*
* @param message 面向付款人的短提示
* @param status HTTP 状态码
* @return 不包含平台参数的固定 HTML 响应
*/
private ResponseEntity<String> messageResponse(String message, HttpStatus status) {
String nonce = htmlRenderer.createNonce();
return ResponseEntity.status(status).headers(baseHeaders(nonce)).contentType(MediaType.TEXT_HTML)
.body(htmlRenderer.renderMessagePage(message, nonce));
}
/**
* 构造聚合收银入口共用的安全响应头。
*
* @param nonce HTML 响应 CSP nonce;重定向和 JSON 响应时为空
* @return 禁止缓存、禁止来源泄漏和禁止 MIME 猜测的响应头
*/
private HttpHeaders baseHeaders(String nonce) {
HttpHeaders headers = new HttpHeaders();
headers.setCacheControl(CacheControl.noStore());
headers.set("Pragma", "no-cache");
headers.set("Referrer-Policy", "no-referrer");
headers.set("X-Content-Type-Options", "nosniff");
if (nonce != null) {
headers.set("Content-Security-Policy", "default-src 'none'; script-src 'nonce-" + nonce
+ "'; style-src 'nonce-" + nonce + "'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'");
}
return headers;
}
}
@@ -7,10 +7,16 @@ import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMicropayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdMiniRemotePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdRemoterRefundDto;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatFzService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
@@ -18,26 +24,45 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 第三方支付内部远程调用入口,统一通过 {@link Inner} 约束服务间访问。
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/pay-remoter")
@Tag(description = "remoter", name = "支付")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
@Validated
public class ThirdAppPlusController {
/**
* 微信分账服务,用于现有商户配置变更后的接收方同步。
*/
private final ThirdAppPlusWechatFzService thirdAppPlusWechatFzService;
/**
* 第三方支付编排服务,承接退款、付款码、订单码和 App 支付请求。
*/
private final ThirdAppPlusService thirdAppPlusService;
/* ************************************* start 远程调用 start ************************************* */
/**
* 接收内部支付退款请求。
*
* @param dto 退款业务、订单和金额信息
* @return 退款平台订单号
*/
@Operation(summary = "支付退款")
@PostMapping("/refund")
@Inner
@@ -45,6 +70,12 @@ public class ThirdAppPlusController {
return R.ok(thirdAppPlusService.remoterRefund(dto));
}
/**
* 接收内部付款码支付请求。
*
* @param dto 用户付款码、业务订单、商户和金额信息
* @return 支付成功后的内部支付订单号
*/
@Operation(summary = "付款码支付")
@PostMapping("/micropay")
@Inner
@@ -52,6 +83,65 @@ public class ThirdAppPlusController {
return R.ok(thirdAppPlusService.micropay(dto));
}
/**
* 接收内部服务的支付宝或微信订单码创建请求。
*
* @param dto 经过 Bean Validation 校验的订单码支付参数
* @return 支付订单号、二维码内容串和过期时间
*/
@Operation(summary = "订单码支付")
@PostMapping("/order-code-pay")
@Inner
public R<ThirdOrderCodePayVo> orderCodePay(@Valid @RequestBody ThirdOrderCodePayDto dto) {
return R.ok(thirdAppPlusService.orderCodePay(dto));
}
/**
* 查询内部订单码支付状态。
*
* @param payNo 第三方支付服务生成的支付订单号
* @return 支付订单的最小状态信息
*/
@Operation(summary = "查询订单码支付状态")
@GetMapping("/order-code-pay/status")
@Inner
public R<ThirdOrderCodePayStatusVo> getOrderCodePayStatus(@NotBlank(message = "支付订单号不能为空") @RequestParam("payNo") String payNo) {
return R.ok(thirdAppPlusService.getOrderCodePayStatus(payNo));
}
/**
* 接收内部服务的支付宝与微信聚合订单码创建请求。
*
* @param dto 不指定支付平台的业务订单、商户和金额参数
* @return 聚合会话编号、HTTPS 二维码内容和过期时间
*/
@Operation(summary = "支付宝微信聚合订单码支付")
@PostMapping("/aggregate-order-code-pay")
@Inner
public R<ThirdAggregateOrderCodePayVo> aggregateOrderCodePay(@Valid @RequestBody ThirdAggregateOrderCodePayDto dto) {
return R.ok(thirdAppPlusService.aggregateOrderCodePay(dto));
}
/**
* 查询内部聚合订单码支付状态。
*
* @param aggregateNo 聚合支付会话编号
* @return 聚合状态、选定平台和第三方子订单号
*/
@Operation(summary = "查询支付宝微信聚合订单码支付状态")
@GetMapping("/aggregate-order-code-pay/status")
@Inner
public R<ThirdAggregateOrderCodePayStatusVo> getAggregateOrderCodePayStatus(
@NotBlank(message = "聚合支付会话编号不能为空") @RequestParam("aggregateNo") String aggregateNo) {
return R.ok(thirdAppPlusService.getAggregateOrderCodePayStatus(aggregateNo));
}
/**
* 接收现有 App 支付请求。
*
* @param dto App 用户及支付业务参数
* @return App 拉起第三方支付所需参数
*/
@Operation(summary = "app支付")
@PostMapping("/app-pay")
public R<ThirdMiniAppPayVo> remoterAppPay(@RequestBody ThirdMiniRemotePayDto dto) {
@@ -60,6 +150,12 @@ public class ThirdAppPlusController {
/* ************************************* end 远程调用 end ************************************* */
/**
* 同步商户配置变更到微信分账接收方维护流程。
*
* @param dto 变更后的商户详情和支付配置
* @return 接收变更请求后返回 {@code true}
*/
@Operation(summary = "商户改动")
@PostMapping("/change-mch")
@Inner
@@ -18,12 +18,16 @@ import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* App 与小程序支付接口及第三方平台回调入口。
*/
@RestController
@RequiredArgsConstructor
@RequestMapping("/third-app-pay")
@@ -31,6 +35,9 @@ import java.util.Map;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class ThirdMinAppPayCotroller {
/**
* 第三方支付编排服务,负责支付、退款、查询和回调状态推进。
*/
private final ThirdAppPlusService thirdMiniAppService;
@@ -58,17 +65,25 @@ public class ThirdMinAppPayCotroller {
return R.ok(thirdMiniAppService.miniPay(user.getId(), dto));
}
/**
* 接收支付宝或微信共享支付回调,并按平台协议返回纯文本确认值。
*
* @param request Servlet 请求,用于读取支付宝表单参数
* @param httpEntity 请求实体,用于读取微信回调原始请求体
* @param headers 请求头,用于微信回调验签
* @return 支付平台要求的纯文本 {@code success} 或 {@code fail}
*/
@Operation(summary = "支付回调")
@PostMapping("/app/pay-callback")
@PostMapping(value = "/app/pay-callback", produces = MediaType.TEXT_PLAIN_VALUE)
@Inner(value = false)
public R<String> miniPayCallback(HttpServletRequest request, HttpEntity<String> httpEntity, @RequestHeader Map<String, String> headers) {
public String miniPayCallback(HttpServletRequest request, HttpEntity<String> httpEntity, @RequestHeader Map<String, String> headers) {
String body = httpEntity.getBody();
Map<String, String[]> parameterMap = request.getParameterMap();
Map<String, String> params = new HashMap<>();
for (Map.Entry<String, String[]> entry : parameterMap.entrySet()) {
params.put(entry.getKey(), entry.getValue()[0]);
}
return R.ok(thirdMiniAppService.miniPayCallback(params, body, headers));
return thirdMiniAppService.miniPayCallback(params, body, headers);
}
@Operation(summary = "退款回调")
@@ -0,0 +1,71 @@
package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateOrderPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 支付宝与微信聚合订单码状态轮询和到期关闭任务。
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class ThirdAggregateOrderPayJob {
/**
* 单轮最多处理的聚合会话数量,限制第三方网关慢调用对调度线程的占用。
*/
private static final int BATCH_SIZE = 100;
/**
* 聚合会话服务,用于跨租户分批读取仍可能支付的会话。
*/
private final ThirdAggregatePaySessionService aggregatePaySessionService;
/**
* 聚合支付编排服务,负责平台查单、撤销、关单和原子状态同步。
*/
private final ThirdAggregateOrderPayService aggregateOrderPayService;
/**
* 每五秒分批处理等待扫码、平台创建中和支付中的聚合会话。
*
* <p>调度线程初始没有租户上下文,单条处理前使用记录自身 tenantId 恢复上下文;
* Redis 键仍由公共基础设施自动附加租户前缀。</p>
*/
@Scheduled(fixedDelay = 5000L, initialDelay = 5000L)
public void handlePendingSessions() {
List<ThirdAggregatePaySessionEntity> sessions = aggregatePaySessionService.list(
new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.in(ThirdAggregatePaySessionEntity::getStatus,
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType())
.orderByAsc(ThirdAggregatePaySessionEntity::getCreateTime)
.last("limit " + BATCH_SIZE));
if (ObjectUtil.isEmpty(sessions)) {
return;
}
for (ThirdAggregatePaySessionEntity session : sessions) {
if (session.getTenantId() == null) {
log.warn("聚合支付会话缺少持久化租户ID,sessionId:{}", session.getId());
continue;
}
try {
TenantBroker.runAs(session.getTenantId(), tenantId -> aggregateOrderPayService.processPendingSession(session.getId()));
} catch (Exception exception) {
log.error("处理聚合支付会话异常,sessionId:{},tenantId:{}", session.getId(), session.getTenantId(), exception);
}
}
}
}
@@ -4,23 +4,42 @@ package com.bsd.pjcloud.thirdparty.job;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.List;
/**
* 第三方支付订单定时处理任务,负责补偿业务回调及轮询支付宝、微信订单码状态。
*/
@Component
@Slf4j
@RequiredArgsConstructor
public class ThirdAppPlusPayJob {
/**
* 单轮订单码轮询的最大处理数量,避免慢支付网关调用长期占用调度线程。
*/
private static final int ORDER_CODE_BATCH_SIZE = 100;
/**
* 支付订单服务,用于分批读取仍未支付的订单码记录。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 支付编排服务,负责按平台查询并执行带状态条件的成功或关闭更新。
*/
private final ThirdAppPlusService thirdAppPlusService;
/**
@@ -47,7 +66,42 @@ public class ThirdAppPlusPayJob {
}
/**
* 处理,长时间未支付的订单(1个小时不支付)
* 每五秒分批处理支付宝和微信云消费机订单码。
*
* <p>初始查询在线程无租户上下文时跨租户读取,处理单条订单前通过 TenantBroker
* 恢复订单所属租户,确保后续读取和原子更新都受正确租户条件约束。</p>
*/
@Scheduled(fixedDelay = 5000L, initialDelay = 5000L)
public void handleOrderCodeOrders() {
List<ThirdPayOrderEntity> orders = thirdPayOrderService.list(new LambdaQueryWrapper<ThirdPayOrderEntity>()
.in(ThirdPayOrderEntity::getPayPlatformType,
AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType())
.eq(ThirdPayOrderEntity::getBizType, ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
.isNotNull(ThirdPayOrderEntity::getExpireTime)
.notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'")
.orderByAsc(ThirdPayOrderEntity::getCreateTime)
.last("limit " + ORDER_CODE_BATCH_SIZE));
if (ObjectUtil.isEmpty(orders)) {
return;
}
for (ThirdPayOrderEntity order : orders) {
if (order.getTenantId() == null) {
log.warn("订单码缺少租户ID,orderId:{},platform:{}", order.getId(), order.getPayPlatformType());
continue;
}
try {
TenantBroker.runAs(order.getTenantId(), tenantId -> {
ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(order.getId());
if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
thirdAppPlusService.queryPayResult(latestOrder);
}
});
} catch (Exception exception) {
log.error("处理订单码状态异常,orderId:{},tenantId:{},platform:{}",
order.getId(), order.getTenantId(), order.getPayPlatformType(), exception);
}
}
}
}
@@ -0,0 +1,14 @@
package com.bsd.pjcloud.thirdparty.mapper.pay;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import org.apache.ibatis.annotations.Mapper;
/**
* 聚合订单码支付会话 Mapper。
*
* <p>所有读写均通过多租户 MyBatis 拦截器自动附加租户条件。</p>
*/
@Mapper
public interface ThirdAggregatePaySessionMapper extends PjcloudBaseMapper<ThirdAggregatePaySessionEntity> {
}
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.thirdparty.model.pay;
import cn.hutool.core.util.StrUtil;
/**
* 支付宝用户授权换取的付款人身份。
*
* <p>新应用优先使用 open_id,兼容尚未返回 open_id 的存量应用 user_id。</p>
*/
public class ThirdAggregateAlipayOauthIdentity {
/**
* 支付宝开放平台用户 open_id;接口未返回时为空。
*/
private final String openId;
/**
* 支付宝历史用户 user_id;新接口只返回 open_id 时可为空。
*/
private final String userId;
/**
* 创建支付宝付款人身份值对象。
*
* @param openId 支付宝 open_id,允许为空
* @param userId 支付宝 user_id,允许为空
*/
public ThirdAggregateAlipayOauthIdentity(String openId, String userId) {
this.openId = openId;
this.userId = userId;
}
/**
* 获取支付宝 open_id。
*
* @return open_id;未返回时为空
*/
public String getOpenId() {
return openId;
}
/**
* 获取支付宝历史 user_id。
*
* @return user_id;未返回时为空
*/
public String getUserId() {
return userId;
}
/**
* 获取用于会话付款人互斥的稳定身份值,优先选择 open_id。
*
* @return 非空支付宝付款人身份值
*/
public String getStableIdentity() {
return StrUtil.firstNonBlank(openId, userId);
}
}
@@ -0,0 +1,68 @@
package com.bsd.pjcloud.thirdparty.model.pay;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 聚合收银页调起第三方 JSBridge 所需的最小参数。
*/
public class ThirdAggregateCashierLaunch {
/**
* 当前会话已经原子选定的支付宝或微信平台。
*/
private final AppPlusPlatformTypeEnum platform;
/**
* 已保存到 {@code third_pay_order} 的第三方商户订单号。
*/
private final String payNo;
/**
* 传给平台 JSBridge 的签名参数副本,不包含商户密钥或公众号 Secret。
*/
private final Map<String, Object> payParams;
/**
* 创建收银页调起结果并复制参数,避免调用方修改已持久化语义。
*
* @param platform 支付平台
* @param payNo 第三方商户订单号
* @param payParams JSBridge 调起参数
*/
public ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum platform, String payNo, Map<String, Object> payParams) {
this.platform = platform;
this.payNo = payNo;
this.payParams = Collections.unmodifiableMap(new LinkedHashMap<>(payParams));
}
/**
* 获取当前选定支付平台。
*
* @return 支付宝或微信平台
*/
public AppPlusPlatformTypeEnum getPlatform() {
return platform;
}
/**
* 获取第三方商户订单号。
*
* @return {@code third_pay_order.pay_no}
*/
public String getPayNo() {
return payNo;
}
/**
* 获取不可变的 JSBridge 调起参数。
*
* @return 不包含密钥的调起参数
*/
public Map<String, Object> getPayParams() {
return payParams;
}
}
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.thirdparty.model.pay;
/**
* 聚合 OAuth 回调后渲染收银页所需的数据。
*/
public class ThirdAggregateCashierPageData {
/**
* 支付宝或微信 JSBridge 调起参数及平台信息。
*/
private final ThirdAggregateCashierLaunch launch;
/**
* 当前租户下使用短期 ticket 查询支付结果的 HTTPS 地址。
*/
private final String statusUrl;
/**
* 创建聚合收银页数据。
*
* @param launch JSBridge 调起数据
* @param statusUrl 短期状态查询地址
*/
public ThirdAggregateCashierPageData(ThirdAggregateCashierLaunch launch, String statusUrl) {
this.launch = launch;
this.statusUrl = statusUrl;
}
/**
* 获取 JSBridge 调起数据。
*
* @return 当前选定平台的调起数据
*/
public ThirdAggregateCashierLaunch getLaunch() {
return launch;
}
/**
* 获取短期状态查询地址。
*
* @return 当前租户下的状态查询 HTTPS 地址
*/
public String getStatusUrl() {
return statusUrl;
}
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.thirdparty.model.pay;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 聚合支付平台子订单创建上下文。
*
* <p>由聚合编排服务在锁定平台后构造,仅在第三方服务内部传递。</p>
*/
@Data
public class ThirdAggregatePayCreateContext {
/**
* 预分配的第三方商户订单号,必须与聚合会话绑定值一致。
*/
private String payNo;
/**
* 调用方业务订单唯一标识。
*/
private String bizId;
/**
* 支付业务类型,首版固定为云消费机支付。
*/
private ThirdPayOrderBizTypeEnum bizType;
/**
* 付款用户ID;匿名消费机订单为 {@code -1L}。
*/
private Long userId;
/**
* 收款商户ID。
*/
private Long mchId;
/**
* 应付金额,单位为人民币分。
*/
private Integer amount;
/**
* 服务端支付截止时间,同时写入聚合会话和第三方子订单。
*/
private LocalDateTime expireTime;
}
@@ -0,0 +1,51 @@
package com.bsd.pjcloud.thirdparty.service;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
/**
* 支付宝聚合订单码 OAuth 与 JSAPI 支付能力。
*/
public interface ThirdAggregateAliPayService {
/**
* 使用一次性授权码换取支付宝付款人身份。
*
* @param authCode 支付宝 OAuth 回调授权码
* @return 包含 open_id 或兼容 user_id 的付款人身份
*/
ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode);
/**
* 调用 {@code alipay.trade.create} 创建服务商模式 JSAPI 子订单并保存内部支付订单。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的支付宝授权配置
* @param identity 已通过 OAuth 获取的支付宝付款人身份
* @return 支付宝 JSBridge 调起参数
*/
ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity);
/**
* 查询聚合支付宝子订单状态。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝查询响应;网络或 SDK 异常时返回 {@code null}
*/
AlipayTradeQueryResponse query(String payNo, String settingJson);
/**
* 撤销到期但尚未完成的聚合支付宝子订单。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝撤销响应;网络或 SDK 异常时返回 {@code null}
*/
AlipayTradeCancelResponse cancel(String payNo, String settingJson);
}
@@ -0,0 +1,176 @@
package com.bsd.pjcloud.thirdparty.service;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import org.springframework.stereotype.Component;
import java.security.SecureRandom;
/**
* 聚合支付固定收银 HTML 渲染器。
*
* <p>页面不加载外部脚本或前端框架,只调用支付宝或微信客户端内置 JSBridge。</p>
*/
@Component
public class ThirdAggregateCashierHtmlRenderer {
/**
* CSP nonce 随机字节数,每次响应重新生成。
*/
private static final int NONCE_BYTES = 18;
/**
* 支付宝 JSBridge 自动调用后显示人工重试入口的等待时间,单位毫秒。
*
* <p>部分支付宝 WebView 会限制无用户手势的支付调起,等待后展示按钮可让付款人通过真实点击再次调用。</p>
*/
private static final int ALIPAY_LAUNCH_FALLBACK_MILLIS = 1500;
/**
* 支付宝 JSBridge 注入等待上限,单位毫秒;超时后页面提示付款人确认扫码客户端并允许重试。
*/
private static final int ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS = 3000;
/**
* 进程级强随机数生成器,用于每个收银页响应的 CSP nonce。
*/
private final SecureRandom secureRandom = new SecureRandom();
/**
* 渲染调用支付宝或微信 JSBridge 的固定收银页。
*
* @param pageData 平台调起参数和短期状态查询地址
* @param nonce 当前响应 CSP nonce
* @return 完整 UTF-8 HTML 文本
*/
public String renderPayPage(ThirdAggregateCashierPageData pageData, String nonce) {
String payParams = safeScriptJson(pageData.getLaunch().getPayParams());
String statusUrl = safeScriptJson(pageData.getStatusUrl());
boolean wechat = AppPlusPlatformTypeEnum.WECHAT.equals(pageData.getLaunch().getPlatform());
String invokeScript = wechat
? "function invokePay(){WeixinJSBridge.invoke('getBrandWCPayRequest',payParams,function(){startPolling();});}"
+ "if(typeof WeixinJSBridge==='undefined'){document.addEventListener('WeixinJSBridgeReady',invokePay,false);}else{invokePay();}"
: alipayInvokeScript();
return "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">"
+ "<title>订单支付</title><style nonce=\"" + nonce + "\">"
+ baseStyles() + "</style></head><body><main><div class=\"spinner\" aria-hidden=\"true\"></div>"
+ "<h1 id=\"title\">正在调起支付</h1><p id=\"message\">请在当前应用内完成付款</p>"
+ "<button id=\"retryButton\" type=\"button\" hidden>重新调起支付</button></main>"
+ "<script nonce=\"" + nonce + "\">const payParams=" + payParams + ";const statusUrl=" + statusUrl + ";"
+ "let polling=false;function setState(title,message){document.getElementById('title').textContent=title;"
+ "document.getElementById('message').textContent=message;}"
+ "function startPolling(){if(polling)return;polling=true;checkStatus();}"
+ "function checkStatus(){fetch(statusUrl,{cache:'no-store',credentials:'omit'}).then(r=>r.json()).then(body=>{"
+ "const data=body&&body.data;if(data&&data.paySuccess){setState('支付成功','本页可以关闭');return;}"
+ "if(data&&['40','50','60'].includes(data.status)){setState('支付未完成','二维码已关闭或失效');return;}"
+ "setTimeout(checkStatus,1500);}).catch(()=>setTimeout(checkStatus,2000));}"
+ invokeScript + "setTimeout(startPolling,1000);</script></body></html>";
}
/**
* 构造支付宝内置浏览器的收银台调起脚本。
*
* <p>严格按支付宝文档传递 {@code tradeNO},并处理官方结果码。自动调起被 WebView 限制、桥接尚未注入或同步抛错时,
* 页面提供真实点击重试入口;支付结果仍由服务端状态轮询最终确认,避免仅凭客户端结果认定成功。</p>
*
* @return 不包含动态业务数据的固定支付宝 JSBridge 脚本
*/
private String alipayInvokeScript() {
return "const retryButton=document.getElementById('retryButton');const spinner=document.querySelector('.spinner');"
+ "let alipayBridgeInvoked=false;let alipayResultReceived=false;"
+ "function showAlipayRetry(title,message){setState(title,message);spinner.hidden=true;retryButton.hidden=false;}"
+ "function hideAlipayRetry(){spinner.hidden=false;retryButton.hidden=true;}"
+ "function handleAlipayResult(result){alipayResultReceived=true;const resultCode=String(result&&result.resultCode||'');"
+ "if(resultCode==='9000'){hideAlipayRetry();setState('支付结果确认中','正在确认服务端支付结果');startPolling();return;}"
+ "if(resultCode==='8000'||resultCode==='6004'){hideAlipayRetry();setState('支付结果确认中','请稍候,请勿重复付款');startPolling();return;}"
+ "if(resultCode==='6001'){showAlipayRetry('支付已取消','如需继续付款,请重新调起支付宝收银台');startPolling();return;}"
+ "if(resultCode==='6002'){showAlipayRetry('网络连接异常','请检查网络后重新调起支付');startPolling();return;}"
+ "if(resultCode==='4000'){showAlipayRetry('支付未完成','请重新调起支付或重新扫码');startPolling();return;}"
+ "showAlipayRetry('支付未能调起','请重新调起支付宝收银台');startPolling();}"
+ "function invokePay(){const tradeNO=payParams&&payParams.tradeNO;"
+ "if(typeof tradeNO!=='string'||!tradeNO.trim()){showAlipayRetry('支付参数无效','请关闭页面后重新扫码');return;}"
+ "const bridge=window.AlipayJSBridge;if(!bridge||typeof bridge.call!=='function'){"
+ "showAlipayRetry('支付组件未就绪','请确认当前页面在支付宝内打开后重试');return;}"
+ "alipayBridgeInvoked=true;alipayResultReceived=false;hideAlipayRetry();setState('正在调起支付','请在支付宝内完成付款');"
+ "try{bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult);"
+ "setTimeout(function(){if(!alipayResultReceived){showAlipayRetry('等待打开支付宝收银台',"
+ "'未自动打开时,请点击下方按钮继续支付');}}," + ALIPAY_LAUNCH_FALLBACK_MILLIS + ");}"
+ "catch(error){alipayBridgeInvoked=false;showAlipayRetry('支付组件调用失败','请重新调起支付或重新扫码');}}"
+ "retryButton.addEventListener('click',invokePay);"
+ "if(window.AlipayJSBridge&&typeof window.AlipayJSBridge.call==='function'){invokePay();}else{"
+ "document.addEventListener('AlipayJSBridgeReady',invokePay,false);"
+ "setTimeout(function(){if(!alipayBridgeInvoked){showAlipayRetry('支付组件未就绪',"
+ "'请确认当前页面在支付宝内打开后重试');}}," + ALIPAY_BRIDGE_READY_TIMEOUT_MILLIS + ");}";
}
/**
* 渲染不包含脚本的固定提示页,供普通浏览器、自有 App、过期和非法请求使用。
*
* @param message 面向付款人的短提示,服务端会执行 HTML 转义
* @param nonce 当前响应 CSP nonce
* @return 完整 UTF-8 HTML 文本
*/
public String renderMessagePage(String message, String nonce) {
return "<!doctype html><html lang=\"zh-CN\"><head><meta charset=\"utf-8\">"
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no\">"
+ "<title>订单支付</title><style nonce=\"" + nonce + "\">" + baseStyles()
+ "</style></head><body><main><h1>无法继续支付</h1><p>" + escapeHtml(message) + "</p></main></body></html>";
}
/**
* 生成当前响应使用的不可预测 CSP nonce。
*
* @return URL 安全且不含填充字符的随机 nonce
*/
public String createNonce() {
byte[] bytes = new byte[NONCE_BYTES];
secureRandom.nextBytes(bytes);
return java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* 返回收银页固定内联样式,不接受任何外部输入。
*
* @return 适配移动端安全区的 CSS 文本
*/
private String baseStyles() {
return "*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#f5f7fa;color:#1f2937;"
+ "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}main{min-height:100vh;display:flex;"
+ "flex-direction:column;align-items:center;justify-content:center;padding:24px;padding-bottom:env(safe-area-inset-bottom)}"
+ "h1{margin:18px 0 8px;font-size:22px;font-weight:600;letter-spacing:0}p{margin:0;color:#6b7280;font-size:15px;"
+ "line-height:1.6;text-align:center}.spinner{width:36px;height:36px;border:3px solid #d1d5db;border-top-color:#1677ff;"
+ "border-radius:50%;animation:spin 1s linear infinite}.spinner[hidden]{display:none}button{margin-top:24px;min-width:152px;"
+ "height:44px;padding:0 20px;border:0;border-radius:6px;background:#1677ff;color:#fff;font-size:16px;font-weight:600}"
+ "button[hidden]{display:none}@keyframes spin{to{transform:rotate(360deg)}}";
}
/**
* 将 Java 对象序列化为可安全嵌入 script 文本的 JSON,阻断闭合标签和 HTML 实体注入。
*
* <p>Hutool 对顶层字符串调用 {@code toJsonStr} 不会补 JSON 引号,必须单独使用 {@code quote};否则 HTTPS
* 状态地址会被渲染为非法 JavaScript,并导致包括 JSBridge 调起在内的整段脚本无法执行。</p>
*
* @param value 仅包含平台调起参数或服务端固定 URL 的对象
* @return 已转义小于号、大于号和与号的 JSON 文本
*/
private String safeScriptJson(Object value) {
String json = value instanceof CharSequence ? JSONUtil.quote(value.toString()) : JSONUtil.toJsonStr(value);
return json.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026");
}
/**
* 对提示文本执行最小 HTML 转义,避免业务异常文本进入页面结构。
*
* @param value 原始提示文本,允许为空
* @return 可安全放入文本节点的内容
*/
private String escapeHtml(String value) {
if (value == null) {
return "支付请求无效";
}
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace("\"", "&quot;").replace("'", "&#39;");
}
}
@@ -0,0 +1,92 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
/**
* 支付宝与微信聚合订单码支付编排服务。
*/
public interface ThirdAggregateOrderPayService {
/**
* 创建或复用同业务的支付宝、微信聚合二维码。
*
* @param dto 业务订单、商户、金额和可选用户参数
* @return 聚合会话编号、HTTPS 二维码内容和失效时间
*/
ThirdAggregateOrderCodePayVo create(ThirdAggregateOrderCodePayDto dto);
/**
* 按聚合会话编号查询当前租户最小支付状态。
*
* @param aggregateNo 聚合支付会话编号
* @return 聚合状态、选定平台和第三方子订单号
*/
ThirdAggregateOrderCodePayStatusVo getStatus(String aggregateNo);
/**
* 在旧平台原生订单码创建前检查同业务是否存在阻塞中的聚合会话。
*
* @param bizType 支付业务类型
* @param bizId 调用方业务订单唯一标识
*/
void assertNoBlockingAggregateSession(ThirdPayOrderBizTypeEnum bizType, String bizId);
/**
* 校验扫码客户端和访问令牌,并创建一次性 OAuth state 后返回平台授权地址。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param token 聚合 URL 随机访问令牌
* @param userAgent 扫码客户端 User-Agent
* @return 支付宝或微信官方 OAuth 授权地址
*/
String getCashierOauthUrl(String tenantCode, String token, String userAgent);
/**
* 处理服务商统一公众号 OAuth 回调并准备 JSBridge 收银页。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param authCode 微信一次性授权码
* @param state 服务端创建的一次性 OAuth state
* @return 微信 JSBridge 调起数据和短期状态查询地址
*/
ThirdAggregateCashierPageData handleWechatOauth(String tenantCode, String authCode, String state);
/**
* 处理支付宝 OAuth 回调并准备 AlipayJSBridge 收银页。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param authCode 支付宝一次性授权码
* @param state 服务端创建的一次性 OAuth state
* @return 支付宝 JSBridge 调起数据和短期状态查询地址
*/
ThirdAggregateCashierPageData handleAlipayOauth(String tenantCode, String authCode, String state);
/**
* 使用短期 ticket 查询当前租户聚合支付状态。
*
* @param tenantCode 聚合 URL 中的租户唯一编码
* @param ticket OAuth 回调收银页取得的短期状态票据
* @return 不含敏感字段的聚合支付状态
*/
ThirdAggregateOrderCodePayStatusVo getStatusByTicket(String tenantCode, String ticket);
/**
* 查询、关闭并同步一条待处理聚合会话,供定时任务和创建互斥逻辑复用。
*
* @param sessionId 聚合支付会话主键
*/
void processPendingSession(Long sessionId);
/**
* 判断支付回调订单是否属于聚合会话;属于时同步会话终态并阻止旧业务回调链继续执行。
*
* @param payOrder 已通过平台验签和关键字段校验的最新第三方支付订单
* @return 属于聚合 JSAPI 子订单时返回 {@code true}
*/
boolean syncAggregateChildOrder(ThirdPayOrderEntity payOrder);
}
@@ -0,0 +1,92 @@
package com.bsd.pjcloud.thirdparty.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
/**
* 聚合订单码支付会话持久化服务。
*/
public interface ThirdAggregatePaySessionService extends IService<ThirdAggregatePaySessionEntity> {
/**
* 按聚合会话编号读取当前租户会话。
*
* @param aggregateNo 聚合支付会话编号
* @return 匹配会话;不存在时返回 {@code null}
*/
ThirdAggregatePaySessionEntity getByAggregateNo(String aggregateNo);
/**
* 按访问令牌摘要读取当前租户会话。
*
* @param accessTokenHash 原始随机令牌的 SHA-256 摘要
* @return 匹配会话;不存在时返回 {@code null}
*/
ThirdAggregatePaySessionEntity getByAccessTokenHash(String accessTokenHash);
/**
* 按第三方子订单号读取当前租户聚合会话。
*
* @param payNo {@code third_pay_order.pay_no}
* @return 绑定会话;该订单不是聚合 JSAPI 子订单时返回 {@code null}
*/
ThirdAggregatePaySessionEntity getByPayNo(String payNo);
/**
* 查询当前租户同业务下最新创建的聚合会话。
*
* @param bizType 支付业务类型
* @param bizId 调用方业务订单唯一标识
* @return 最新会话;不存在时返回 {@code null}
*/
ThirdAggregatePaySessionEntity getLatestByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId);
/**
* 首个 OAuth 回调原子锁定支付平台、付款人和预分配的第三方子订单号。
*
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param payerIdentityHash 付款人身份摘要
* @return 仅从等待扫码状态成功推进一次时返回 {@code true}
*/
boolean selectPlatform(Long sessionId, AppPlusPlatformTypeEnum platform, String payNo, String payerIdentityHash);
/**
* 将已创建第三方子订单的会话原子推进为支付中。
*
* @param sessionId 聚合会话主键
* @param payNo 已成功保存的第三方子订单号
* @return 仅从平台创建中状态推进成功时返回 {@code true}
*/
boolean markPaying(Long sessionId, String payNo);
/**
* 将平台创建中的会话标记为创建失败。
*
* @param sessionId 聚合会话主键
* @param payNo 创建失败但已预分配的第三方子订单号
* @return 状态首次推进成功时返回 {@code true}
*/
boolean markCreateFailed(Long sessionId, String payNo);
/**
* 在会话尚未进入终态时原子写入指定终态。
*
* @param sessionId 聚合会话主键
* @param status 允许写入的成功、关闭或过期状态
* @return 状态首次推进成功时返回 {@code true}
*/
boolean markTerminal(Long sessionId, ThirdAggregatePaySessionStatusEnum status);
/**
* 将第三方支付子订单终态幂等同步到绑定的聚合会话。
*
* @param payOrder 最新第三方支付子订单
*/
void syncFromPayOrder(ThirdPayOrderEntity payOrder);
}
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
/**
* 微信服务商聚合订单码 OAuth 与 JSAPI 支付能力。
*/
public interface ThirdAggregateWechatPayService {
/**
* 使用服务商统一公众号网页授权码换取 sp_openid。
*
* @param authCode 微信公众号 OAuth 回调授权码
* @return 当前服务商公众号下的付款人 sp_openid
*/
String exchangeSpOpenId(String authCode);
/**
* 创建微信支付服务商 JSAPI 子订单并保存内部支付订单。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的微信子商户号配置
* @param spOpenId 当前服务商统一公众号下的付款人 openid
* @return 微信 JSBridge 调起签名参数
*/
ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting, String spOpenId);
/**
* 查询聚合微信 JSAPI 子订单状态。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的微信子商户配置快照
* @return 微信订单状态;网络或 SDK 异常时返回 {@code null}
*/
Transaction query(String payNo, String settingJson);
/**
* 关闭到期但尚未支付的聚合微信 JSAPI 子订单。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的微信子商户配置快照
* @return SDK 无异常完成关单时返回 {@code true}
*/
boolean close(String payNo, String settingJson);
}
@@ -2,14 +2,20 @@ package com.bsd.pjcloud.thirdparty.service;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 支付宝支付能力,封装应用支付、订单码预创建、查询、撤销及回调验真。
*/
public interface ThirdAppPlusAliPayService {
/**
@@ -17,6 +23,25 @@ public interface ThirdAppPlusAliPayService {
*/
AlipayTradeQueryResponse query(String payNo, String settingJson);
/**
* 调用支付宝当面付预创建接口并保存第三方支付订单。
*
* @param dto 已生成支付订单号的内部支付参数
* @param paySetting 当前商户启用的支付宝配置
* @param expireTime 服务端二维码失效时间
* @return 支付订单号、支付宝二维码内容串和服务端过期时间
*/
ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo paySetting, LocalDateTime expireTime);
/**
* 撤销尚未完成的支付宝交易。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户支付配置快照
* @return 支付宝撤销响应;网络或 SDK 异常时返回 {@code null},由上层稍后重试
*/
AlipayTradeCancelResponse cancel(String payNo, String settingJson);
/**
* app支付
*/
@@ -30,7 +55,7 @@ public interface ThirdAppPlusAliPayService {
/**
* 支付回调
*/
ThirdPayOrderEntity payCallback(Map<String, String> params);
AliPayCallbackResult payCallback(Map<String, String> params);
/**
* 退款回调
@@ -41,4 +66,65 @@ public interface ThirdAppPlusAliPayService {
* 支付消息订阅
*/
ThirdALiPayCallbackVo miniSubscribeCallback(Map<String, String> params, String body, Map<String, String> headers);
/**
* 支付宝回调验真及原子状态更新结果。
*/
final class AliPayCallbackResult {
/**
* 验真通过后重新读取的支付订单;始终属于回调中的商户订单号。
*/
private final ThirdPayOrderEntity payOrder;
/**
* 当前订单是否已经确认支付成功;交易关闭时为 {@code false}。
*/
private final boolean paySuccess;
/**
* 本次回调是否把未支付状态原子推进到终态,用于阻止重复业务处理。
*/
private final boolean stateChanged;
/**
* 创建支付宝回调处理结果。
*
* @param payOrder 回调对应的最新支付订单
* @param paySuccess 是否已经确认支付成功
* @param stateChanged 本次回调是否完成状态推进
*/
public AliPayCallbackResult(ThirdPayOrderEntity payOrder, boolean paySuccess, boolean stateChanged) {
this.payOrder = payOrder;
this.paySuccess = paySuccess;
this.stateChanged = stateChanged;
}
/**
* 获取回调对应的最新支付订单。
*
* @return 最新支付订单
*/
public ThirdPayOrderEntity getPayOrder() {
return payOrder;
}
/**
* 判断当前订单是否支付成功。
*
* @return 支付成功时返回 {@code true}
*/
public boolean isPaySuccess() {
return paySuccess;
}
/**
* 判断本次回调是否实际推进了内部状态。
*
* @return 本次从未支付推进到终态时返回 {@code true}
*/
public boolean isStateChanged() {
return stateChanged;
}
}
}
@@ -5,11 +5,18 @@ import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDt
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.vo.appplus.ThirdMinAppOAth2LoginVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
import jakarta.validation.Valid;
import java.util.Map;
/**
* App、小程序及消费机第三方支付业务编排接口。
*/
public interface ThirdAppPlusService {
/**
@@ -37,6 +44,32 @@ public interface ThirdAppPlusService {
*/
String micropay(ThirdMicropayDto dto);
/**
* 创建支付宝或微信订单码支付订单;仅支持云消费机业务。
*/
ThirdOrderCodePayVo orderCodePay(@Valid ThirdOrderCodePayDto dto);
/**
* 查询订单码支付状态,不返回支付配置或第三方平台原始报文。
*/
ThirdOrderCodePayStatusVo getOrderCodePayStatus(String payNo);
/**
* 创建支付宝与微信共用的聚合订单二维码;实际平台由付款人扫码客户端决定。
*
* @param dto 业务订单、收款商户、金额和可选用户参数
* @return 聚合会话编号、HTTPS 二维码内容和失效时间
*/
ThirdAggregateOrderCodePayVo aggregateOrderCodePay(@Valid ThirdAggregateOrderCodePayDto dto);
/**
* 查询聚合订单码支付状态,不返回访问令牌、付款人标识或第三方原始报文。
*
* @param aggregateNo 聚合支付会话编号
* @return 聚合状态、选定平台和实际第三方子订单号
*/
ThirdAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(String aggregateNo);
/**
* 退款
*/
@@ -0,0 +1,44 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction;
import java.time.LocalDateTime;
/**
* 微信支付服务商模式 Native 订单码服务。
*
* <p>该服务只负责微信支付侧的下单、查单和关单,不承担跨平台幂等及业务状态回调。</p>
*/
public interface ThirdAppPlusWechatNativePayService {
/**
* 创建微信服务商 Native 支付订单并持久化内部支付订单。
*
* @param dto 已生成商户订单号的内部支付参数,金额单位为人民币分
* @param paySetting 当前子商户启用的微信支付配置
* @param expireTime 服务端订单码过期时间,同时作为微信支付截止时间
* @return 支付订单号、微信 {@code code_url} 内容串和服务端过期时间
*/
ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo paySetting, LocalDateTime expireTime);
/**
* 按商户订单号查询微信服务商 Native 支付状态。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的子商户支付配置快照
* @return 微信订单状态;网络、配置或 SDK 异常时返回 {@code null} 供轮询重试
*/
Transaction query(String payNo, String settingJson);
/**
* 关闭仍未支付的微信服务商 Native 订单。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的子商户支付配置快照
* @return 微信确认关单成功时返回 {@code true};异常时返回 {@code false} 供轮询重试
*/
boolean close(String payNo, String settingJson);
}
@@ -50,12 +50,20 @@ public interface ThirdAppPlusWechatService {
ThirdMiniWechatRefundQueryVo refundQuery(String payNo);
/**
* 支付的回调通知
* 验签、解密并处理微信支付成功回调,按未支付条件原子推进本地订单。
*
* @param body 微信支付回调原始请求体
* @param headers 微信支付签名请求头
* @return 已支付的内部订单;验签、字段校验或状态推进失败时返回 {@code null}
*/
ThirdPayOrderEntity payCallback(String body, @RequestHeader Map<String, String> headers);
/**
* 解析支付的回调
* 对微信支付回调执行签名验证与 AES-GCM 解密。
*
* @param body 微信支付回调原始请求体
* @param headers 微信支付签名请求头
* @return 解密后的交易信息;验签或解密失败时返回 {@code null}
*/
Transaction pasePayCallback(String body, Map<String, String> headers);
@@ -0,0 +1,327 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 支付宝聚合订单码 OAuth 与 JSAPI 支付实现。
*/
@Service
@Validated
@Slf4j
@RequiredArgsConstructor
public class ThirdAggregateAliPayServiceImpl implements ThirdAggregateAliPayService {
/**
* 支付宝聚合支付日志标签,不包含授权码、用户标识或商户授权令牌。
*/
private static final String TAG = "支付宝聚合JSAPI支付";
/**
* 支付宝 JSAPI 支付固定产品码。
*/
private static final String PRODUCT_CODE = "JSAPI_PAY";
/**
* 支付宝账单固定安全标题,避免传递内部订单信息。
*/
private static final String SUBJECT = "资安信消费支付";
/**
* 支付宝 OAuth 授权码交换固定 grant_type。
*/
private static final String OAUTH_GRANT_TYPE = "authorization_code";
/**
* 支付宝服务商模式商户授权令牌公共参数名。
*/
private static final String APP_AUTH_TOKEN_PARAM = "app_auth_token";
/**
* 支付宝应用与回调地址配置,来源于现有配置中心。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 第三方支付订单服务,用于保存 JSAPI 子订单及精简平台快照。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 现有支付宝支付服务,复用已验证的服务商查单和撤销能力。
*/
private final ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/**
* 当前应用配置对应的支付宝客户端,延迟初始化并在请求间复用。
*/
private volatile AlipayClient alipayClient;
/**
* 使用一次性 OAuth 授权码换取支付宝付款人身份,不保存授权令牌。
*
* @param authCode 支付宝 OAuth 回调授权码
* @return 包含 open_id 或兼容 user_id 的付款人身份
*/
@Override
public ThirdAggregateAlipayOauthIdentity exchangeOauthCode(String authCode) {
if (StrUtil.isBlank(authCode)) {
throw new RuntimeException("支付宝授权码不能为空");
}
AlipaySystemOauthTokenRequest request = new AlipaySystemOauthTokenRequest();
request.setGrantType(OAUTH_GRANT_TYPE);
request.setCode(authCode);
try {
AlipaySystemOauthTokenResponse response = getAlipayClient().execute(request);
if (response == null || !response.isSuccess()) {
String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN");
log.warn("|{}|支付宝用户授权失败,errorCode:{}", TAG, errorCode);
throw new RuntimeException("支付宝用户授权失败,请重新扫码");
}
ThirdAggregateAlipayOauthIdentity identity = new ThirdAggregateAlipayOauthIdentity(response.getOpenId(), response.getUserId());
if (StrUtil.isBlank(identity.getStableIdentity())) {
throw new RuntimeException("支付宝用户授权未返回付款人标识");
}
return identity;
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
log.warn("|{}|支付宝用户授权异常,exceptionType:{}", TAG, exception.getClass().getSimpleName());
throw new RuntimeException("支付宝用户授权失败,请重新扫码");
}
}
/**
* 创建支付宝服务商 JSAPI 子订单,并仅返回 AlipayJSBridge 所需 tradeNO。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的支付宝授权配置
* @param identity 已通过 OAuth 获取的支付宝付款人身份
* @return 支付宝 JSBridge 调起参数
*/
@Override
public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
ThirdAggregateAlipayOauthIdentity identity) {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(paySetting);
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
AlipayTradeCreateModel model = new AlipayTradeCreateModel();
model.setOutTradeNo(context.getPayNo());
model.setTotalAmount(fenToYuan(context.getAmount()));
model.setSubject(SUBJECT);
model.setProductCode(PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(context.getExpireTime(), DatePattern.NORM_DATETIME_PATTERN));
if (StrUtil.isNotBlank(identity.getOpenId())) {
model.setBuyerOpenId(identity.getOpenId());
} else {
model.setBuyerId(identity.getUserId());
}
AlipayTradeCreateRequest request = new AlipayTradeCreateRequest();
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
request.setBizModel(model);
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
payOrder.setPayRep(buildRequestSnapshot(context).toString());
AlipayTradeCreateResponse response;
try {
response = getAlipayClient().execute(request);
} catch (Exception exception) {
saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName());
log.warn("|{}|支付宝JSAPI子订单创建异常,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName());
throw new RuntimeException("创建支付宝支付订单失败,请重新扫码");
}
if (response == null || !response.isSuccess() || StrUtil.isBlank(response.getTradeNo())) {
String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "EMPTY_TRADE_NO");
saveFailedOrder(payOrder, errorCode, null);
log.warn("|{}|支付宝JSAPI子订单创建失败,payNo:{},errorCode:{}", TAG, context.getPayNo(), errorCode);
throw new RuntimeException("创建支付宝支付订单失败,请重新扫码");
}
Map<String, Object> payParams = new LinkedHashMap<>();
payParams.put("tradeNO", response.getTradeNo());
payOrder.setPayRes(JSONUtil.createObj()
.set("aggregatePay", true)
.set("tradeNo", response.getTradeNo())
.set("launchParams", payParams).toString());
try {
if (!thirdPayOrderService.save(payOrder)) {
throw new RuntimeException("聚合支付子订单保存失败");
}
} catch (RuntimeException exception) {
thirdAppPlusAliPayService.cancel(context.getPayNo(), JSONUtil.toJsonStr(paySetting));
log.error("|{}|支付宝JSAPI子订单保存失败,已执行最佳努力撤销,payNo:{}", TAG, context.getPayNo());
throw new RuntimeException("创建支付宝支付订单失败,请重新扫码");
}
return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY, context.getPayNo(), payParams);
}
/**
* 复用现有支付宝服务商查单实现。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝查询响应;异常时由现有服务返回 {@code null}
*/
@Override
public AlipayTradeQueryResponse query(String payNo, String settingJson) {
return thirdAppPlusAliPayService.query(payNo, settingJson);
}
/**
* 复用现有支付宝服务商撤销实现。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝撤销响应;异常时由现有服务返回 {@code null}
*/
@Override
public AlipayTradeCancelResponse cancel(String payNo, String settingJson) {
return thirdAppPlusAliPayService.cancel(payNo, settingJson);
}
/**
* 获取延迟初始化的支付宝客户端。
*
* @return 使用现有应用密钥和支付宝公钥初始化的客户端
*/
protected synchronized AlipayClient getAlipayClient() {
if (alipayClient == null) {
try {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
AlipayConfig sdkConfig = new AlipayConfig();
sdkConfig.setServerUrl("https://openapi.alipay.com/gateway.do");
sdkConfig.setAppId(config.getAppId());
sdkConfig.setPrivateKey(config.getAppPrivateKey());
sdkConfig.setFormat("json");
sdkConfig.setAlipayPublicKey(config.getAlipayPublicKey());
sdkConfig.setCharset("UTF-8");
sdkConfig.setSignType("RSA2");
alipayClient = new DefaultAlipayClient(sdkConfig);
} catch (Exception exception) {
throw new RuntimeException("支付宝客户端初始化失败");
}
}
return alipayClient;
}
/**
* 构建不含付款人标识和商户授权令牌的请求快照。
*
* @param context 聚合支付子订单上下文
* @return 可安全持久化的精简请求信息
*/
private JSONObject buildRequestSnapshot(ThirdAggregatePayCreateContext context) {
return JSONUtil.createObj()
.set("aggregatePay", true)
.set("productCode", PRODUCT_CODE)
.set("outTradeNo", context.getPayNo())
.set("amount", context.getAmount())
.set("expireTime", context.getExpireTime());
}
/**
* 构建待支付的支付宝聚合子订单。
*
* @param context 聚合支付子订单上下文
* @param paySetting 商户支付宝配置快照
* @return 可持久化的未支付订单
*/
private ThirdPayOrderEntity buildPayOrder(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) {
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
payOrder.setBizType(context.getBizType().getType());
payOrder.setBizId(context.getBizId());
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting));
return payOrder;
}
/**
* 保存支付宝子订单创建失败的精简状态,不记录 SDK 原始响应或敏感配置。
*
* @param payOrder 待保存的第三方支付订单
* @param errorCode 支付宝错误码或本地错误标识
* @param exceptionType 异常类型;非异常失败时为空
*/
private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) {
JSONObject snapshot = JSONUtil.createObj().set("aggregatePay", true).set("code", errorCode);
if (StrUtil.isNotBlank(exceptionType)) {
snapshot.set("exceptionType", exceptionType);
}
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
payOrder.setPayRes(snapshot.toString());
thirdPayOrderService.save(payOrder);
}
/**
* 校验商户支付宝授权配置并解析 app_auth_token。
*
* @param paySetting 当前商户启用的支付宝支付配置
* @return 包含非空商户授权令牌的配置
*/
private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo paySetting) {
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (setting == null || StrUtil.isBlank(setting.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
}
return setting;
}
/**
* 将整数人民币分精确转换为两位小数人民币元字符串。
*
* @param amount 金额,单位为人民币分
* @return 两位小数人民币元字符串
*/
private String fenToYuan(Integer amount) {
if (ObjectUtil.isEmpty(amount)) {
return "0.00";
}
return BigDecimal.valueOf(amount).movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY).toPlainString();
}
}
@@ -0,0 +1,369 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.core.exception.ServiceException;
import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Amount;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Payer;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 微信支付服务商聚合订单码 OAuth 与 JSAPI 支付实现。
*/
@Service
@Validated
@Slf4j
@RequiredArgsConstructor
public class ThirdAggregateWechatPayServiceImpl implements ThirdAggregateWechatPayService {
/**
* 微信聚合支付日志标签,不包含公众号 Secret、OAuth code 或 sp_openid。
*/
private static final String TAG = "微信服务商聚合JSAPI支付";
/**
* 微信公众号网页授权码换取 access_token 的官方接口地址。
*/
private static final String OAUTH_TOKEN_URL = "https://api.weixin.qq.com/sns/oauth2/access_token";
/**
* 微信网页授权固定 grant_type。
*/
private static final String OAUTH_GRANT_TYPE = "authorization_code";
/**
* 微信 OAuth HTTP 请求连接与读取总超时,单位毫秒。
*/
private static final int OAUTH_TIMEOUT_MILLIS = 5000;
/**
* 微信账单固定商品描述,避免传递内部订单信息。
*/
private static final String DESCRIPTION = "资安信消费支付";
/**
* 微信支付人民币币种编码。
*/
private static final String CURRENCY_CNY = "CNY";
/**
* 微信服务商 JSAPI SDK 服务,复用现有服务商证书配置。
*/
private final JsapiServiceExtension aggregateWechatJsapiServiceExtension;
/**
* 微信服务商应用和回调地址配置,来源于现有配置中心。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 第三方支付订单服务,用于保存 JSAPI 子订单和精简平台响应。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 调用服务商统一公众号 OAuth 接口换取 sp_openid。
*
* @param authCode 微信公众号 OAuth 回调授权码
* @return 当前服务商公众号下的付款人 sp_openid
*/
@Override
public String exchangeSpOpenId(String authCode) {
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
if (StrUtil.isBlank(authCode) || wechatConfig == null
|| StrUtil.hasBlank(wechatConfig.getGzhAppId(), wechatConfig.getGzhAppSecret())) {
throw new RuntimeException("微信用户授权参数不完整");
}
Map<String, Object> form = new LinkedHashMap<>();
form.put("appid", wechatConfig.getGzhAppId());
form.put("secret", wechatConfig.getGzhAppSecret());
form.put("code", authCode);
form.put("grant_type", OAUTH_GRANT_TYPE);
try (HttpResponse response = HttpRequest.get(OAUTH_TOKEN_URL)
.timeout(OAUTH_TIMEOUT_MILLIS)
.form(form)
.execute()) {
if (!response.isOk()) {
log.warn("|{}|微信用户授权HTTP失败,httpStatus:{}", TAG, response.getStatus());
throw new RuntimeException("微信用户授权失败,请重新扫码");
}
JSONObject body = JSONUtil.parseObj(response.body());
String spOpenId = body.getStr("openid");
if (StrUtil.isBlank(spOpenId)) {
log.warn("|{}|微信用户授权业务失败,errorCode:{}", TAG, body.getStr("errcode"));
throw new RuntimeException("微信用户授权失败,请重新扫码");
}
return spOpenId;
} catch (RuntimeException exception) {
throw exception;
} catch (Exception exception) {
log.warn("|{}|微信用户授权异常,exceptionType:{}", TAG, exception.getClass().getSimpleName());
throw new RuntimeException("微信用户授权失败,请重新扫码");
}
}
/**
* 创建服务商 JSAPI 子订单,使用统一服务商公众号 sp_appid 与 sp_openid 标识付款人。
*
* @param context 已绑定聚合会话的支付上下文
* @param paySetting 当前商户启用的微信子商户号配置
* @param spOpenId 当前服务商统一公众号下的付款人 openid
* @return 微信 JSBridge 调起签名参数
*/
@Override
public ThirdAggregateCashierLaunch createPay(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting,
String spOpenId) {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySetting(paySetting);
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
ThirdPayOrderEntity payOrder = buildPayOrder(context, paySetting);
Amount amount = new Amount();
amount.setTotal(context.getAmount());
amount.setCurrency(CURRENCY_CNY);
Payer payer = new Payer();
payer.setSpOpenid(spOpenId);
PrepayRequest request = new PrepayRequest();
request.setSpAppid(wechatConfig.getGzhAppId());
request.setSpMchid(wechatConfig.getMerchantId());
request.setSubMchid(wechatPaySetting.getSubMchid());
request.setDescription(DESCRIPTION);
request.setOutTradeNo(context.getPayNo());
request.setTimeExpire(formatWechatTime(context.getExpireTime()));
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
request.setPayer(payer);
payOrder.setPayRep(buildRequestSnapshot(context, wechatPaySetting).toString());
PrepayWithRequestPaymentResponse response;
try {
response = aggregateWechatJsapiServiceExtension.prepayWithRequestPayment(request, wechatConfig.getGzhAppId());
} catch (ServiceException exception) {
String errorCode = StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN");
saveFailedOrder(payOrder, errorCode, exception.getClass().getSimpleName());
log.warn("|{}|微信JSAPI子订单创建失败,payNo:{},errorCode:{}", TAG, context.getPayNo(), errorCode);
throw new RuntimeException("创建微信支付订单失败,请重新扫码");
} catch (Exception exception) {
saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName());
log.warn("|{}|微信JSAPI子订单创建异常,payNo:{},exceptionType:{}", TAG, context.getPayNo(), exception.getClass().getSimpleName());
throw new RuntimeException("创建微信支付订单失败,请重新扫码");
}
if (!isCompleteResponse(response)) {
saveFailedOrder(payOrder, "INCOMPLETE_RESPONSE", null);
throw new RuntimeException("创建微信支付订单失败,请重新扫码");
}
Map<String, Object> payParams = new LinkedHashMap<>();
payParams.put("appId", response.getAppId());
payParams.put("timeStamp", response.getTimeStamp());
payParams.put("nonceStr", response.getNonceStr());
payParams.put("package", response.getPackageVal());
payParams.put("signType", response.getSignType());
payParams.put("paySign", response.getPaySign());
payOrder.setPayRes(JSONUtil.createObj().set("aggregatePay", true).set("launchParams", payParams).toString());
try {
if (!thirdPayOrderService.save(payOrder)) {
throw new RuntimeException("聚合支付子订单保存失败");
}
} catch (RuntimeException exception) {
close(context.getPayNo(), JSONUtil.toJsonStr(paySetting));
log.error("|{}|微信JSAPI子订单保存失败,已执行最佳努力关单,payNo:{}", TAG, context.getPayNo());
throw new RuntimeException("创建微信支付订单失败,请重新扫码");
}
return new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.WECHAT, context.getPayNo(), payParams);
}
/**
* 使用订单配置快照中的子商户号查询微信服务商 JSAPI 子订单。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的微信子商户配置快照
* @return 微信订单状态;调用异常时返回 {@code null}
*/
@Override
public Transaction query(String payNo, String settingJson) {
try {
MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson);
QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest();
request.setOutTradeNo(payNo);
request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId());
request.setSubMchid(setting.getSubMchid());
return aggregateWechatJsapiServiceExtension.queryOrderByOutTradeNo(request);
} catch (ServiceException exception) {
log.warn("|{}|查询微信JSAPI子订单失败,payNo:{},errorCode:{}", TAG, payNo,
StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"));
} catch (Exception exception) {
log.warn("|{}|查询微信JSAPI子订单异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
}
return null;
}
/**
* 使用订单配置快照中的子商户号关闭微信服务商 JSAPI 子订单。
*
* @param payNo 第三方商户订单号
* @param settingJson 创建订单时保存的微信子商户配置快照
* @return SDK 无异常完成关单时返回 {@code true}
*/
@Override
public boolean close(String payNo, String settingJson) {
try {
MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson);
CloseOrderRequest request = new CloseOrderRequest();
request.setOutTradeNo(payNo);
request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId());
request.setSubMchid(setting.getSubMchid());
aggregateWechatJsapiServiceExtension.closeOrder(request);
return true;
} catch (ServiceException exception) {
log.warn("|{}|关闭微信JSAPI子订单失败,payNo:{},errorCode:{}", TAG, payNo,
StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"));
} catch (Exception exception) {
log.warn("|{}|关闭微信JSAPI子订单异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
}
return false;
}
/**
* 构建不含 sp_openid 的微信 JSAPI 请求快照。
*
* @param context 聚合支付子订单上下文
* @param paySetting 微信支付子商户号配置
* @return 可安全持久化的精简请求信息
*/
private JSONObject buildRequestSnapshot(ThirdAggregatePayCreateContext context,
MchPaySettingEntity.WechatPaySetting paySetting) {
return JSONUtil.createObj()
.set("aggregatePay", true)
.set("tradeType", "JSAPI")
.set("outTradeNo", context.getPayNo())
.set("spAppid", appPlusPayConfig.getWechatConfig().getGzhAppId())
.set("subMchid", paySetting.getSubMchid())
.set("amount", context.getAmount())
.set("expireTime", context.getExpireTime());
}
/**
* 构建待支付的微信聚合 JSAPI 子订单。
*
* @param context 聚合支付子订单上下文
* @param paySetting 商户微信配置快照
* @return 可持久化的未支付订单
*/
private ThirdPayOrderEntity buildPayOrder(ThirdAggregatePayCreateContext context, MchPaySettingVo paySetting) {
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
payOrder.setBizType(context.getBizType().getType());
payOrder.setBizId(context.getBizId());
payOrder.setUserId(context.getUserId());
payOrder.setMchId(context.getMchId());
payOrder.setPrice(context.getAmount());
payOrder.setPayNo(context.getPayNo());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setExpireTime(context.getExpireTime());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(paySetting));
return payOrder;
}
/**
* 保存微信 JSAPI 子订单创建失败的精简状态。
*
* @param payOrder 待保存的第三方支付订单
* @param errorCode 微信错误码或本地错误标识
* @param exceptionType 异常类型;非异常失败时为空
*/
private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) {
JSONObject snapshot = JSONUtil.createObj().set("aggregatePay", true).set("code", errorCode);
if (StrUtil.isNotBlank(exceptionType)) {
snapshot.set("exceptionType", exceptionType);
}
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
payOrder.setPayRes(snapshot.toString());
thirdPayOrderService.save(payOrder);
}
/**
* 校验并解析当前商户微信子商户号配置。
*
* @param paySetting 当前商户启用的微信支付配置
* @return 包含非空 sub_mchid 的配置
*/
private MchPaySettingEntity.WechatPaySetting parseWechatPaySetting(MchPaySettingVo paySetting) {
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户微信支付配置不完整");
}
MchPaySettingEntity.WechatPaySetting setting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (setting == null || StrUtil.isBlank(setting.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
return setting;
}
/**
* 从第三方支付订单的商户配置快照解析微信子商户号。
*
* @param settingJson 创建订单时保存的商户配置快照
* @return 包含非空 sub_mchid 的微信配置
*/
private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) {
if (StrUtil.isBlank(settingJson)) {
throw new RuntimeException("支付订单缺少商户配置快照");
}
MchPaySettingEntity snapshot = JSONUtil.toBean(settingJson, MchPaySettingEntity.class);
if (snapshot == null || StrUtil.isBlank(snapshot.getSettingJson())) {
throw new RuntimeException("支付订单商户配置快照无效");
}
MchPaySettingVo setting = new MchPaySettingVo();
setting.setSettingJson(snapshot.getSettingJson());
return parseWechatPaySetting(setting);
}
/**
* 判断微信 SDK 返回的 JSBridge 签名参数是否完整。
*
* @param response 微信服务商 JSAPI 预支付响应
* @return 所有必需字段非空时返回 {@code true}
*/
private boolean isCompleteResponse(PrepayWithRequestPaymentResponse response) {
return response != null && !StrUtil.hasBlank(response.getAppId(), response.getTimeStamp(), response.getNonceStr(),
response.getPackageVal(), response.getSignType(), response.getPaySign());
}
/**
* 将服务端本地时间转换为微信支付要求的 RFC3339 带时区时间。
*
* @param expireTime 服务端支付截止时间
* @return 精确到秒的 RFC3339 字符串
*/
private String formatWechatTime(LocalDateTime expireTime) {
return expireTime.withNano(0).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}
}
@@ -4,35 +4,47 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayApiException;
import com.alipay.api.AlipayClient;
import com.alipay.api.AlipayConfig;
import com.alipay.api.DefaultAlipayClient;
import com.alipay.api.domain.AlipayTradeAppPayModel;
import com.alipay.api.domain.AlipayTradeCancelModel;
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.domain.AlipayTradeQueryModel;
import com.alipay.api.domain.AlipayTradeRefundModel;
import com.alipay.api.domain.ExtendParams;
import com.alipay.api.internal.util.AlipaySignature;
import com.alipay.api.request.AlipayTradeAppPayRequest;
import com.alipay.api.request.AlipayTradeCancelRequest;
import com.alipay.api.request.AlipayTradePrecreateRequest;
import com.alipay.api.request.AlipayTradeQueryRequest;
import com.alipay.api.request.AlipayTradeRefundRequest;
import com.alipay.api.response.AlipayTradeAppPayResponse;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradePrecreateResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.alipay.api.response.AlipayTradeRefundResponse;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdMiniWechatRefundDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
@@ -51,14 +63,54 @@ import java.util.Map;
@RequiredArgsConstructor
public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService {
private final String TAG = "支付宝支付(app版本)";
/**
* 支付宝支付日志标签,仅用于标识模块,不包含商户和授权信息。
*/
private static final String TAG = "支付宝支付(app版本)";
static private AlipayClient alipayClient = null;
/**
* 支付宝订单码支付固定产品码,当面付预创建接口要求使用该值。
*/
private static final String ORDER_CODE_PRODUCT_CODE = "QR_CODE_OFFLINE";
/**
* 订单码交易标题,固定安全文案避免把业务隐私传给第三方平台。
*/
private static final String ORDER_CODE_SUBJECT = "资安信消费支付";
/**
* 支付宝服务商模式传递商户授权令牌的公共参数名。
*/
private static final String APP_AUTH_TOKEN_PARAM = "app_auth_token";
/**
* 当前应用配置对应的支付宝客户端;延迟初始化后由所有支付请求复用。
*/
private volatile AlipayClient alipayClient;
/**
* 支付宝应用级配置,来源于现有 app-plus.pay 配置中心。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 第三方支付订单服务,用于保存请求快照并执行带状态条件的原子更新。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 聚合会话服务只用于共享回调确认 JSAPI 子订单具有严格的 pay_no 会话绑定。
*
* <p>设为可选注入以保持现有测试子类构造器兼容;生产 Spring 容器会提供该 Bean。</p>
*/
@Autowired(required = false)
private ThirdAggregatePaySessionService aggregatePaySessionService;
/**
* 构建支付宝 SDK 客户端配置,不输出或复制任何密钥到日志。
*
* @return 当前应用的支付宝 SDK 配置
*/
private AlipayConfig getAlipayConfig() {
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
AlipayConfig alipayConfig = new AlipayConfig();
@@ -72,47 +124,144 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return alipayConfig;
}
private synchronized AlipayClient getAlipayClient() {
/**
* 获取延迟初始化的支付宝客户端。
*
* <p>方法保留为 protected,便于单元测试替换为 Mock 客户端;生产环境只会创建一次。</p>
*
* @return 可用的支付宝客户端
*/
protected synchronized AlipayClient getAlipayClient() {
if (alipayClient == null) {
try {
alipayClient = new DefaultAlipayClient(getAlipayConfig());
} catch (Exception e) {
e.printStackTrace();
log.error("初始化支付宝客户端失败error:{}", e.getMessage());
log.error("初始化支付宝客户端失败", e);
throw new RuntimeException("支付宝客户端初始化失败");
}
}
return alipayClient;
}
/**
* 将人民币分精确转换为保留两位小数的人民币元字符串。
*
* @param fen 金额,单位为人民币分
* @return 两位小数的人民币元字符串
*/
private String fenToYuan(BigDecimal fen) {
if (ObjectUtil.isEmpty(fen)) {
return "0";
return "0.00";
}
return fen.divide(new BigDecimal(100), 2, RoundingMode.HALF_UP).toString();
return fen.movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY).toPlainString();
}
/**
* 查询订单
* 按商户订单号查询支付宝交易状态。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户配置快照
* @return 支付宝查询响应;解析、网络或 SDK 异常时返回 {@code null}
*/
@Override
public AlipayTradeQueryResponse query(String payNo, String settingJson) {
MchPaySettingEntity mchPaySettingEntity = JSONUtil.toBean(settingJson, MchPaySettingEntity.class);
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(mchPaySettingEntity.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
AlipayTradeQueryModel model = new AlipayTradeQueryModel();
model.setOutTradeNo(payNo);
request.setBizModel(model);
request.putOtherTextParam("app_auth_token", aliPaySetting.getAppAuthToken());
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(settingJson);
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
AlipayTradeQueryModel model = new AlipayTradeQueryModel();
model.setOutTradeNo(payNo);
request.setBizModel(model);
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
return getAlipayClient().execute(request);
} catch (Exception e) {
e.printStackTrace();
log.error("|{}|查询订单失败error:{}", TAG, e.getMessage());
log.error("|{}|查询支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, e.getClass().getSimpleName());
}
return null;
}
/**
* 调用支付宝当面付预创建接口并保存订单及不含授权令牌的请求、响应快照。
*
* @param dto 已生成支付订单号的内部支付参数
* @param setting 当前商户启用的支付宝支付配置
* @param expireTime 服务端二维码失效时间
* @return 支付订单号、二维码内容串和服务端过期时间
*/
@Override
public ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
AppPlusPayConfig.AliPayConfig aliPayConfig = appPlusPayConfig.getAliPayConfig();
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySetting(setting);
ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime);
AlipayTradePrecreateRequest request = new AlipayTradePrecreateRequest();
request.setNotifyUrl(aliPayConfig.getBaseCallbackUrl() + aliPayConfig.getPayCallbackUrl());
AlipayTradePrecreateModel model = new AlipayTradePrecreateModel();
model.setOutTradeNo(dto.getOutTradeNo());
model.setTotalAmount(fenToYuan(BigDecimal.valueOf(dto.getAmount())));
model.setSubject(ORDER_CODE_SUBJECT);
model.setProductCode(ORDER_CODE_PRODUCT_CODE);
model.setTimeExpire(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN));
payOrder.setPayRep(JSONUtil.toJsonStr(model));
request.setBizModel(model);
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
AlipayTradePrecreateResponse response;
try {
response = getAlipayClient().execute(request);
} catch (Exception exception) {
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
payOrder.setPayRes(buildExceptionSnapshot(exception).toString());
thirdPayOrderService.save(payOrder);
log.error("|{}|支付宝订单码预创建异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), exception.getClass().getSimpleName());
throw new RuntimeException("创建支付宝订单码失败,请稍后重试");
}
payOrder.setPayRes(buildPrecreateResponseSnapshot(response).toString());
if (response == null || !response.isSuccess()) {
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
thirdPayOrderService.save(payOrder);
String errorCode = response == null ? "EMPTY_RESPONSE" : StrUtil.firstNonBlank(response.getSubCode(), response.getCode(), "UNKNOWN");
throw new RuntimeException("创建支付宝订单码失败,支付宝错误码:" + errorCode);
}
if (StrUtil.isBlank(response.getQrCode())) {
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
thirdPayOrderService.save(payOrder);
throw new RuntimeException("创建支付宝订单码失败,支付宝未返回二维码");
}
thirdPayOrderService.save(payOrder);
ThirdOrderCodePayVo result = new ThirdOrderCodePayVo();
result.setPayNo(dto.getOutTradeNo());
result.setQrCode(response.getQrCode());
result.setExpireTime(expireTime);
return result;
}
/**
* 调用支付宝撤销接口;异常时保留未支付状态,由上层定时任务继续重试。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的商户支付配置快照
* @return 支付宝撤销响应,调用异常时返回 {@code null}
*/
@Override
public AlipayTradeCancelResponse cancel(String payNo, String settingJson) {
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = parseAliPaySettingSnapshot(settingJson);
AlipayTradeCancelRequest request = new AlipayTradeCancelRequest();
AlipayTradeCancelModel model = new AlipayTradeCancelModel();
model.setOutTradeNo(payNo);
request.setBizModel(model);
request.putOtherTextParam(APP_AUTH_TOKEN_PARAM, aliPaySetting.getAppAuthToken());
return getAlipayClient().execute(request);
} catch (Exception exception) {
log.error("|{}|撤销支付宝订单失败,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
return null;
}
}
/**
* app支付
*/
@@ -156,8 +305,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
payOrder.setPayRes(orderStr);
return orderStr;
} catch (Exception e) {
e.printStackTrace();
log.error("|{}|app支付异常,error:|{}|", TAG, e.getMessage());
log.error("|{}|app支付异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), e.getClass().getSimpleName());
return null;
} finally {
thirdPayOrderService.save(payOrder);
@@ -216,8 +364,7 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
}
} catch (Exception e) {
e.printStackTrace();
log.error("|{}|app退款异常,error:|{}|", TAG, e.getMessage());
log.error("|{}|app退款异常,payNo:{},exceptionType:{}", TAG, thirdPayOrder.getPayNo(), e.getClass().getSimpleName());
} finally {
thirdPayOrderService.updateById(updateObj);
}
@@ -225,34 +372,77 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
}
/**
* 支付回调
* 验证支付宝回调签名、应用、订单、平台和金额,并原子推进支付终态。
*
* @param params 支付宝异步通知表单参数
* @return 验真通过后的状态结果;非法通知返回 {@code null}
*/
@Override
public ThirdPayOrderEntity payCallback(Map<String, String> params) {
String paramsJson = JSONUtil.toJsonStr(params);
ThirdALiPayCallbackVo vo = JSONUtil.toBean(paramsJson, ThirdALiPayCallbackVo.class);
if (ObjectUtil.isEmpty(vo)) {
public AliPayCallbackResult payCallback(Map<String, String> params) {
if (ObjectUtil.isEmpty(params) || !verifyPayCallbackSignature(params)) {
return null;
}
boolean paySucess = vo.isPaySucess();
// 查询订单
ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(vo.getOut_trade_no());
String payNo = params.get("out_trade_no");
ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(payNo);
if (ObjectUtil.isEmpty(payOrderEntity)) {
log.error("|{}|payCallback,意外的错误,订单不存在,微信返回json|{}|", TAG, paramsJson);
log.warn("|{}|支付宝支付回调订单不存在,payNo:{}", TAG, payNo);
return null;
}
// 重复的回调
if (!validatePayCallbackOrder(params, payOrderEntity)) {
return null;
}
String tradeStatus = params.get("trade_status");
boolean paySuccess = ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS") || ObjectUtil.equals(tradeStatus, "TRADE_FINISHED");
boolean payClosed = ObjectUtil.equals(tradeStatus, "TRADE_CLOSED");
boolean waitingBuyerPay = ObjectUtil.equals(tradeStatus, "WAIT_BUYER_PAY");
if (!paySuccess && !payClosed && !waitingBuyerPay) {
log.warn("|{}|支付宝支付回调状态不可处理,payNo:{},tradeStatus:{}", TAG, payNo, tradeStatus);
return null;
}
if (waitingBuyerPay) {
return new AliPayCallbackResult(payOrderEntity,
ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()), false);
}
// 终态通知已经处理过时直接确认,避免支付宝重试导致业务成功逻辑重复执行。
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return new AliPayCallbackResult(payOrderEntity, true, false);
}
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) {
return new AliPayCallbackResult(payOrderEntity, false, false);
}
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
log.error("|{}|payCallback,意外的错误,重复的回调,微信返回json|{}|", TAG, paramsJson);
return null;
}
// 更新订单状态
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setId(payOrderEntity.getId());
updateObj.setPayCallbackRes(paramsJson);
updateObj.setStatus(paySucess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_FAILED.getType());
thirdPayOrderService.updateById(updateObj);
return thirdPayOrderService.getById(payOrderEntity.getId());
updateObj.setPayCallbackRes(JSONUtil.toJsonStr(params));
updateObj.setStatus(paySuccess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
if (paySuccess) {
updateObj.setThirdPayNo(params.get("trade_no"));
updateObj.setPayTime(LocalDateTime.now());
}
boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderEntity.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(payOrderEntity.getId());
if (latestOrder == null) {
return null;
}
if (!stateChanged) {
if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return new AliPayCallbackResult(latestOrder, true, false);
}
if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) {
return new AliPayCallbackResult(latestOrder, false, false);
}
return null;
}
return new AliPayCallbackResult(latestOrder, paySuccess, true);
}
/**
@@ -318,4 +508,190 @@ public class ThirdAppPlusAliPayServiceImpl implements ThirdAppPlusAliPayService
return null;
}
/**
* 构建订单码支付的待支付订单,完整记录业务、平台、金额、配置和过期时间。
*
* @param dto 内部支付参数
* @param setting 商户支付宝配置
* @param expireTime 服务端二维码失效时间
* @return 可直接持久化的待支付订单
*/
private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
payOrder.setBizId(dto.getBizId());
payOrder.setBizType(dto.getBizType().getType());
payOrder.setUserId(dto.getUserId());
payOrder.setPrice(dto.getAmount());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setExpireTime(expireTime);
return payOrder;
}
/**
* 解析当前商户支付宝配置并确认授权令牌存在。
*
* @param setting 当前商户支付配置
* @return 支付宝商户授权配置
*/
private MchPaySettingEntity.AliPaySetting parseAliPaySetting(MchPaySettingVo setting) {
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
throw new RuntimeException("商户支付宝支付配置不完整");
}
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
}
return aliPaySetting;
}
/**
* 从支付订单保存的商户配置快照中解析支付宝授权配置。
*
* @param settingJson 创建支付订单时保存的配置快照
* @return 支付宝商户授权配置
*/
private MchPaySettingEntity.AliPaySetting parseAliPaySettingSnapshot(String settingJson) {
if (StrUtil.isBlank(settingJson)) {
throw new RuntimeException("支付订单缺少商户配置快照");
}
MchPaySettingEntity paySetting = JSONUtil.toBean(settingJson, MchPaySettingEntity.class);
if (paySetting == null) {
throw new RuntimeException("支付订单商户配置快照无效");
}
MchPaySettingVo setting = BeanUtil.toBean(paySetting, MchPaySettingVo.class);
return parseAliPaySetting(setting);
}
/**
* 生成支付宝预创建响应快照,仅保存定位订单所需字段和二维码内容。
*
* @param response 支付宝 SDK 响应,允许为空
* @return 可持久化的精简响应快照
*/
private JSONObject buildPrecreateResponseSnapshot(AlipayTradePrecreateResponse response) {
JSONObject snapshot = JSONUtil.createObj();
if (response == null) {
snapshot.set("code", "EMPTY_RESPONSE");
return snapshot;
}
snapshot.set("code", response.getCode());
snapshot.set("message", response.getMsg());
snapshot.set("subCode", response.getSubCode());
snapshot.set("subMessage", response.getSubMsg());
snapshot.set("outTradeNo", response.getOutTradeNo());
snapshot.set("qrCode", response.getQrCode());
return snapshot;
}
/**
* 生成支付宝 SDK 异常快照,避免把堆栈、密钥或授权令牌持久化到订单。
*
* @param exception SDK 或网络调用异常
* @return 仅包含错误类别和 SDK 错误码的精简快照
*/
private JSONObject buildExceptionSnapshot(Exception exception) {
JSONObject snapshot = JSONUtil.createObj();
snapshot.set("code", "SDK_EXCEPTION");
snapshot.set("exceptionType", exception.getClass().getSimpleName());
if (exception instanceof AlipayApiException alipayApiException) {
snapshot.set("alipayErrorCode", alipayApiException.getErrCode());
}
return snapshot;
}
/**
* 使用应用支付宝公钥对支付回调执行 RSA2 验签。
*
* @param params 支付宝表单回调参数
* @return 验签通过时返回 {@code true}
*/
protected boolean verifyPayCallbackSignature(Map<String, String> params) {
try {
return AlipaySignature.rsaCheckV1(params, appPlusPayConfig.getAliPayConfig().getAlipayPublicKey(), "UTF-8", "RSA2");
} catch (Exception exception) {
log.warn("|{}|支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no"));
return false;
}
}
/**
* 校验回调应用、商户订单、支付平台及金额均与内部订单一致。
*
* @param params 已通过签名校验的支付宝回调参数
* @param payOrder 回调商户订单号对应的内部支付订单
* @return 全部业务约束一致时返回 {@code true}
*/
private boolean validatePayCallbackOrder(Map<String, String> params, ThirdPayOrderEntity payOrder) {
String payNo = payOrder.getPayNo();
if (ObjectUtil.notEqual(params.get("app_id"), appPlusPayConfig.getAliPayConfig().getAppId())) {
log.warn("|{}|支付宝支付回调appId不匹配,payNo:{}", TAG, payNo);
return false;
}
if (ObjectUtil.notEqual(params.get("out_trade_no"), payNo)
|| ObjectUtil.notEqual(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
log.warn("|{}|支付宝支付回调订单或平台不匹配,payNo:{}", TAG, payNo);
return false;
}
ThirdAggregatePaySessionEntity aggregateSession = aggregatePaySessionService == null
? null : aggregatePaySessionService.getByPayNo(payNo);
boolean aggregateRequest = isAggregatePayRequest(payOrder);
if (aggregateRequest != (aggregateSession != null)
|| (aggregateSession != null
&& (!isMatchingAggregateSession(aggregateSession, payOrder)
|| ObjectUtil.notEqual(aggregateSession.getSelectedPlatform(), AppPlusPlatformTypeEnum.ALI_PAY.getType())))) {
log.warn("|{}|支付宝聚合支付回调会话绑定不一致,payNo:{}", TAG, payNo);
return false;
}
try {
BigDecimal callbackAmount = new BigDecimal(params.get("total_amount")).setScale(2, RoundingMode.UNNECESSARY);
BigDecimal orderAmount = BigDecimal.valueOf(payOrder.getPrice()).movePointLeft(2).setScale(2, RoundingMode.UNNECESSARY);
if (callbackAmount.compareTo(orderAmount) != 0) {
log.warn("|{}|支付宝支付回调金额不匹配,payNo:{}", TAG, payNo);
return false;
}
} catch (Exception exception) {
log.warn("|{}|支付宝支付回调金额格式错误,payNo:{}", TAG, payNo);
return false;
}
return true;
}
/**
* 校验聚合会话与实际第三方子订单的业务、商户和金额快照一致。
*
* @param session {@code pay_no} 绑定的聚合支付会话
* @param payOrder 支付宝回调对应的第三方子订单
* @return 关键业务字段完全一致时返回 {@code true}
*/
private boolean isMatchingAggregateSession(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) {
return ObjectUtil.equals(session.getPayNo(), payOrder.getPayNo())
&& ObjectUtil.equals(session.getBizType(), payOrder.getBizType())
&& ObjectUtil.equals(session.getBizId(), payOrder.getBizId())
&& ObjectUtil.equals(session.getMchId(), payOrder.getMchId())
&& ObjectUtil.equals(session.getPrice(), payOrder.getPrice());
}
/**
* 判断内部订单请求快照是否明确标记为支付宝聚合 JSAPI 子订单。
*
* @param payOrder 回调商户订单号对应的内部支付订单
* @return 请求快照同时包含聚合标记和 {@code JSAPI_PAY} 产品码时返回 {@code true}
*/
private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) {
return false;
}
try {
JSONObject request = JSONUtil.parseObj(payOrder.getPayRep());
return Boolean.TRUE.equals(request.getBool("aggregatePay"))
&& ObjectUtil.equals(request.getStr("productCode"), "JSAPI_PAY");
} catch (Exception exception) {
return false;
}
}
}
@@ -4,9 +4,13 @@ import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import com.alipay.api.internal.util.AlipaySignature;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.admin.api.dto.UserInfo;
import com.bsd.pjcloud.admin.api.dto.UserTokenDTO;
import com.bsd.pjcloud.admin.api.feign.RemoteTokenService;
@@ -34,6 +38,10 @@ import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.*;
import com.bsd.pjcloud.thirdparty.vo.appplus.ThirdMinAppOAth2LoginVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdMiniAppPayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.alipay.ThirdALiPayCallbackVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.wechat.*;
import com.bsd.pjcloud.thirdparty.vo.pay.ThirdPayOrderVo;
@@ -53,19 +61,26 @@ import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.github.binarywang.wxpay.bean.result.WxPayMicropayResult;
import com.wechat.pay.java.service.partnerpayments.app.model.Transaction;
import com.wechat.pay.java.service.refund.model.RefundNotification;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 小程序(小程序版本)
* App、小程序及消费机第三方支付编排服务。
*
* <p>负责选择商户支付配置、创建平台订单、处理回调、轮询状态和执行原有业务结果通知。</p>
*/
@Service
@Validated
@@ -73,29 +88,128 @@ import java.util.Map;
@RequiredArgsConstructor
public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
static private final String TAG = "小程序服务类";
/**
* 支付编排日志标签,不包含支付配置、授权令牌或二维码内容。
*/
private static final String TAG = "小程序服务类";
/**
* 匿名消费机订单的内部用户ID占位值,用于区分空值和真实登录用户。
*/
private static final long ANONYMOUS_ORDER_USER_ID = -1L;
/**
* 订单码有效时长,和提交给支付宝、微信的支付截止时间保持一致。
*/
private static final long ORDER_CODE_EXPIRE_MINUTES = 2L;
/**
* 订单码幂等锁命名空间;租户前缀由 Redis 基础设施统一附加,业务层只拼接业务类型和业务ID。
*/
private static final String ORDER_CODE_LOCK_PREFIX = "thirdparty:order-code-pay:lock:";
/**
* 订单码幂等锁最长持有时间;覆盖正常的查询、撤销和预创建网络调用。
*/
private static final Duration ORDER_CODE_LOCK_TTL = Duration.ofSeconds(30);
/**
* 支付宝撤销接口要求稍后重试时返回的标记值。
*/
private static final String ALIPAY_CANCEL_RETRY = "Y";
/**
* 仅当 Redis 锁值仍属于当前请求时删除锁,避免超时后误删后继请求的锁。
*/
private static final DefaultRedisScript<Long> ORDER_CODE_UNLOCK_SCRIPT = new DefaultRedisScript<>(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class);
/**
* App 与小程序支付应用配置,来源于现有 app-plus.pay 配置中心。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 微信支付服务,负责微信下单、查询、退款和回调解析。
*/
private final ThirdAppPlusWechatService thirdMiniAppWechatService;
/**
* 微信服务商 Native 支付服务,负责订单码下单、查询和关单。
*/
private final ThirdAppPlusWechatNativePayService thirdAppPlusWechatNativePayService;
/**
* 微信付款码支付服务,负责云消费机主动扫用户付款码的旧支付流程。
*/
private final ThirdAppPlusWechatMicropayService thirdAppPlusWechatMicropayService;
/**
* 支付宝支付服务,负责应用支付、订单码预创建、查询、撤销和回调验真。
*/
private final ThirdAppPlusAliPayService thirdMiniAppAliPayService;
/**
* 第三方支付日志服务,由既有支付流程用于记录平台交互日志。
*/
private final ThirdPayLogService thirdPayLogService;
/**
* 第三方支付订单服务,持有支付状态和商户配置快照的持久化边界。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 支付宝与微信聚合订单码编排服务,旧服务仅负责公开契约委托和跨模式互斥。
*/
private final ThirdAggregateOrderPayService thirdAggregateOrderPayService;
/**
* Redis 字符串模板,用于同一租户业务订单的短时并发互斥。
*/
private final StringRedisTemplate stringRedisTemplate;
/**
* 用户远程服务,用于现有登录、用户归属商户和钱包支付流程。
*/
private final RemoteUserService remoteUserService;
/** 用户关系远程服务,用于支付发起前再次校验监护授权。 */
/**
* 用户关系远程服务,用于支付发起前再次校验监护授权。
*/
private final RemoteUserRelationService remoteUserRelationService;
/**
* 令牌远程服务,用于现有小程序登录成功后的 Token 签发。
*/
private final RemoteTokenService remoteTokenService;
/**
* App 用户远程服务,用于现有小程序账号同步。
*/
private final RemoteAppUserService remoteAppUserService;
/**
* 钱包充值套餐远程服务,用于解析充值金额并接收支付、退款结果。
*/
private final RemoteWalletRechargePackageService remoteWalletRechargePackageService;
/** 充值记录远程服务,用于类型 40 解析可信金额和目标钱包。 */
/**
* 充值记录远程服务,用于类型 40 解析可信金额和目标钱包。
*/
private final RemoteWalletRechargeService remoteWalletRechargeService;
/** 钱包远程服务,用于确认目标饭卡状态与所属食堂。 */
/**
* 钱包远程服务,用于确认目标饭卡状态与所属食堂。
*/
private final RemoteWalletService remoteWalletService;
/**
* 商户远程服务,订单码支付仅按请求中的商户ID读取商户及启用配置。
*/
private final RemoteMchService remoteMchService;
/**
* 硬件订单远程服务,仅供原有 App 商品订单支付结果回调使用。
*/
private final RemoteHardOrderService remoteHardOrderService;
/**
@@ -313,9 +427,6 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
/**
* 查询当前付款人所属商户,原本人充值和商城支付继续沿用同一套商户解析逻辑。
*
* @param userId 当前付款人用户ID
* @return 当前付款人所属商户
*/
private MchManageVo getUserMerchant(Long userId) {
R<UserInfo> userResult = remoteUserService.getByUserId(userId);
@@ -408,6 +519,349 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
return null;
}
/**
* 创建支付宝或微信订单码支付订单,并在同租户业务维度复用同平台仍有效的二维码。
*
* @param dto 业务订单、收款商户、金额和扫码支付平台参数
* @return 支付订单号、平台二维码内容串和服务端过期时间
*/
@Override
public ThirdOrderCodePayVo orderCodePay(@Valid ThirdOrderCodePayDto dto) {
validateOrderCodePayRequest(dto);
String lockKey = buildOrderCodePayLockKey(dto);
String lockToken = UUID.randomUUID().toString();
Boolean lockAcquired = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockToken, ORDER_CODE_LOCK_TTL);
if (!Boolean.TRUE.equals(lockAcquired)) {
throw new RuntimeException("订单码支付正在处理中,请勿重复提交");
}
try {
return createOrReuseOrderCodePay(dto);
} finally {
releaseOrderCodePayLock(lockKey, lockToken);
}
}
/**
* 查询订单码支付的最小状态,不透出商户配置和第三方支付报文。
*/
@Override
public ThirdOrderCodePayStatusVo getOrderCodePayStatus(String payNo) {
if (StrUtil.isBlank(payNo)) {
throw new RuntimeException("支付订单号不能为空");
}
ThirdPayOrderEntity payOrder = thirdPayOrderService.getByPayNo(payNo);
if (payOrder == null
|| ObjectUtil.notEqual(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
|| !isSupportedOrderCodePlatform(payOrder.getPayPlatformType())) {
throw new RuntimeException("订单码支付订单不存在");
}
ThirdOrderCodePayStatusVo result = new ThirdOrderCodePayStatusVo();
result.setPayNo(payOrder.getPayNo());
result.setStatus(payOrder.getStatus());
result.setPaySuccess(ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()));
result.setExpireTime(payOrder.getExpireTime());
return result;
}
/**
* 委托聚合支付服务创建或复用支付宝、微信共用二维码。
*
* @param dto 业务订单、收款商户、金额和可选用户参数
* @return 聚合会话编号、HTTPS 二维码内容和失效时间
*/
@Override
public ThirdAggregateOrderCodePayVo aggregateOrderCodePay(@Valid ThirdAggregateOrderCodePayDto dto) {
return thirdAggregateOrderPayService.create(dto);
}
/**
* 委托聚合支付服务查询最小会话状态。
*
* @param aggregateNo 聚合支付会话编号
* @return 聚合状态、选定平台和第三方子订单号
*/
@Override
public ThirdAggregateOrderCodePayStatusVo getAggregateOrderCodePayStatus(String aggregateNo) {
return thirdAggregateOrderPayService.getStatus(aggregateNo);
}
/**
* 在已经取得业务锁后复用有效订单或创建新的支付宝、微信订单码。
*/
private ThirdOrderCodePayVo createOrReuseOrderCodePay(ThirdOrderCodePayDto dto) {
thirdAggregateOrderPayService.assertNoBlockingAggregateSession(dto.getBizType(), dto.getBizId());
if (hasSuccessfulOrderCodePay(dto.getBizType(), dto.getBizId())) {
throw new RuntimeException("该业务订单已支付,请勿重复下单");
}
ThirdPayOrderEntity latestOrder = getLatestOrderCodePay(dto.getBizType(), dto.getBizId());
if (latestOrder != null) {
if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
throw new RuntimeException("该业务订单已支付,请勿重复下单");
}
if (ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
validateReusableOrder(latestOrder, dto);
String qrCode = getOrderCodeQrCode(latestOrder);
if (latestOrder.getExpireTime() != null && latestOrder.getExpireTime().isAfter(LocalDateTime.now()) && StrUtil.isNotBlank(qrCode)) {
if (ObjectUtil.notEqual(latestOrder.getPayPlatformType(), dto.getMiniPlatformType().getType())) {
throw new RuntimeException("该业务订单已有其他平台的待支付订单码");
}
return buildOrderCodePayVo(latestOrder, qrCode);
}
// 过期订单必须先向原支付平台确认并关闭,不能仅按本地时间直接创建第二笔可支付交易。
queryPayResult(latestOrder);
latestOrder = thirdPayOrderService.getById(latestOrder.getId());
if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
throw new RuntimeException("该业务订单已支付,请勿重复下单");
}
if (latestOrder != null && ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
if (latestOrder.getExpireTime() != null && latestOrder.getExpireTime().isAfter(LocalDateTime.now())) {
throw new RuntimeException("订单码数据异常,请稍后重试");
}
throw new RuntimeException("上一笔订单正在确认关闭,请稍后重试");
}
}
}
MchManageVo merchant = getOrderCodePayMerchant(dto.getMchId());
ThirdAppPlusPayDto payDto = new ThirdAppPlusPayDto();
payDto.setOutTradeNo(createPayNo());
payDto.setBizId(dto.getBizId());
payDto.setBizType(dto.getBizType());
payDto.setUserId(dto.getUserId() == null ? ANONYMOUS_ORDER_USER_ID : dto.getUserId());
payDto.setAmount(dto.getAmount());
payDto.setMchId(merchant.getId());
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(ORDER_CODE_EXPIRE_MINUTES);
if (ObjectUtil.equals(dto.getMiniPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) {
MchPaySettingVo paySetting = getEnabledAliPaySetting(merchant);
validateAliPayApplicationConfig();
return thirdMiniAppAliPayService.precreate(payDto, paySetting, expireTime);
}
MchPaySettingVo paySetting = getEnabledWechatPaySetting(merchant);
validateWechatApplicationConfig();
return thirdAppPlusWechatNativePayService.precreate(payDto, paySetting, expireTime);
}
/**
* 校验订单码支付首版允许的业务、平台、金额和商户参数。
*/
private void validateOrderCodePayRequest(ThirdOrderCodePayDto dto) {
if (dto == null) {
throw new RuntimeException("订单码支付参数不能为空");
}
if (ObjectUtil.notEqual(dto.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY)) {
throw new RuntimeException("订单码支付仅支持云消费机支付业务");
}
if (dto.getMiniPlatformType() == null || !isSupportedOrderCodePlatform(dto.getMiniPlatformType().getType())) {
throw new RuntimeException("订单码支付仅支持支付宝或微信");
}
if (StrUtil.isBlank(dto.getBizId())) {
throw new RuntimeException("支付业务id不能为空");
}
if (dto.getMchId() == null) {
throw new RuntimeException("商户id不能为空");
}
if (dto.getAmount() == null || dto.getAmount() <= 0) {
throw new RuntimeException("支付金额必须大于0");
}
}
/**
* 查询同租户、同业务类型和业务ID下最新创建的支付宝或微信订单码支付订单。
*/
private ThirdPayOrderEntity getLatestOrderCodePay(ThirdPayOrderBizTypeEnum bizType, String bizId) {
return thirdPayOrderService.getOne(new LambdaQueryWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getBizType, bizType.getType())
.eq(ThirdPayOrderEntity::getBizId, bizId)
.in(ThirdPayOrderEntity::getPayPlatformType,
AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType())
.isNotNull(ThirdPayOrderEntity::getExpireTime)
.notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'")
.orderByDesc(ThirdPayOrderEntity::getCreateTime)
.last("limit 1"));
}
/**
* 判断同租户业务订单是否已经存在支付成功的支付宝或微信订单码。
*/
private boolean hasSuccessfulOrderCodePay(ThirdPayOrderBizTypeEnum bizType, String bizId) {
return thirdPayOrderService.count(new LambdaQueryWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getBizType, bizType.getType())
.eq(ThirdPayOrderEntity::getBizId, bizId)
.in(ThirdPayOrderEntity::getPayPlatformType, AppPlusPlatformTypeEnum.ALI_PAY.getType(), AppPlusPlatformTypeEnum.WECHAT.getType())
.isNotNull(ThirdPayOrderEntity::getExpireTime)
.notExists("SELECT 1 FROM third_aggregate_pay_session s WHERE s.pay_no = third_pay_order.pay_no AND s.del_flag = '0'")
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) > 0;
}
/**
* 校验待复用订单与本次请求的收款商户和金额完全一致。
*/
private void validateReusableOrder(ThirdPayOrderEntity payOrder, ThirdOrderCodePayDto dto) {
if (ObjectUtil.notEqual(payOrder.getMchId(), dto.getMchId()) || ObjectUtil.notEqual(payOrder.getPrice(), dto.getAmount())) {
throw new RuntimeException("该业务订单已有不同金额或商户的待支付订单");
}
}
/**
* 从预创建响应快照中提取支付宝或微信二维码内容串。
*/
private String getOrderCodeQrCode(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRes())) {
return null;
}
try {
return JSONUtil.parseObj(payOrder.getPayRes()).getStr("qrCode");
} catch (Exception exception) {
log.warn("|{}|订单码响应快照解析失败,payNo:{}", TAG, payOrder.getPayNo());
return null;
}
}
/**
* 将已有支付订单转换为可复用的订单码创建结果。
*/
private ThirdOrderCodePayVo buildOrderCodePayVo(ThirdPayOrderEntity payOrder, String qrCode) {
ThirdOrderCodePayVo result = new ThirdOrderCodePayVo();
result.setPayNo(payOrder.getPayNo());
result.setQrCode(qrCode);
result.setExpireTime(payOrder.getExpireTime());
return result;
}
/**
* 仅按请求中的商户ID读取收款商户,并校验远程调用业务状态。
*/
private MchManageVo getOrderCodePayMerchant(Long mchId) {
R<MchManageVo> merchantResult = remoteMchService.getById(mchId);
MchManageVo merchant = merchantResult == null || !merchantResult.isOk() ? null : merchantResult.getData();
if (merchant == null) {
throw new RuntimeException("收款商户不存在");
}
if (merchant.getId() == null || ObjectUtil.notEqual(merchant.getId(), mchId)) {
throw new RuntimeException("收款商户信息不一致");
}
if (ObjectUtil.isEmpty(merchant.getPaySettingList())) {
throw new RuntimeException("商户未配置支付参数");
}
return merchant;
}
/**
* 从商户详情中选择启用的支付宝配置并校验应用授权令牌。
*/
private MchPaySettingVo getEnabledAliPaySetting(MchManageVo merchant) {
MchPaySettingVo paySetting = merchant.getPaySettingList().stream()
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.ALI_PAY.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst()
.orElse(null);
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户未启用支付宝支付配置");
}
try {
MchPaySettingEntity.AliPaySetting aliPaySetting = JSONUtil.toBean(paySetting.getSettingJson(), MchPaySettingEntity.AliPaySetting.class);
if (aliPaySetting == null || StrUtil.isBlank(aliPaySetting.getAppAuthToken())) {
throw new RuntimeException("商户支付宝授权Token未配置");
}
} catch (RuntimeException exception) {
if ("商户支付宝授权Token未配置".equals(exception.getMessage())) {
throw exception;
}
throw new RuntimeException("商户支付宝支付配置无效");
}
return paySetting;
}
/**
* 从商户详情中选择启用的微信支付配置并校验服务商模式子商户号。
*/
private MchPaySettingVo getEnabledWechatPaySetting(MchManageVo merchant) {
MchPaySettingVo paySetting = merchant.getPaySettingList().stream()
.filter(item -> ObjectUtil.equals(item.getPlatformType(), AppPlusPlatformPayType.WECHAT.getType()))
.filter(item -> ObjectUtil.equals(item.getEnable(), YesNoEnum.YES.getCode()))
.findFirst()
.orElse(null);
if (paySetting == null || StrUtil.isBlank(paySetting.getSettingJson())) {
throw new RuntimeException("商户未启用微信支付配置");
}
try {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(
paySetting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
} catch (RuntimeException exception) {
if ("商户微信子商户号未配置".equals(exception.getMessage())) {
throw exception;
}
throw new RuntimeException("商户微信支付配置无效");
}
return paySetting;
}
/**
* 校验现有支付宝应用配置满足预创建、回调验签和回调地址拼接要求。
*/
private void validateAliPayApplicationConfig() {
AppPlusPayConfig.AliPayConfig config = appPlusPayConfig.getAliPayConfig();
if (config == null || StrUtil.hasBlank(config.getAppId(), config.getAppPrivateKey(), config.getAlipayPublicKey(),
config.getBaseCallbackUrl(), config.getPayCallbackUrl())) {
throw new RuntimeException("支付宝应用支付配置不完整");
}
}
/**
* 校验现有微信服务商应用配置满足 Native 下单、回调验签和回调地址拼接要求。
*/
private void validateWechatApplicationConfig() {
AppPlusPayConfig.WechatConfig config = appPlusPayConfig.getWechatConfig();
if (config == null || StrUtil.hasBlank(config.getAppPlatformAppId(), config.getMerchantId(),
config.getMchPrivatePath(), config.getMerchantSerialNumber(), config.getApiV3Key(),
config.getBaseCallbackUrl(), config.getPayCallbackUrl())) {
throw new RuntimeException("微信服务商支付配置不完整");
}
}
/**
* 判断平台持久化编码是否属于订单码支持的支付宝或微信平台。
*/
private boolean isSupportedOrderCodePlatform(String platformType) {
return ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY.getType())
|| ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT.getType());
}
/**
* 生成符合支付宝商户订单号长度限制的唯一支付订单号。
*/
private String createPayNo() {
return LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + RandomUtil.randomString(10);
}
/**
* 生成业务维度的订单码幂等锁键,租户隔离交由 Redis 基础设施统一处理。
*
* @param dto 已校验的订单码支付请求
* @return 仅包含业务命名空间、业务类型和业务ID的 Redis 锁键
*/
private String buildOrderCodePayLockKey(ThirdOrderCodePayDto dto) {
return ORDER_CODE_LOCK_PREFIX + dto.getBizType().getType() + ":" + dto.getBizId();
}
/**
* 使用令牌校验脚本释放订单码幂等锁;Redis 异常不覆盖主支付结果。
*
* @param lockKey 当前请求的 Redis 锁键
* @param lockToken 当前请求持有的随机锁令牌
*/
private void releaseOrderCodePayLock(String lockKey, String lockToken) {
try {
stringRedisTemplate.execute(ORDER_CODE_UNLOCK_SCRIPT, List.of(lockKey), lockToken);
} catch (Exception exception) {
log.warn("|{}|订单码支付锁释放异常", TAG, exception);
}
}
/**
* 小程序退款
*/
@@ -479,39 +933,51 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
}
/**
* 支付回调
* 处理共享支付回调并返回平台要求的纯文本确认结果。
*
* @param params 表单回调参数,支付宝回调使用
* @param body 原始请求体,微信回调使用
* @param headers 原始请求头,微信回调验签使用
* @return 平台要求的 {@code success} 或 {@code fail}
*/
@Override
public String miniPayCallback(Map<String, String> params, String body, Map<String, String> headers) {
AppPlusPlatformTypeEnum platformType = checkMiniPlatformType(params, body, headers);
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
// 微信小程序
// 微信支付验签与关键字段校验已在微信服务中完成;重复成功通知不会再次推进支付状态。
if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.WECHAT)) {
ThirdPayOrderEntity thirdPayOrder = thirdMiniAppWechatService.payCallback(body, headers);
if (ObjectUtil.isEmpty(thirdPayOrder)) {
return "fail";
}
// 根据订单bizType来处理
boolean isSuccess = disposePaySuccessAfter(thirdPayOrder);
if (isSuccess) {
updateObj.setId(thirdPayOrder.getId());
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
thirdPayOrderService.updateById(updateObj);
if (thirdAggregateOrderPayService.syncAggregateChildOrder(thirdPayOrder)) {
return "success";
}
return isSuccess ? "success" : "fail";
markBusinessCallbackIfSuccessful(thirdPayOrder);
ThirdPayOrderEntity latestOrder = thirdPayOrderService.getById(thirdPayOrder.getId());
return latestOrder != null
&& ObjectUtil.equals(latestOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
&& ObjectUtil.equals(latestOrder.getIsBizCallback(), YesNoEnum.YES.getCode()) ? "success" : "fail";
}
// 支付宝
else if (ObjectUtil.equals(platformType, AppPlusPlatformTypeEnum.ALI_PAY)) {
ThirdPayOrderEntity thirdPayOrder = thirdMiniAppAliPayService.payCallback(params);
if (ObjectUtil.isEmpty(thirdPayOrder)) {
ThirdAppPlusAliPayService.AliPayCallbackResult callbackResult = thirdMiniAppAliPayService.payCallback(params);
if (callbackResult == null) {
return ThirdALiPayCallbackVo.aliPayFail();
}
if (thirdAggregateOrderPayService.syncAggregateChildOrder(callbackResult.getPayOrder())) {
return ThirdALiPayCallbackVo.aliPaySuccess();
}
// 已关闭通知和重复成功通知均已完成平台确认,不再重复触发下游业务处理。
if (!callbackResult.isPaySuccess() || !callbackResult.isStateChanged()) {
return ThirdALiPayCallbackVo.aliPaySuccess();
}
ThirdPayOrderEntity thirdPayOrder = callbackResult.getPayOrder();
// 根据订单bizType来处理
boolean isSuccess = disposePaySuccessAfter(thirdPayOrder);
// 更新
if (isSuccess) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setId(thirdPayOrder.getId());
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
thirdPayOrderService.updateById(updateObj);
@@ -520,70 +986,210 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
}
// 不支持的支付类型
else {
return null;
return "fail";
}
}
/**
* 主动查询支付平台状态并以未支付条件原子推进内部订单状态。
*
* <p>订单码到期仍待支付时会调用对应平台的撤销或关单接口;网络错误和可重试结果均保留未支付状态。</p>
*
* @param dto 需要查询的内部支付订单
*/
@Override
public void queryPayResult(ThirdPayOrderEntity dto) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
boolean isUpdateOrder = false;
// 微信小程序
if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT)) {
if (dto == null || dto.getId() == null) {
return;
}
// 聚合 JSAPI 子订单由独立任务按会话平台查询,禁止在旧入口中误走微信 Native 查询或旧业务回调。
if (thirdAggregateOrderPayService.syncAggregateChildOrder(dto)) {
return;
}
// 微信支付:订单码使用 Native 服务,原有 App/小程序订单继续沿用既有查询服务。
if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
if (isOrderCodePay(dto)) {
queryWechatOrderCodePayResult(dto);
return;
}
Transaction thirdPayOrder = thirdMiniAppWechatService.query(dto.getPayNo(), dto.getMchPaySettingJson());
if (ObjectUtil.isEmpty(thirdPayOrder) || ObjectUtil.isEmpty(thirdPayOrder.getTradeState())) {
return;
}
Transaction.TradeStateEnum tradeState = thirdPayOrder.getTradeState();
if (ObjectUtil.equals(tradeState, Transaction.TradeStateEnum.SUCCESS)) {
if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
// 执行支付成功的代码
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
updateObj.setPayTime(LocalDateTime.now());
isUpdateOrder = true;
}
if (ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode())) {
dto.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
boolean isSuccess = disposePaySuccessAfter(dto);
if (isSuccess) {
isUpdateOrder = true;
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
thirdPayOrderService.updateById(updateObj);
}
boolean stateChanged = updatePayOrderSuccess(dto, thirdPayOrder.getTransactionId());
if (stateChanged || (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
&& ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode()))) {
markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId()));
}
}
}
// 支付宝
else if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY)) {
else if (ObjectUtil.equals(dto.getPayPlatformType(), AppPlusPlatformTypeEnum.ALI_PAY.getType())) {
AlipayTradeQueryResponse aliRes = thirdMiniAppAliPayService.query(dto.getPayNo(), dto.getMchPaySettingJson());
if (ObjectUtil.isEmpty(aliRes) || ObjectUtil.isEmpty(aliRes.getTradeStatus())) {
if (aliRes == null) {
return;
}
String tradeStatus = aliRes.getTradeStatus();
if (ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS")) {
if (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
// 执行支付成功的代码
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
updateObj.setPayTime(LocalDateTime.now());
isUpdateOrder = true;
}
if (ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode())) {
dto.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
boolean isSuccess = disposePaySuccessAfter(dto);
if (isSuccess) {
isUpdateOrder = true;
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
thirdPayOrderService.updateById(updateObj);
}
if (ObjectUtil.equals(tradeStatus, "TRADE_SUCCESS") || ObjectUtil.equals(tradeStatus, "TRADE_FINISHED")) {
boolean stateChanged = updatePayOrderSuccess(dto, aliRes.getTradeNo());
if (stateChanged || (ObjectUtil.equals(dto.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
&& ObjectUtil.notEqual(dto.getIsBizCallback(), YesNoEnum.YES.getCode()))) {
markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(dto.getId()));
}
} else if (ObjectUtil.equals(tradeStatus, "TRADE_CLOSED")) {
updateUnpaidOrderToClosed(dto.getId());
} else if (isExpiredOrderCodePay(dto)) {
closeExpiredOrderCodePay(dto);
}
}
// 不支持的支付类型
else {
}
/**
* 将未支付的第三方订单原子更新为支付成功并记录平台交易号。
*
* @param payOrder 当前内部支付订单
* @param tradeNo 支付平台交易号
* @return 本次是否完成从未支付到支付成功的状态推进
*/
private boolean updatePayOrderSuccess(ThirdPayOrderEntity payOrder, String tradeNo) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
updateObj.setPayTime(LocalDateTime.now());
updateObj.setThirdPayNo(tradeNo);
return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrder.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
}
/**
* 查询微信服务商 Native 订单码并处理成功、关闭、失败和到期关单状态。
*
* @param payOrder 当前仍需确认状态的微信订单码内部订单
*/
private void queryWechatOrderCodePayResult(ThirdPayOrderEntity payOrder) {
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction =
thirdAppPlusWechatNativePayService.query(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
if (transaction == null || transaction.getTradeState() == null) {
return;
}
if (isUpdateOrder) {
thirdPayOrderService.updateById(updateObj);
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum tradeState = transaction.getTradeState();
if (ObjectUtil.equals(tradeState,
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.SUCCESS)) {
boolean stateChanged = updatePayOrderSuccess(payOrder, transaction.getTransactionId());
if (stateChanged || (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
&& ObjectUtil.notEqual(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode()))) {
markBusinessCallbackIfSuccessful(thirdPayOrderService.getById(payOrder.getId()));
}
return;
}
if (ObjectUtil.equals(tradeState,
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.CLOSED)
|| ObjectUtil.equals(tradeState,
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.REVOKED)) {
updateUnpaidOrderToClosed(payOrder.getId());
return;
}
if (ObjectUtil.equals(tradeState,
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.PAYERROR)) {
updateUnpaidOrderToFailed(payOrder.getId());
return;
}
if (isExpiredOrderCodePay(payOrder)) {
closeExpiredOrderCodePay(payOrder);
}
}
/**
* 将未支付订单原子推进为交易关闭,避免覆盖并发到达的支付成功状态。
*
* @param payOrderId 内部支付订单主键
* @return 本次是否完成关闭状态推进
*/
private boolean updateUnpaidOrderToClosed(Long payOrderId) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderId)
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
}
/**
* 将未支付订单原子推进为支付失败,用于微信明确返回 {@code PAYERROR} 的异常终态。
*
* @param payOrderId 内部支付订单主键
* @return 本次是否完成失败状态推进
*/
private boolean updateUnpaidOrderToFailed(Long payOrderId) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
return thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderId)
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
}
/**
* 判断支付订单是否属于本次支付宝或微信云消费机订单码能力。
*
* @param payOrder 当前内部支付订单
* @return 业务类型、平台和过期时间均符合订单码特征时返回 {@code true}
*/
private boolean isOrderCodePay(ThirdPayOrderEntity payOrder) {
return payOrder != null
&& ObjectUtil.equals(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
&& isSupportedOrderCodePlatform(payOrder.getPayPlatformType())
&& payOrder.getExpireTime() != null;
}
/**
* 判断订单是否属于已到期但仍未支付的支付宝或微信云消费机订单码。
*
* @param payOrder 当前内部支付订单
* @return 满足订单码业务、未支付且服务端已到期时返回 {@code true}
*/
private boolean isExpiredOrderCodePay(ThirdPayOrderEntity payOrder) {
return isOrderCodePay(payOrder)
&& ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())
&& !payOrder.getExpireTime().isAfter(LocalDateTime.now());
}
/**
* 关闭已经到期的支付宝或微信订单码;需重试、网络异常或未知结果均保留未支付状态。
*
* @param payOrder 已到期且查询未确认成功的订单码支付订单
*/
private void closeExpiredOrderCodePay(ThirdPayOrderEntity payOrder) {
if (ObjectUtil.equals(payOrder.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())) {
if (thirdAppPlusWechatNativePayService.close(payOrder.getPayNo(), payOrder.getMchPaySettingJson())) {
updateUnpaidOrderToClosed(payOrder.getId());
}
return;
}
AlipayTradeCancelResponse cancelResponse = thirdMiniAppAliPayService.cancel(payOrder.getPayNo(), payOrder.getMchPaySettingJson());
if (cancelResponse != null && cancelResponse.isSuccess()
&& ObjectUtil.notEqual(cancelResponse.getRetryFlag(), ALIPAY_CANCEL_RETRY)
&& (ObjectUtil.equals(cancelResponse.getAction(), "close") || ObjectUtil.equals(cancelResponse.getAction(), "refund"))) {
updateUnpaidOrderToClosed(payOrder.getId());
}
}
/**
* 支付成功后执行原有业务回调,并仅在业务处理成功时记录完成标识。
*
* @param payOrder 已确认支付成功的最新内部订单
*/
private void markBusinessCallbackIfSuccessful(ThirdPayOrderEntity payOrder) {
if (payOrder == null || ObjectUtil.notEqual(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())
|| ObjectUtil.equals(payOrder.getIsBizCallback(), YesNoEnum.YES.getCode())) {
return;
}
if (disposePaySuccessAfter(payOrder)) {
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setIsBizCallback(YesNoEnum.YES.getCode());
thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrder.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_SUCCESS.getType()));
}
}
@@ -631,7 +1237,14 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
return "success";
}
// 判断支付回调
/**
* 识别共享支付回调所属平台;支付宝分支先执行 RSA2 验签再返回平台类型。
*
* @param params 表单回调参数
* @param body 原始请求体
* @param headers 原始请求头
* @return 已验真的支付平台;无法识别或验签失败时返回 {@code null}
*/
private AppPlusPlatformTypeEnum checkMiniPlatformType(Map<String, String> params, String body, Map<String, String> headers) {
Transaction transaction = thirdMiniAppWechatService.pasePayCallback(body, headers);
// 微信
@@ -644,7 +1257,7 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
return AppPlusPlatformTypeEnum.ALI_PAY;
}
} catch (Exception e) {
log.error("|{}|判断支付回调alipay sdk验签错误,body:|{}|parameterMap:{||}", TAG, body, JSONUtil.toJsonStr(params));
log.warn("|{}|判断支付宝支付回调验签异常,payNo:{}", TAG, params.get("out_trade_no"));
}
}
return null;
@@ -671,6 +1284,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
return null;
}
/**
* 根据业务类型执行原有支付成功后置处理;云消费机订单码本次仅确认 thirdparty 订单,不跨服务更新硬件订单。
*
* @param thirdPayOrder 已确认支付成功的内部支付订单
* @return 后置处理成功或该业务无需后置处理时返回 {@code true}
*/
public boolean disposePaySuccessAfter(ThirdPayOrderEntity thirdPayOrder) {
if (ObjectUtil.notEqual(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return true;
@@ -704,6 +1323,12 @@ public class ThirdAppPlusServiceImpl implements ThirdAppPlusService {
}
}
/**
* 根据业务类型执行原有退款成功后置处理。
*
* @param thirdPayOrder 已确认退款成功的内部支付订单
* @return 后置处理成功或该业务无需后置处理时返回 {@code true}
*/
private boolean disposeRefundSuccessAfter(ThirdPayOrderEntity thirdPayOrder) {
if (ObjectUtil.notEqual(thirdPayOrder.getStatus(), ThirdPayOrderStatusEnum.REFUND_SUCCESS.getType())) {
return true;
@@ -0,0 +1,269 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatNativePayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.core.exception.ServiceException;
import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.Amount;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
/**
* 微信支付服务商模式 Native 订单码实现。
*
* <p>使用项目现有微信支付 APIv3 SDK 与服务商证书配置,金额始终以整数分传递。</p>
*/
@Service
@Validated
@Slf4j
@RequiredArgsConstructor
public class ThirdAppPlusWechatNativePayServiceImpl implements ThirdAppPlusWechatNativePayService {
/**
* 微信订单码日志标签,不包含商户证书、APIv3 密钥或二维码内容。
*/
private static final String TAG = "微信服务商Native支付";
/**
* 微信账单展示的固定商品描述,避免向第三方平台传递内部订单隐私。
*/
private static final String ORDER_CODE_DESCRIPTION = "资安信消费支付";
/**
* 微信订单码使用的人民币币种编码。
*/
private static final String CURRENCY_CNY = "CNY";
/**
* 微信支付服务商 Native SDK 服务,复用应用级证书与签名配置。
*/
private final NativePayService nativePayService;
/**
* 微信应用与服务商配置,来源于现有 {@code app-plus.pay} 配置中心。
*/
private final AppPlusPayConfig appPlusPayConfig;
/**
* 第三方支付订单服务,用于保存请求、响应和失败快照。
*/
private final ThirdPayOrderService thirdPayOrderService;
/**
* 创建微信服务商 Native 订单,只有 SDK 返回非空 {@code code_url} 才视为成功。
*
* @param dto 已生成商户订单号的内部支付参数,金额单位为人民币分
* @param setting 当前子商户启用的微信支付配置
* @param expireTime 服务端订单码过期时间,同时作为微信支付截止时间
* @return 支付订单号、二维码内容串和服务端过期时间
*/
@Override
public ThirdOrderCodePayVo precreate(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySetting(setting);
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
ThirdPayOrderEntity payOrder = buildOrderCodePayOrder(dto, setting, expireTime);
Amount amount = new Amount();
amount.setTotal(dto.getAmount());
amount.setCurrency(CURRENCY_CNY);
PrepayRequest request = new PrepayRequest();
request.setSpAppid(wechatConfig.getAppPlatformAppId());
request.setSpMchid(wechatConfig.getMerchantId());
request.setSubMchid(wechatPaySetting.getSubMchid());
request.setDescription(ORDER_CODE_DESCRIPTION);
request.setOutTradeNo(dto.getOutTradeNo());
request.setTimeExpire(formatWechatTime(expireTime));
request.setNotifyUrl(wechatConfig.getBaseCallbackUrl() + wechatConfig.getPayCallbackUrl());
request.setAmount(amount);
payOrder.setPayRep(JSONUtil.toJsonStr(request));
PrepayResponse response;
try {
response = nativePayService.prepay(request);
} catch (ServiceException exception) {
String errorCode = StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN");
saveFailedOrder(payOrder, errorCode, exception.getClass().getSimpleName());
log.error("|{}|创建微信订单码失败,payNo:{},errorCode:{}", TAG, dto.getOutTradeNo(), errorCode);
throw new RuntimeException("创建微信订单码失败,微信错误码:" + errorCode);
} catch (Exception exception) {
saveFailedOrder(payOrder, "SDK_EXCEPTION", exception.getClass().getSimpleName());
log.error("|{}|创建微信订单码异常,payNo:{},exceptionType:{}", TAG, dto.getOutTradeNo(), exception.getClass().getSimpleName());
throw new RuntimeException("创建微信订单码失败,请稍后重试");
}
if (response == null || StrUtil.isBlank(response.getCodeUrl())) {
saveFailedOrder(payOrder, response == null ? "EMPTY_RESPONSE" : "EMPTY_CODE_URL", null);
throw new RuntimeException("创建微信订单码失败,微信未返回二维码");
}
JSONObject responseSnapshot = JSONUtil.createObj().set("qrCode", response.getCodeUrl());
payOrder.setPayRes(responseSnapshot.toString());
thirdPayOrderService.save(payOrder);
ThirdOrderCodePayVo result = new ThirdOrderCodePayVo();
result.setPayNo(dto.getOutTradeNo());
result.setQrCode(response.getCodeUrl());
result.setExpireTime(expireTime);
return result;
}
/**
* 使用订单配置快照中的子商户号查询微信 Native 订单。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的子商户支付配置快照
* @return 微信订单状态;调用异常时返回 {@code null}
*/
@Override
public Transaction query(String payNo, String settingJson) {
try {
MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson);
QueryOrderByOutTradeNoRequest request = new QueryOrderByOutTradeNoRequest();
request.setOutTradeNo(payNo);
request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId());
request.setSubMchid(setting.getSubMchid());
return nativePayService.queryOrderByOutTradeNo(request);
} catch (ServiceException exception) {
log.warn("|{}|查询微信订单码失败,payNo:{},errorCode:{}", TAG, payNo,
StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"));
} catch (Exception exception) {
log.error("|{}|查询微信订单码异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
}
return null;
}
/**
* 使用订单配置快照中的子商户号关闭微信 Native 订单。
*
* @param payNo 第三方支付服务生成的商户订单号
* @param settingJson 创建订单时保存的子商户支付配置快照
* @return SDK 无异常完成关单时返回 {@code true}
*/
@Override
public boolean close(String payNo, String settingJson) {
try {
MchPaySettingEntity.WechatPaySetting setting = parseWechatPaySettingSnapshot(settingJson);
CloseOrderRequest request = new CloseOrderRequest();
request.setOutTradeNo(payNo);
request.setSpMchid(appPlusPayConfig.getWechatConfig().getMerchantId());
request.setSubMchid(setting.getSubMchid());
nativePayService.closeOrder(request);
return true;
} catch (ServiceException exception) {
log.warn("|{}|关闭微信订单码失败,payNo:{},errorCode:{}", TAG, payNo,
StrUtil.blankToDefault(exception.getErrorCode(), "UNKNOWN"));
} catch (Exception exception) {
log.error("|{}|关闭微信订单码异常,payNo:{},exceptionType:{}", TAG, payNo, exception.getClass().getSimpleName());
}
return false;
}
/**
* 构建完整的微信订单码待支付记录,沿用现有订单表字段而不扩展数据库结构。
*
* @param dto 内部支付参数
* @param setting 子商户微信支付配置
* @param expireTime 服务端二维码过期时间
* @return 可直接持久化的待支付订单
*/
private ThirdPayOrderEntity buildOrderCodePayOrder(ThirdAppPlusPayDto dto, MchPaySettingVo setting, LocalDateTime expireTime) {
ThirdPayOrderEntity payOrder = new ThirdPayOrderEntity();
payOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
payOrder.setBizId(dto.getBizId());
payOrder.setBizType(dto.getBizType().getType());
payOrder.setUserId(dto.getUserId());
payOrder.setPrice(dto.getAmount());
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
payOrder.setPayNo(dto.getOutTradeNo());
payOrder.setMchId(dto.getMchId());
payOrder.setMchPaySettingJson(JSONUtil.toJsonStr(setting));
payOrder.setExpireTime(expireTime);
return payOrder;
}
/**
* 将预创建失败原因保存为精简快照,不持久化 SDK 原始响应或敏感配置。
*
* @param payOrder 待保存的内部支付订单
* @param errorCode 微信错误码或本地错误标识
* @param exceptionType 异常类型;无异常对象时可为空
*/
private void saveFailedOrder(ThirdPayOrderEntity payOrder, String errorCode, String exceptionType) {
JSONObject failureSnapshot = JSONUtil.createObj().set("code", errorCode);
if (StrUtil.isNotBlank(exceptionType)) {
failureSnapshot.set("exceptionType", exceptionType);
}
payOrder.setStatus(ThirdPayOrderStatusEnum.PAY_FAILED.getType());
payOrder.setPayRes(failureSnapshot.toString());
thirdPayOrderService.save(payOrder);
}
/**
* 校验并解析当前子商户微信支付配置。
*
* @param setting 当前商户启用的微信支付配置
* @return 包含非空子商户号的微信配置
*/
private MchPaySettingEntity.WechatPaySetting parseWechatPaySetting(MchPaySettingVo setting) {
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
throw new RuntimeException("商户微信支付配置不完整");
}
MchPaySettingEntity.WechatPaySetting wechatPaySetting = JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
if (wechatPaySetting == null || StrUtil.isBlank(wechatPaySetting.getSubMchid())) {
throw new RuntimeException("商户微信子商户号未配置");
}
return wechatPaySetting;
}
/**
* 从支付订单保存的配置快照中解析子商户微信支付配置。
*
* @param settingJson 创建支付订单时保存的商户配置快照
* @return 包含非空子商户号的微信配置
*/
private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) {
if (StrUtil.isBlank(settingJson)) {
throw new RuntimeException("支付订单缺少商户配置快照");
}
MchPaySettingEntity settingSnapshot = JSONUtil.toBean(settingJson, MchPaySettingEntity.class);
if (settingSnapshot == null) {
throw new RuntimeException("支付订单商户配置快照无效");
}
MchPaySettingVo setting = new MchPaySettingVo();
setting.setSettingJson(settingSnapshot.getSettingJson());
return parseWechatPaySetting(setting);
}
/**
* 将本地时间按微信支付要求转换为带时区且精确到秒的 RFC3339 字符串。
*
* @param expireTime 服务端订单码过期时间
* @return 微信支付 {@code time_expire} 参数
*/
private String formatWechatTime(LocalDateTime expireTime) {
return expireTime.withNano(0).atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}
}
@@ -7,14 +7,19 @@ import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.XmlUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.*;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusWechatService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle;
@@ -27,13 +32,13 @@ import com.wechat.pay.java.core.notification.RequestParam;
import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension;
import com.wechat.pay.java.service.partnerpayments.app.model.*;
import com.wechat.pay.java.service.partnerpayments.app.model.Amount;
import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService;
import com.wechat.pay.java.service.profitsharing.ProfitsharingService;
import com.wechat.pay.java.service.refund.RefundService;
import com.wechat.pay.java.service.refund.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -71,6 +76,14 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
private final ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle;
/**
* 聚合会话服务只用于共享回调严格区分 JSAPI 聚合子订单和旧 Native/App 订单。
*
* <p>设为可选注入以保持现有独立单元测试构造方式;生产 Spring 容器会提供该 Bean。</p>
*/
@Autowired(required = false)
private ThirdAggregatePaySessionService aggregatePaySessionService;
private static final ExecutorService executorService = Executors.newFixedThreadPool(10);
/**
@@ -277,62 +290,83 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
}
/**
* 支付的回调通知
* 验签、解密并校验微信支付成功回调,使用未支付条件原子推进内部订单。
*
* @param body 微信支付回调原始请求体
* @param headers 微信支付签名请求头
* @return 已支付的最新内部订单;验签、字段校验或状态推进失败时返回 {@code null}
*/
@Override
public ThirdPayOrderEntity payCallback(String body, Map<String, String> headers) {
Transaction transaction = pasePayCallback(body, headers);
if (ObjectUtil.isEmpty(transaction) || ObjectUtil.isEmpty(transaction.getOutTradeNo())) {
log.error("|{}|payCallback,意外的错误,微信支付回调解析错误", TAG);
log.warn("|{}|微信支付回调解析或验签失败", TAG);
return null;
}
ThirdPayOrderEntity payOrderEntity = thirdPayOrderService.getByPayNo(transaction.getOutTradeNo());
if (ObjectUtil.isEmpty(payOrderEntity)) {
log.error("|{}|payCallback,意外的错误,订单不存在,微信返回body|{}|", TAG, body);
log.warn("|{}|微信支付回调订单不存在,payNo:{}", TAG, transaction.getOutTradeNo());
return null;
}
// 重复的回调
ThirdAggregatePaySessionEntity aggregateSession = aggregatePaySessionService == null
? null : aggregatePaySessionService.getByPayNo(payOrderEntity.getPayNo());
if (ObjectUtil.notEqual(payOrderEntity.getPayPlatformType(), AppPlusPlatformTypeEnum.WECHAT.getType())
|| !validateWechatPayCallback(transaction, payOrderEntity, aggregateSession)) {
return null;
}
if (ObjectUtil.notEqual(Transaction.TradeStateEnum.SUCCESS, transaction.getTradeState())) {
log.warn("|{}|微信支付成功回调状态无效,payNo:{},tradeState:{}", TAG,
transaction.getOutTradeNo(), transaction.getTradeState());
return null;
}
// 微信可能重复通知;已经成功的订单直接返回,由上层继续确认业务回调标识。
if (ObjectUtil.equals(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return payOrderEntity;
}
if (ObjectUtil.notEqual(payOrderEntity.getStatus(), ThirdPayOrderStatusEnum.PAY_UNPAID.getType())) {
log.error("|{}|payCallback,意外的错误,重复的回调,微信返回body|{}|", TAG, body);
return null;
}
boolean paySucess = ObjectUtil.equals(Transaction.TradeStateEnum.SUCCESS, transaction.getTradeState());
// 更新订单状态
ThirdPayOrderEntity updateObj = new ThirdPayOrderEntity();
updateObj.setThirdPayNo(transaction.getTransactionId());
updateObj.setId(payOrderEntity.getId());
updateObj.setPayCallbackRes(JSONUtil.toJsonStr(transaction));
updateObj.setIsBizCallback(YesNoEnum.NO.getCode());
updateObj.setStatus(paySucess ? ThirdPayOrderStatusEnum.PAY_SUCCESS.getType() : ThirdPayOrderStatusEnum.PAY_FAILED.getType());
thirdPayOrderService.updateById(updateObj);
// 聚合 JSAPI 子订单只同步聚合会话,不进入旧业务回调链。
if (aggregateSession == null) {
updateObj.setIsBizCallback(YesNoEnum.NO.getCode());
}
updateObj.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
updateObj.setPayTime(LocalDateTime.now());
boolean stateChanged = thirdPayOrderService.update(updateObj, new LambdaUpdateWrapper<ThirdPayOrderEntity>()
.eq(ThirdPayOrderEntity::getId, payOrderEntity.getId())
.eq(ThirdPayOrderEntity::getStatus, ThirdPayOrderStatusEnum.PAY_UNPAID.getType()));
ThirdPayOrderEntity result = thirdPayOrderService.getById(payOrderEntity.getId());
// 订单支付成功
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(30000);
appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrderEntity.getId()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("|{}|延迟调用orderPaySuccess被中断", TAG, e);
} catch (Exception e) {
log.error("|{}|延迟调用orderPaySuccess异常", TAG, e);
}
}, executorService);
if (result == null || ObjectUtil.notEqual(result.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
return null;
}
boolean orderCodePay = ObjectUtil.equals(payOrderEntity.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
&& payOrderEntity.getExpireTime() != null;
// 订单码不参与原有 App 充值分账生命周期,避免无分账配置的消费机订单触发延迟任务。
if (stateChanged && !orderCodePay) {
schedulePaySuccessLifecycle(payOrderEntity.getId());
}
return result;
}
/**
* 解析支付的回调
* 使用微信 SDK 验证回调签名并解密交易资源。
*
* @param body 微信支付回调原始请求体
* @param headers 微信支付签名请求头
* @return 解密后的交易信息;验签或解密失败时返回 {@code null}
*/
@Override
public Transaction pasePayCallback(String body, Map<String, String> headers) {
String serialNumber = headers.get("Wechatpay-Serial");
String nonce = headers.get("Wechatpay-Nonce");
String signature = headers.get("Wechatpay-Signature");
String signatureType = headers.get("Wechatpay-Signature-Type");
String timestamp = headers.get("Wechatpay-Timestamp");
String serialNumber = getHeaderIgnoreCase(headers, "Wechatpay-Serial");
String nonce = getHeaderIgnoreCase(headers, "Wechatpay-Nonce");
String signature = getHeaderIgnoreCase(headers, "Wechatpay-Signature");
String signatureType = getHeaderIgnoreCase(headers, "Wechatpay-Signature-Type");
String timestamp = getHeaderIgnoreCase(headers, "Wechatpay-Timestamp");
if (!ObjectUtil.isAllNotEmpty(serialNumber, nonce, signature, timestamp)) {
return null;
}
@@ -347,10 +381,172 @@ public class ThirdAppPlusWechatServiceImpl implements ThirdAppPlusWechatService
try {
return notificationParser.parse(requestParam, Transaction.class);
} catch (Exception e) {
log.warn("|{}|微信支付回调验签或解密异常,exceptionType:{}", TAG, e.getClass().getSimpleName());
return null;
}
}
/**
* 校验微信支付回调中的应用、服务商、子商户、金额及订单码交易类型。
*
* @param transaction 微信验签并解密后的交易数据
* @param payOrder 本地支付订单及其创建时配置快照
* @return 所有关键字段与本地订单一致时返回 {@code true}
*/
private boolean validateWechatPayCallback(Transaction transaction, ThirdPayOrderEntity payOrder,
ThirdAggregatePaySessionEntity aggregateSession) {
AppPlusPayConfig.WechatConfig wechatConfig = appPlusPayConfig.getWechatConfig();
if (wechatConfig == null || ObjectUtil.notEqual(transaction.getSpMchid(), wechatConfig.getMerchantId())) {
log.warn("|{}|微信支付回调服务商信息不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
MchPaySettingEntity.WechatPaySetting wechatPaySetting = parseWechatPaySettingSnapshot(payOrder.getMchPaySettingJson());
if (wechatPaySetting == null || ObjectUtil.notEqual(transaction.getSubMchid(), wechatPaySetting.getSubMchid())) {
log.warn("|{}|微信支付回调子商户号不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
if (transaction.getAmount() == null || ObjectUtil.notEqual(transaction.getAmount().getTotal(), payOrder.getPrice())) {
log.warn("|{}|微信支付回调金额不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
boolean aggregateRequest = isAggregatePayRequest(payOrder);
if (aggregateRequest != (aggregateSession != null)) {
log.warn("|{}|微信聚合支付回调会话绑定不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
if (aggregateSession != null) {
String spAppid = getAggregateSpAppid(payOrder);
if (!isMatchingAggregateSession(aggregateSession, payOrder)
|| ObjectUtil.notEqual(aggregateSession.getSelectedPlatform(), AppPlusPlatformTypeEnum.WECHAT.getType())
|| ObjectUtil.notEqual(spAppid, wechatConfig.getGzhAppId())
|| ObjectUtil.notEqual(transaction.getSpAppid(), spAppid)
|| StrUtil.isNotBlank(transaction.getSubAppid())
|| ObjectUtil.notEqual(transaction.getTradeType(), Transaction.TradeTypeEnum.JSAPI)) {
log.warn("|{}|微信聚合JSAPI回调应用或交易类型不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
return true;
}
if (ObjectUtil.notEqual(transaction.getSpAppid(), wechatConfig.getAppPlatformAppId())) {
log.warn("|{}|微信旧支付回调应用不一致,payNo:{}", TAG, payOrder.getPayNo());
return false;
}
boolean orderCodePay = ObjectUtil.equals(payOrder.getBizType(), ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType())
&& payOrder.getExpireTime() != null;
if (orderCodePay && ObjectUtil.notEqual(transaction.getTradeType(), Transaction.TradeTypeEnum.NATIVE)) {
log.warn("|{}|微信订单码回调交易类型不一致,payNo:{},tradeType:{}", TAG,
payOrder.getPayNo(), transaction.getTradeType());
return false;
}
return true;
}
/**
* 校验聚合会话与微信 JSAPI 子订单的业务、商户和金额快照一致。
*
* @param session {@code pay_no} 绑定的聚合支付会话
* @param payOrder 微信回调对应的第三方子订单
* @return 关键业务字段完全一致时返回 {@code true}
*/
private boolean isMatchingAggregateSession(ThirdAggregatePaySessionEntity session, ThirdPayOrderEntity payOrder) {
return ObjectUtil.equals(session.getPayNo(), payOrder.getPayNo())
&& ObjectUtil.equals(session.getBizType(), payOrder.getBizType())
&& ObjectUtil.equals(session.getBizId(), payOrder.getBizId())
&& ObjectUtil.equals(session.getMchId(), payOrder.getMchId())
&& ObjectUtil.equals(session.getPrice(), payOrder.getPrice());
}
/**
* 判断内部订单请求快照是否明确标记为聚合 JSAPI 子订单。
*
* @param payOrder 回调商户订单号对应的内部支付订单
* @return 请求快照同时包含聚合标记和 JSAPI 交易类型时返回 {@code true}
*/
private boolean isAggregatePayRequest(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayRep())) {
return false;
}
try {
cn.hutool.json.JSONObject request = JSONUtil.parseObj(payOrder.getPayRep());
return Boolean.TRUE.equals(request.getBool("aggregatePay"))
&& ObjectUtil.equals(request.getStr("tradeType"), "JSAPI");
} catch (Exception exception) {
return false;
}
}
/**
* 从聚合子订单安全请求快照中读取创建时使用的服务商公众号 sp_appid。
*
* @param payOrder 已确认带聚合 JSAPI 标记的内部支付订单
* @return 创建时的服务商公众号 AppID;快照无效时返回 {@code null}
*/
private String getAggregateSpAppid(ThirdPayOrderEntity payOrder) {
try {
return JSONUtil.parseObj(payOrder.getPayRep()).getStr("spAppid");
} catch (Exception exception) {
return null;
}
}
/**
* 从内部支付订单的商户配置快照解析微信子商户号;快照无效时返回空。
*
* @param settingJson 创建支付订单时保存的商户配置快照
* @return 微信子商户配置;快照为空或解析失败时返回 {@code null}
*/
private MchPaySettingEntity.WechatPaySetting parseWechatPaySettingSnapshot(String settingJson) {
if (StrUtil.isBlank(settingJson)) {
return null;
}
try {
MchPaySettingEntity setting = JSONUtil.toBean(settingJson, MchPaySettingEntity.class);
if (setting == null || StrUtil.isBlank(setting.getSettingJson())) {
return null;
}
return JSONUtil.toBean(setting.getSettingJson(), MchPaySettingEntity.WechatPaySetting.class);
} catch (Exception exception) {
return null;
}
}
/**
* 异步延迟触发现有微信分账生命周期,只在支付状态首次推进成功时调度一次。
*
* @param payOrderId 首次确认支付成功的内部订单主键
*/
private void schedulePaySuccessLifecycle(Long payOrderId) {
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(30000);
appPlusWechatFzLifecycle.orderPaySuccess(thirdPayOrderService.getById(payOrderId));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
log.error("|{}|延迟调用orderPaySuccess被中断", TAG, exception);
} catch (Exception exception) {
log.error("|{}|延迟调用orderPaySuccess异常", TAG, exception);
}
}, executorService);
}
/**
* 以大小写无关方式读取微信回调请求头,兼容 Servlet 容器对 Header 名称的规范化。
*
* @param headers 原始回调请求头
* @param headerName 微信支付文档定义的请求头名称
* @return 请求头值;不存在时返回 {@code null}
*/
private String getHeaderIgnoreCase(Map<String, String> headers, String headerName) {
if (headers == null || StrUtil.isBlank(headerName)) {
return null;
}
return headers.entrySet().stream()
.filter(entry -> headerName.equalsIgnoreCase(entry.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
/**
* 退款回调
*/
@@ -0,0 +1,207 @@
package com.bsd.pjcloud.thirdparty.service.impl.pay;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.mapper.pay.ThirdAggregatePaySessionMapper;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.List;
/**
* 聚合订单码支付会话持久化服务实现。
*/
@Service
@Validated
public class ThirdAggregatePaySessionServiceImpl extends ServiceImpl<ThirdAggregatePaySessionMapper, ThirdAggregatePaySessionEntity>
implements ThirdAggregatePaySessionService {
/**
* 按聚合会话编号读取当前租户有效记录。
*
* @param aggregateNo 聚合支付会话编号
* @return 匹配会话;参数为空或记录不存在时返回 {@code null}
*/
@Override
public ThirdAggregatePaySessionEntity getByAggregateNo(String aggregateNo) {
if (StrUtil.isBlank(aggregateNo)) {
return null;
}
return getOne(new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getAggregateNo, aggregateNo));
}
/**
* 按访问令牌摘要读取当前租户有效记录。
*
* @param accessTokenHash 原始随机令牌的 SHA-256 摘要
* @return 匹配会话;参数为空或记录不存在时返回 {@code null}
*/
@Override
public ThirdAggregatePaySessionEntity getByAccessTokenHash(String accessTokenHash) {
if (StrUtil.isBlank(accessTokenHash)) {
return null;
}
return getOne(new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getAccessTokenHash, accessTokenHash));
}
/**
* 按第三方子订单号读取当前租户绑定会话。
*
* @param payNo {@code third_pay_order.pay_no}
* @return 绑定会话;参数为空或不是聚合订单时返回 {@code null}
*/
@Override
public ThirdAggregatePaySessionEntity getByPayNo(String payNo) {
if (StrUtil.isBlank(payNo)) {
return null;
}
return getOne(new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getPayNo, payNo));
}
/**
* 查询同业务最新聚合会话,供创建幂等和跨二维码互斥使用。
*
* @param bizType 支付业务类型
* @param bizId 调用方业务订单唯一标识
* @return 按创建时间倒序的第一条记录;参数无效或不存在时返回 {@code null}
*/
@Override
public ThirdAggregatePaySessionEntity getLatestByBiz(ThirdPayOrderBizTypeEnum bizType, String bizId) {
if (bizType == null || StrUtil.isBlank(bizId)) {
return null;
}
return getOne(new LambdaQueryWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getBizType, bizType.getType())
.eq(ThirdAggregatePaySessionEntity::getBizId, bizId)
.orderByDesc(ThirdAggregatePaySessionEntity::getCreateTime)
.last("limit 1"));
}
/**
* 使用等待扫码状态作为 CAS 条件锁定平台和付款人,防止支付宝、微信同时创建可支付订单。
*
* @param sessionId 聚合会话主键
* @param platform 首个扫码选定的平台
* @param payNo 预分配的第三方子订单号
* @param payerIdentityHash 付款人身份摘要
* @return 仅一个竞争请求会返回 {@code true}
*/
@Override
public boolean selectPlatform(Long sessionId, AppPlusPlatformTypeEnum platform, String payNo, String payerIdentityHash) {
if (sessionId == null || platform == null || StrUtil.hasBlank(payNo, payerIdentityHash)) {
return false;
}
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType());
update.setSelectedPlatform(platform.getType());
update.setPayNo(payNo);
update.setPayerIdentityHash(payerIdentityHash);
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType())
.isNull(ThirdAggregatePaySessionEntity::getSelectedPlatform)
.isNull(ThirdAggregatePaySessionEntity::getPayNo));
}
/**
* 在第三方子订单保存成功后把会话从创建中推进为支付中。
*
* @param sessionId 聚合会话主键
* @param payNo 已成功保存的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
public boolean markPaying(Long sessionId, String payNo) {
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(ThirdAggregatePaySessionStatusEnum.PAYING.getType());
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.eq(ThirdAggregatePaySessionEntity::getPayNo, payNo)
.eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType()));
}
/**
* 将明确失败的平台创建过程推进为创建失败终态。
*
* @param sessionId 聚合会话主键
* @param payNo 创建失败但已预分配的第三方子订单号
* @return CAS 更新成功时返回 {@code true}
*/
@Override
public boolean markCreateFailed(Long sessionId, String payNo) {
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(ThirdAggregatePaySessionStatusEnum.CREATE_FAILED.getType());
update.setResolvedTime(LocalDateTime.now());
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.eq(ThirdAggregatePaySessionEntity::getPayNo, payNo)
.eq(ThirdAggregatePaySessionEntity::getStatus, ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType()));
}
/**
* 将尚未进入终态的会话推进为成功、关闭或过期状态。
*
* @param sessionId 聚合会话主键
* @param status 目标终态
* @return CAS 更新成功时返回 {@code true}
*/
@Override
public boolean markTerminal(Long sessionId, ThirdAggregatePaySessionStatusEnum status) {
if (sessionId == null || status == null || !List.of(
ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS,
ThirdAggregatePaySessionStatusEnum.PAY_CLOSED,
ThirdAggregatePaySessionStatusEnum.EXPIRED).contains(status)) {
return false;
}
ThirdAggregatePaySessionEntity update = new ThirdAggregatePaySessionEntity();
update.setStatus(status.getType());
update.setResolvedTime(LocalDateTime.now());
return update(update, new LambdaUpdateWrapper<ThirdAggregatePaySessionEntity>()
.eq(ThirdAggregatePaySessionEntity::getId, sessionId)
.in(ThirdAggregatePaySessionEntity::getStatus,
ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(),
ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType(),
ThirdAggregatePaySessionStatusEnum.PAYING.getType()));
}
/**
* 根据第三方支付订单终态更新绑定会话,重复回调和定时查询不会覆盖既有终态。
*
* @param payOrder 最新第三方支付子订单
*/
@Override
public void syncFromPayOrder(ThirdPayOrderEntity payOrder) {
if (payOrder == null || StrUtil.isBlank(payOrder.getPayNo())) {
return;
}
ThirdAggregatePaySessionEntity session = getByPayNo(payOrder.getPayNo());
if (session == null) {
return;
}
if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_SUCCESS.getType())) {
markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_SUCCESS);
} else if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_CLOSED.getType())) {
markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
} else if (ObjectUtil.equals(payOrder.getStatus(), ThirdPayOrderStatusEnum.PAY_FAILED.getType())) {
if (ObjectUtil.equals(session.getStatus(), ThirdAggregatePaySessionStatusEnum.PLATFORM_CREATING.getType())) {
markCreateFailed(session.getId(), payOrder.getPayNo());
} else {
markTerminal(session.getId(), ThirdAggregatePaySessionStatusEnum.PAY_CLOSED);
}
}
}
}
@@ -0,0 +1,59 @@
package com.bsd.pjcloud.thirdparty.job;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ThirdAppPlusPayJobTest {
@Mock
private ThirdPayOrderService thirdPayOrderService;
@Mock
private ThirdAppPlusService thirdAppPlusService;
@InjectMocks
private ThirdAppPlusPayJob job;
@AfterEach
void tearDown() {
TenantContextHolder.setTenantId(null);
}
@Test
void handleOrderCodeOrdersShouldRestoreEachOrderTenant() {
ThirdPayOrderEntity order = new ThirdPayOrderEntity();
order.setId(99L);
order.setTenantId(66L);
order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
order.setExpireTime(LocalDateTime.now().plusMinutes(1));
when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of(order));
when(thirdPayOrderService.getById(order.getId())).thenAnswer(invocation -> {
Assertions.assertEquals(66L, TenantContextHolder.getTenantId());
return order;
});
job.handleOrderCodeOrders();
verify(thirdAppPlusService).queryPayResult(order);
Assertions.assertNull(TenantContextHolder.getTenantId());
}
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.thirdparty.service;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierPageData;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.util.Map;
/**
* 聚合收银 HTML 渲染器的支付宝 JSBridge 调起脚本测试。
*/
class ThirdAggregateCashierHtmlRendererTest {
/**
* 被测固定 HTML 渲染器,不依赖 Spring 容器。
*/
private final ThirdAggregateCashierHtmlRenderer renderer = new ThirdAggregateCashierHtmlRenderer();
/**
* 验证支付宝收银页严格提取 {@code tradeNO},等待桥接注入并保留真实点击重试入口。
*/
@Test
void renderAlipayPageShouldInvokeBridgeWithTradeNoAndProvideRetry() {
String html = renderer.renderPayPage(alipayPageData(), "test-nonce");
Assertions.assertTrue(html.contains("nonce=\"test-nonce\""));
Assertions.assertTrue(html.contains("id=\"retryButton\""));
Assertions.assertTrue(html.contains("window.AlipayJSBridge"));
Assertions.assertTrue(html.contains("bridge.call('tradePay',{tradeNO:tradeNO},handleAlipayResult)"));
Assertions.assertTrue(html.contains("document.addEventListener('AlipayJSBridgeReady',invokePay,false)"));
Assertions.assertTrue(html.contains("const statusUrl=\"https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket\";"));
Assertions.assertFalse(html.contains("AlipayJSBridge.call('tradePay',payParams"));
}
/**
* 验证支付宝官方成功、处理中、网络异常、取消和失败结果码均有明确分支,且不会直接认定服务端支付成功。
*/
@Test
void renderAlipayPageShouldHandleOfficialResultCodesAndKeepPolling() {
String html = renderer.renderPayPage(alipayPageData(), "test-nonce");
Assertions.assertTrue(html.contains("resultCode==='9000'"));
Assertions.assertTrue(html.contains("resultCode==='8000'||resultCode==='6004'"));
Assertions.assertTrue(html.contains("resultCode==='6001'"));
Assertions.assertTrue(html.contains("resultCode==='6002'"));
Assertions.assertTrue(html.contains("resultCode==='4000'"));
Assertions.assertTrue(html.contains("setTimeout(startPolling,1000)"));
}
/**
* 构建只含脱敏测试交易号和本地状态地址的支付宝收银页数据。
*
* @return 支付宝聚合收银页测试数据
*/
private ThirdAggregateCashierPageData alipayPageData() {
ThirdAggregateCashierLaunch launch = new ThirdAggregateCashierLaunch(AppPlusPlatformTypeEnum.ALI_PAY,
"test-pay-no", Map.of("tradeNO", "test-alipay-trade-no"));
return new ThirdAggregateCashierPageData(launch,
"https://pay.example.com/third/aggregate-order-pay/status/tenant-code/test-ticket");
}
}
@@ -0,0 +1,217 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayTradeCreateModel;
import com.alipay.api.request.AlipaySystemOauthTokenRequest;
import com.alipay.api.request.AlipayTradeCreateRequest;
import com.alipay.api.response.AlipaySystemOauthTokenResponse;
import com.alipay.api.response.AlipayTradeCreateResponse;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateAlipayOauthIdentity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 支付宝聚合 OAuth 与服务商 JSAPI 子订单参数测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAggregateAliPayServiceImplTest {
/**
* 支付宝 SDK 客户端 Mock,用于捕获 OAuth 和交易创建请求。
*/
@Mock
private AlipayClient alipayClient;
/**
* 第三方支付订单服务 Mock,用于验证子订单快照和保存失败补偿。
*/
@Mock
private ThirdPayOrderService thirdPayOrderService;
/**
* 旧支付宝服务 Mock,用于验证聚合子订单保存失败后的最佳努力撤销。
*/
@Mock
private ThirdAppPlusAliPayService thirdAppPlusAliPayService;
/**
* 被测支付宝聚合服务,通过子类固定返回 Mock SDK 客户端。
*/
private ThirdAggregateAliPayServiceImpl service;
/**
* 当前测试使用的聚合子订单上下文,金额单位为人民币分。
*/
private ThirdAggregatePayCreateContext context;
/**
* 当前测试使用的支付宝商户授权配置快照。
*/
private MchPaySettingVo paySetting;
/**
* 初始化应用配置、支付上下文和商户授权 Token。
*/
@BeforeEach
void setUp() {
AppPlusPayConfig appPlusPayConfig = new AppPlusPayConfig();
AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig();
aliPayConfig.setAppId("2026000000000001");
aliPayConfig.setAppPrivateKey("test-private-key");
aliPayConfig.setAlipayPublicKey("test-public-key");
aliPayConfig.setBaseCallbackUrl("https://pay.example.com");
aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
appPlusPayConfig.setAliPayConfig(aliPayConfig);
service = new TestableThirdAggregateAliPayService(appPlusPayConfig, thirdPayOrderService,
thirdAppPlusAliPayService, alipayClient);
context = new ThirdAggregatePayCreateContext();
context.setPayNo("AGP202608050001");
context.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
context.setBizId("order-1001");
context.setUserId(-1L);
context.setMchId(88L);
context.setAmount(1234);
context.setExpireTime(LocalDateTime.now().plusMinutes(2));
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setAppAuthToken("merchant-auth-token");
paySetting = new MchPaySettingVo();
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
}
/**
* 验证静默 OAuth 授权码只换取稳定付款人身份,不持久化访问令牌。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void exchangeOauthCodeShouldReturnOpenId() throws Exception {
AlipaySystemOauthTokenResponse response = new AlipaySystemOauthTokenResponse();
response.setCode("10000");
response.setOpenId("alipay-open-id");
when(alipayClient.execute(any(AlipaySystemOauthTokenRequest.class))).thenReturn(response);
ThirdAggregateAlipayOauthIdentity identity = service.exchangeOauthCode("one-time-auth-code");
Assertions.assertEquals("alipay-open-id", identity.getStableIdentity());
ArgumentCaptor<AlipaySystemOauthTokenRequest> requestCaptor =
ArgumentCaptor.forClass(AlipaySystemOauthTokenRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
Assertions.assertEquals("authorization_code", requestCaptor.getValue().getGrantType());
Assertions.assertEquals("one-time-auth-code", requestCaptor.getValue().getCode());
}
/**
* 验证 trade.create 使用 JSAPI_PAY、分转元、付款人 open_id、授权 Token 和统一过期时间。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldSetJsapiParametersAndSaveUnpaidOrder() throws Exception {
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000002");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
ThirdAggregateCashierLaunch result = service.createPay(context, paySetting,
new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null));
ArgumentCaptor<AlipayTradeCreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradeCreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradeCreateRequest request = requestCaptor.getValue();
AlipayTradeCreateModel model = (AlipayTradeCreateModel) request.getBizModel();
Assertions.assertEquals("JSAPI_PAY", model.getProductCode());
Assertions.assertEquals("12.34", model.getTotalAmount());
Assertions.assertEquals("alipay-open-id", model.getBuyerOpenId());
Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"));
Assertions.assertEquals("2026000000000002", result.getPayParams().get("tradeNO"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), orderCaptor.getValue().getStatus());
Assertions.assertEquals(context.getExpireTime(), orderCaptor.getValue().getExpireTime());
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("merchant-auth-token"));
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("alipay-open-id"));
}
/**
* 验证平台下单成功但本地子订单保存失败时立即发起撤销,避免形成孤立可支付订单。
*
* @throws Exception Mock 支付宝客户端声明的受检异常
*/
@Test
void createPayShouldCancelWhenOrderPersistenceFails() throws Exception {
AlipayTradeCreateResponse response = new AlipayTradeCreateResponse();
response.setCode("10000");
response.setTradeNo("2026000000000002");
when(alipayClient.execute(any(AlipayTradeCreateRequest.class))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false);
Assertions.assertThrows(RuntimeException.class, () -> service.createPay(context, paySetting,
new ThirdAggregateAlipayOauthIdentity("alipay-open-id", null)));
verify(thirdAppPlusAliPayService).cancel(eq(context.getPayNo()), any(String.class));
}
/**
* 固定使用 Mock 支付宝客户端的可测试服务实现。
*/
private static final class TestableThirdAggregateAliPayService extends ThirdAggregateAliPayServiceImpl {
/**
* 当前测试方法共享的支付宝 SDK 客户端 Mock。
*/
private final AlipayClient testAlipayClient;
/**
* 创建可替换 SDK 客户端的支付宝聚合服务。
*
* @param appPlusPayConfig 测试应用配置
* @param thirdPayOrderService 支付订单服务 Mock
* @param thirdAppPlusAliPayService 旧支付宝查询撤销服务 Mock
* @param testAlipayClient 支付宝 SDK 客户端 Mock
*/
private TestableThirdAggregateAliPayService(AppPlusPayConfig appPlusPayConfig,
ThirdPayOrderService thirdPayOrderService,
ThirdAppPlusAliPayService thirdAppPlusAliPayService,
AlipayClient testAlipayClient) {
super(appPlusPayConfig, thirdPayOrderService, thirdAppPlusAliPayService);
this.testAlipayClient = testAlipayClient;
}
/**
* 返回测试注入的支付宝 SDK 客户端。
*
* @return 支付宝 SDK 客户端 Mock
*/
@Override
protected AlipayClient getAlipayClient() {
return testAlipayClient;
}
}
}
@@ -0,0 +1,285 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.admin.api.entity.SysTenant;
import com.bsd.pjcloud.admin.api.feign.RemoteTenantService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdAggregatePaySessionStatusEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdAggregateOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregateWechatPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdAggregateOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.jasypt.encryption.StringEncryptor;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 聚合订单码创建、复用、配置校验和二维码隔离测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAggregateOrderPayServiceImplTest {
/**
* 聚合会话持久化服务 Mock。
*/
@Mock
private ThirdAggregatePaySessionService aggregatePaySessionService;
/**
* 支付宝聚合平台服务 Mock。
*/
@Mock
private ThirdAggregateAliPayService aggregateAliPayService;
/**
* 微信聚合平台服务 Mock。
*/
@Mock
private ThirdAggregateWechatPayService aggregateWechatPayService;
/**
* 实际第三方子订单服务 Mock。
*/
@Mock
private ThirdPayOrderService thirdPayOrderService;
/**
* 钱包商户远程服务 Mock。
*/
@Mock
private RemoteMchService remoteMchService;
/**
* 租户唯一编码远程服务 Mock。
*/
@Mock
private RemoteTenantService remoteTenantService;
/**
* Redis 模板 Mock,用于验证不手工拼租户ID的共享业务锁。
*/
@Mock
private StringRedisTemplate stringRedisTemplate;
/**
* Redis 值操作 Mock,用于模拟创建锁和短期票据。
*/
@Mock
private ValueOperations<String, String> valueOperations;
/**
* 项目统一字符串加密器 Mock,用于验证访问令牌只以摘要和密文落库。
*/
@Mock
private StringEncryptor stringEncryptor;
/**
* 被测聚合支付编排服务。
*/
private ThirdAggregateOrderPayServiceImpl service;
/**
* 当前测试使用的合法聚合创建请求。
*/
private ThirdAggregateOrderCodePayDto request;
/**
* 当前租户下同时启用支付宝和微信支付的收款商户。
*/
private MchManageVo merchant;
/**
* 初始化双平台应用配置、租户编码、Redis 锁和商户支付配置。
*/
@BeforeEach
void setUp() {
AppPlusPayConfig appPlusPayConfig = completeApplicationConfig();
service = new ThirdAggregateOrderPayServiceImpl(aggregatePaySessionService, aggregateAliPayService,
aggregateWechatPayService, thirdPayOrderService, remoteMchService,
remoteTenantService, appPlusPayConfig, stringRedisTemplate, stringEncryptor);
when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
when(valueOperations.setIfAbsent(anyString(), anyString(), any(Duration.class))).thenReturn(true);
SysTenant tenant = new SysTenant();
tenant.setId(1L);
tenant.setCode("tenant-code");
lenient().when(remoteTenantService.list()).thenReturn(R.ok(List.of(tenant)));
request = new ThirdAggregateOrderCodePayDto();
request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
request.setBizId("order-1001");
request.setMchId(88L);
request.setAmount(1234);
merchant = completeMerchant();
}
/**
* 验证新会话同时校验双平台配置,并只返回独立 aggregate-order-pay HTTPS 地址。
*/
@Test
void createShouldPersistHashedTokenAndReturnAggregateQrCode() {
when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null);
when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of());
when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant));
when(stringEncryptor.encrypt(anyString())).thenReturn("encrypted-access-token");
when(aggregatePaySessionService.save(any(ThirdAggregatePaySessionEntity.class))).thenReturn(true);
ThirdAggregateOrderCodePayVo result = service.create(request);
Assertions.assertEquals("AGGREGATE_ORDER_PAY", result.getQrCodeType());
Assertions.assertTrue(result.getQrCode().startsWith(
"https://pay.example.com/third/aggregate-order-pay/cashier/tenant-code/"));
Assertions.assertFalse(result.getQrCode().contains("action=harQrCodeScanPay"));
ArgumentCaptor<ThirdAggregatePaySessionEntity> sessionCaptor =
ArgumentCaptor.forClass(ThirdAggregatePaySessionEntity.class);
verify(aggregatePaySessionService).save(sessionCaptor.capture());
ThirdAggregatePaySessionEntity savedSession = sessionCaptor.getValue();
Assertions.assertEquals(64, savedSession.getAccessTokenHash().length());
Assertions.assertEquals("encrypted-access-token", savedSession.getAccessTokenCiphertext());
Assertions.assertEquals(-1L, savedSession.getUserId());
Assertions.assertEquals(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType(), savedSession.getStatus());
Assertions.assertNull(savedSession.getSelectedPlatform());
Assertions.assertNull(savedSession.getPayNo());
}
/**
* 验证同业务未过期会话复用原访问令牌,不再次读取商户或创建数据库记录。
*/
@Test
void createShouldReuseUnexpiredSession() {
ThirdAggregatePaySessionEntity existing = reusableSession();
when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(existing);
when(stringEncryptor.decrypt(existing.getAccessTokenCiphertext())).thenReturn("restored-access-token");
ThirdAggregateOrderCodePayVo result = service.create(request);
Assertions.assertEquals(existing.getAggregateNo(), result.getAggregateNo());
Assertions.assertTrue(result.getQrCode().endsWith("/restored-access-token"));
verify(remoteMchService, never()).getById(any());
verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class));
}
/**
* 验证缺少任一平台商户配置时不创建聚合会话,避免生成只能被单平台使用的伪聚合码。
*/
@Test
void createShouldRejectMerchantWithoutWechatPaySetting() {
merchant.setPaySettingList(List.of(merchant.getPaySettingList().get(0)));
when(aggregatePaySessionService.getLatestByBiz(request.getBizType(), request.getBizId())).thenReturn(null);
when(thirdPayOrderService.list(any(Wrapper.class))).thenReturn(List.of());
when(remoteMchService.getById(request.getMchId())).thenReturn(R.ok(merchant));
Assertions.assertThrows(RuntimeException.class, () -> service.create(request));
verify(aggregatePaySessionService, never()).save(any(ThirdAggregatePaySessionEntity.class));
}
/**
* 构建支付宝和微信全局应用配置,公网地址固定使用 HTTPS。
*
* @return 满足聚合支付创建前置校验的配置
*/
private AppPlusPayConfig completeApplicationConfig() {
AppPlusPayConfig config = new AppPlusPayConfig();
AppPlusPayConfig.AliPayConfig ali = new AppPlusPayConfig.AliPayConfig();
ali.setAppId("2026000000000001");
ali.setAppPrivateKey("test-private-key");
ali.setAlipayPublicKey("test-public-key");
ali.setBaseCallbackUrl("https://pay.example.com");
ali.setPayCallbackUrl("/third-app-pay/app/pay-callback");
config.setAliPayConfig(ali);
AppPlusPayConfig.WechatConfig wechat = new AppPlusPayConfig.WechatConfig();
wechat.setGzhAppId("wx-service-jsapi-appid");
wechat.setGzhAppSecret("service-public-account-secret");
wechat.setMerchantId("1900000001");
wechat.setMchPrivatePath("/tmp/test-private-key.pem");
wechat.setMerchantSerialNumber("test-serial-number");
wechat.setApiV3Key("test-api-v3-key");
wechat.setBaseCallbackUrl("https://pay.example.com");
wechat.setPayCallbackUrl("/third-app-pay/app/pay-callback");
config.setWechatConfig(wechat);
return config;
}
/**
* 构建同时启用支付宝 app_auth_token 和微信 sub_mchid 的商户。
*
* @return 当前租户下的完整收款商户
*/
private MchManageVo completeMerchant() {
MchPaySettingEntity.AliPaySetting aliDetail = new MchPaySettingEntity.AliPaySetting();
aliDetail.setAppAuthToken("merchant-auth-token");
MchPaySettingVo ali = new MchPaySettingVo();
ali.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
ali.setEnable(YesNoEnum.YES.getCode());
ali.setSettingJson(JSONUtil.toJsonStr(aliDetail));
MchPaySettingEntity.WechatPaySetting wechatDetail = new MchPaySettingEntity.WechatPaySetting();
wechatDetail.setSubMchid("1900000109");
MchPaySettingVo wechat = new MchPaySettingVo();
wechat.setPlatformType(AppPlusPlatformPayType.WECHAT.getType());
wechat.setEnable(YesNoEnum.YES.getCode());
wechat.setSettingJson(JSONUtil.toJsonStr(wechatDetail));
MchManageVo result = new MchManageVo();
result.setId(88L);
result.setTenantId(1L);
result.setPaySettingList(List.of(ali, wechat));
return result;
}
/**
* 构建尚未扫码且未过期的可复用聚合会话。
*
* @return 与默认请求商户、金额和业务标识一致的会话
*/
private ThirdAggregatePaySessionEntity reusableSession() {
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setId(99L);
session.setAggregateNo("AGG202608050001");
session.setAccessTokenCiphertext("encrypted-access-token");
session.setBizType(request.getBizType().getType());
session.setBizId(request.getBizId());
session.setMchId(request.getMchId());
session.setPrice(request.getAmount());
session.setStatus(ThirdAggregatePaySessionStatusEnum.WAIT_SCAN.getType());
session.setExpireTime(LocalDateTime.now().plusMinutes(1));
session.setTenantId(1L);
return session;
}
}
@@ -0,0 +1,162 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregateCashierLaunch;
import com.bsd.pjcloud.thirdparty.model.pay.ThirdAggregatePayCreateContext;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.service.partnerpayments.jsapi.JsapiServiceExtension;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.jsapi.model.PrepayWithRequestPaymentResponse;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 微信支付服务商聚合 JSAPI 子订单参数和失败补偿测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAggregateWechatPayServiceImplTest {
/**
* 微信服务商 JSAPI SDK Mock,用于捕获预支付和关单请求。
*/
@Mock
private JsapiServiceExtension jsapiServiceExtension;
/**
* 第三方支付订单服务 Mock,用于验证保存状态和保存失败补偿。
*/
@Mock
private ThirdPayOrderService thirdPayOrderService;
/**
* 被测微信聚合支付服务。
*/
private ThirdAggregateWechatPayServiceImpl service;
/**
* 聚合子订单上下文,金额单位为人民币分。
*/
private ThirdAggregatePayCreateContext context;
/**
* 微信支付子商户号配置快照。
*/
private MchPaySettingVo paySetting;
/**
* 初始化服务商统一公众号、子商户号和支付上下文。
*/
@BeforeEach
void setUp() {
AppPlusPayConfig appPlusPayConfig = new AppPlusPayConfig();
AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig();
wechatConfig.setGzhAppId("wx-service-jsapi-appid");
wechatConfig.setMerchantId("1900000001");
wechatConfig.setBaseCallbackUrl("https://pay.example.com");
wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
appPlusPayConfig.setWechatConfig(wechatConfig);
service = new ThirdAggregateWechatPayServiceImpl(jsapiServiceExtension, appPlusPayConfig, thirdPayOrderService);
context = new ThirdAggregatePayCreateContext();
context.setPayNo("AGP202608050002");
context.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
context.setBizId("order-1002");
context.setUserId(-1L);
context.setMchId(88L);
context.setAmount(1234);
context.setExpireTime(LocalDateTime.now().plusMinutes(2));
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("1900000109");
paySetting = new MchPaySettingVo();
paySetting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
}
/**
* 验证服务商 JSAPI 下单使用统一 sp_appid、sp_openid,并且不传逐商户 sub_appid。
*/
@Test
void createPayShouldSetPartnerJsapiParameters() {
PrepayWithRequestPaymentResponse response = completeResponse();
when(jsapiServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class),
eq("wx-service-jsapi-appid"))).thenReturn(response);
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(true);
ThirdAggregateCashierLaunch result = service.createPay(context, paySetting, "sp-open-id");
ArgumentCaptor<PrepayRequest> requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class);
verify(jsapiServiceExtension).prepayWithRequestPayment(requestCaptor.capture(),
eq("wx-service-jsapi-appid"));
PrepayRequest request = requestCaptor.getValue();
Assertions.assertEquals("wx-service-jsapi-appid", request.getSpAppid());
Assertions.assertEquals("1900000001", request.getSpMchid());
Assertions.assertNull(request.getSubAppid());
Assertions.assertEquals("1900000109", request.getSubMchid());
Assertions.assertEquals("sp-open-id", request.getPayer().getSpOpenid());
Assertions.assertNull(request.getPayer().getSubOpenid());
Assertions.assertEquals(1234, request.getAmount().getTotal());
Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl());
Assertions.assertEquals(response.getPaySign(), result.getPayParams().get("paySign"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), orderCaptor.getValue().getStatus());
Assertions.assertFalse(orderCaptor.getValue().getPayRep().contains("sp-open-id"));
Assertions.assertTrue(orderCaptor.getValue().getPayRep().contains("\"spAppid\":\"wx-service-jsapi-appid\""));
Assertions.assertTrue(orderCaptor.getValue().getPayRep().contains("\"tradeType\":\"JSAPI\""));
}
/**
* 验证平台预支付成功但本地保存失败时立即使用原子订单号和子商户号发起关单。
*/
@Test
void createPayShouldCloseWhenOrderPersistenceFails() {
when(jsapiServiceExtension.prepayWithRequestPayment(any(PrepayRequest.class),
eq("wx-service-jsapi-appid"))).thenReturn(completeResponse());
when(thirdPayOrderService.save(any(ThirdPayOrderEntity.class))).thenReturn(false);
Assertions.assertThrows(RuntimeException.class,
() -> service.createPay(context, paySetting, "sp-open-id"));
ArgumentCaptor<CloseOrderRequest> closeCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class);
verify(jsapiServiceExtension).closeOrder(closeCaptor.capture());
Assertions.assertEquals(context.getPayNo(), closeCaptor.getValue().getOutTradeNo());
Assertions.assertEquals("1900000001", closeCaptor.getValue().getSpMchid());
Assertions.assertEquals("1900000109", closeCaptor.getValue().getSubMchid());
}
/**
* 构建字段完整的微信 JSBridge 预支付响应。
*
* @return 可通过服务完整性校验的响应
*/
private PrepayWithRequestPaymentResponse completeResponse() {
PrepayWithRequestPaymentResponse response = new PrepayWithRequestPaymentResponse();
response.setAppId("wx-service-jsapi-appid");
response.setTimeStamp("1785912000");
response.setNonceStr("nonce");
response.setPackageVal("prepay_id=wx-prepay-id");
response.setSignType("RSA");
response.setPaySign("pay-sign");
return response;
}
}
@@ -0,0 +1,314 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.json.JSONUtil;
import com.alipay.api.AlipayClient;
import com.alipay.api.domain.AlipayTradePrecreateModel;
import com.alipay.api.request.AlipayTradePrecreateRequest;
import com.alipay.api.response.AlipayTradePrecreateResponse;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdAppPlusAliPayService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ThirdAppPlusAliPayServiceImplTest {
@Mock
private ThirdPayOrderService thirdPayOrderService;
@Mock
private AlipayClient alipayClient;
/**
* 聚合会话服务 Mock,用于校验支付宝 JSAPI 回调必须存在严格 pay_no 绑定。
*/
@Mock
private ThirdAggregatePaySessionService aggregatePaySessionService;
private AppPlusPayConfig appPlusPayConfig;
private MchPaySettingVo paySetting;
private ThirdAppPlusPayDto payDto;
private TestableThirdAppPlusAliPayService service;
@BeforeEach
void setUp() {
appPlusPayConfig = new AppPlusPayConfig();
AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig();
aliPayConfig.setAppId("2026000000000001");
aliPayConfig.setAppPrivateKey("test-private-key");
aliPayConfig.setAlipayPublicKey("test-public-key");
aliPayConfig.setBaseCallbackUrl("https://pay.example.com");
aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
appPlusPayConfig.setAliPayConfig(aliPayConfig);
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setAppAuthToken("merchant-auth-token");
paySetting = new MchPaySettingVo();
paySetting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
paySetting.setEnable(YesNoEnum.YES.getCode());
paySetting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
payDto = new ThirdAppPlusPayDto();
payDto.setOutTradeNo("202608051234560001");
payDto.setBizId("order-1001");
payDto.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
payDto.setUserId(-1L);
payDto.setAmount(1234);
payDto.setMchId(88L);
service = new TestableThirdAppPlusAliPayService(appPlusPayConfig, thirdPayOrderService, alipayClient);
ReflectionTestUtils.setField(service, "aggregatePaySessionService", aggregatePaySessionService);
}
@Test
void precreateShouldSetRequiredParametersAndSaveUnpaidOrder() throws Exception {
AlipayTradePrecreateResponse response = successPrecreateResponse("https://qr.alipay.com/test-content");
when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(response);
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(2);
ThirdOrderCodePayVo result = service.precreate(payDto, paySetting, expireTime);
ArgumentCaptor<AlipayTradePrecreateRequest> requestCaptor = ArgumentCaptor.forClass(AlipayTradePrecreateRequest.class);
verify(alipayClient).execute(requestCaptor.capture());
AlipayTradePrecreateRequest request = requestCaptor.getValue();
AlipayTradePrecreateModel model = (AlipayTradePrecreateModel) request.getBizModel();
Assertions.assertEquals("QR_CODE_OFFLINE", model.getProductCode());
Assertions.assertEquals("12.34", model.getTotalAmount());
Assertions.assertEquals("资安信消费支付", model.getSubject());
Assertions.assertEquals(LocalDateTimeUtil.format(expireTime, DatePattern.NORM_DATETIME_PATTERN), model.getTimeExpire());
Assertions.assertEquals("merchant-auth-token", request.getTextParams().get("app_auth_token"));
Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl());
Assertions.assertEquals(response.getQrCode(), result.getQrCode());
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
ThirdPayOrderEntity savedOrder = orderCaptor.getValue();
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus());
Assertions.assertEquals(1234, savedOrder.getPrice());
Assertions.assertEquals(expireTime, savedOrder.getExpireTime());
Assertions.assertNull(savedOrder.getPayTime());
Assertions.assertTrue(savedOrder.getPayRes().contains("qrCode"));
}
@Test
void precreateShouldSaveFailedOrderWhenAlipayRejectsRequest() throws Exception {
AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse();
response.setCode("40004");
response.setSubCode("ACQ.INVALID_PARAMETER");
when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(response);
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2)));
Assertions.assertTrue(exception.getMessage().contains("ACQ.INVALID_PARAMETER"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus());
Assertions.assertNull(orderCaptor.getValue().getPayTime());
}
@Test
void precreateShouldRejectSuccessfulResponseWithoutQrCode() throws Exception {
when(alipayClient.execute(any(AlipayTradePrecreateRequest.class))).thenReturn(successPrecreateResponse(null));
Assertions.assertThrows(RuntimeException.class,
() -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2)));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus());
}
@Test
void payCallbackShouldRejectInvalidSignatureBeforeReadingOrder() {
service.signatureValid = false;
Assertions.assertNull(service.payCallback(successCallbackParams()));
verify(thirdPayOrderService, never()).getByPayNo(any());
}
@Test
void payCallbackShouldRejectWrongAppIdAndAmount() {
ThirdPayOrderEntity order = unpaidOrder();
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
Map<String, String> params = successCallbackParams();
params.put("app_id", "wrong-app-id");
Assertions.assertNull(service.payCallback(params));
params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId());
params.put("total_amount", "12.35");
Assertions.assertNull(service.payCallback(params));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void payCallbackShouldAtomicallyMarkSuccessAndRecordTradeNo() {
ThirdPayOrderEntity order = unpaidOrder();
ThirdPayOrderEntity paidOrder = unpaidOrder();
paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
paidOrder.setThirdPayNo("202600000001");
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertTrue(result.isStateChanged());
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("202600000001", updateCaptor.getValue().getThirdPayNo());
Assertions.assertNotNull(updateCaptor.getValue().getPayTime());
}
@Test
void payCallbackShouldAcceptDuplicateSuccessWithoutUpdatingAgain() {
ThirdPayOrderEntity paidOrder = unpaidOrder();
paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(thirdPayOrderService.getByPayNo(paidOrder.getPayNo())).thenReturn(paidOrder);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertFalse(result.isStateChanged());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void payCallbackShouldKeepClosedStateWhenCancelWinsConcurrency() {
ThirdPayOrderEntity order = unpaidOrder();
ThirdPayOrderEntity closedOrder = unpaidOrder();
closedOrder.setStatus(ThirdPayOrderStatusEnum.PAY_CLOSED.getType());
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(false);
when(thirdPayOrderService.getById(order.getId())).thenReturn(closedOrder);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertFalse(result.isPaySuccess());
Assertions.assertFalse(result.isStateChanged());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), result.getPayOrder().getStatus());
}
/**
* 验证支付宝聚合回调必须同时具备 JSAPI 请求快照、会话绑定和支付宝平台选择。
*/
@Test
void payCallbackShouldAcceptBoundAggregateJsapiOrder() {
ThirdPayOrderEntity order = unpaidOrder();
order.setPayRep(JSONUtil.createObj().set("aggregatePay", true)
.set("productCode", "JSAPI_PAY").toString());
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setPayNo(order.getPayNo());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.ALI_PAY.getType());
session.setBizType(order.getBizType());
session.setBizId(order.getBizId());
session.setMchId(order.getMchId());
session.setPrice(order.getPrice());
when(aggregatePaySessionService.getByPayNo(order.getPayNo())).thenReturn(session);
when(thirdPayOrderService.getByPayNo(order.getPayNo())).thenReturn(order);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = unpaidOrder();
paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
when(thirdPayOrderService.getById(order.getId())).thenReturn(paidOrder);
ThirdAppPlusAliPayService.AliPayCallbackResult result = service.payCallback(successCallbackParams());
Assertions.assertNotNull(result);
Assertions.assertTrue(result.isPaySuccess());
Assertions.assertTrue(result.isStateChanged());
}
private AlipayTradePrecreateResponse successPrecreateResponse(String qrCode) {
AlipayTradePrecreateResponse response = new AlipayTradePrecreateResponse();
response.setCode("10000");
response.setMsg("Success");
response.setOutTradeNo(payDto.getOutTradeNo());
response.setQrCode(qrCode);
return response;
}
private ThirdPayOrderEntity unpaidOrder() {
ThirdPayOrderEntity order = new ThirdPayOrderEntity();
order.setId(99L);
order.setPayNo(payDto.getOutTradeNo());
order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
order.setBizType(payDto.getBizType().getType());
order.setBizId(payDto.getBizId());
order.setMchId(payDto.getMchId());
order.setPrice(payDto.getAmount());
order.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
return order;
}
private Map<String, String> successCallbackParams() {
Map<String, String> params = new HashMap<>();
params.put("app_id", appPlusPayConfig.getAliPayConfig().getAppId());
params.put("out_trade_no", payDto.getOutTradeNo());
params.put("trade_no", "202600000001");
params.put("trade_status", "TRADE_SUCCESS");
params.put("total_amount", "12.34");
return params;
}
private static final class TestableThirdAppPlusAliPayService extends ThirdAppPlusAliPayServiceImpl {
private final AlipayClient testAlipayClient;
private boolean signatureValid = true;
private TestableThirdAppPlusAliPayService(AppPlusPayConfig appPlusPayConfig, ThirdPayOrderService thirdPayOrderService,
AlipayClient testAlipayClient) {
super(appPlusPayConfig, thirdPayOrderService);
this.testAlipayClient = testAlipayClient;
}
@Override
protected AlipayClient getAlipayClient() {
return testAlipayClient;
}
@Override
protected boolean verifyPayCallbackSignature(Map<String, String> params) {
return signatureValid;
}
}
}
@@ -0,0 +1,522 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.alipay.api.response.AlipayTradeCancelResponse;
import com.alipay.api.response.AlipayTradeQueryResponse;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.admin.api.feign.RemoteTokenService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserRelationService;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.app.api.feign.RemoteAppUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformPayType;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.ThirdOrderCodePayDto;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.*;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayStatusVo;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargePackageService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletRechargeService;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class ThirdAppPlusServiceImplOrderCodePayTest {
@Mock
private AppPlusPayConfig appPlusPayConfig;
@Mock
private ThirdAppPlusWechatService thirdMiniAppWechatService;
@Mock
private ThirdAppPlusWechatNativePayService thirdAppPlusWechatNativePayService;
@Mock
private ThirdAppPlusWechatMicropayService thirdAppPlusWechatMicropayService;
@Mock
private ThirdAppPlusAliPayService thirdMiniAppAliPayService;
@Mock
private ThirdPayLogService thirdPayLogService;
@Mock
private ThirdPayOrderService thirdPayOrderService;
/**
* 聚合支付服务 Mock,用于验证旧原生订单码创建前执行跨模式互斥检查。
*/
@Mock
private ThirdAggregateOrderPayService thirdAggregateOrderPayService;
@Mock
private StringRedisTemplate stringRedisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
@Mock
private RemoteUserService remoteUserService;
@Mock
private RemoteUserRelationService remoteUserRelationService;
@Mock
private RemoteTokenService remoteTokenService;
@Mock
private RemoteAppUserService remoteAppUserService;
@Mock
private RemoteWalletRechargePackageService remoteWalletRechargePackageService;
@Mock
private RemoteWalletRechargeService remoteWalletRechargeService;
@Mock
private RemoteWalletService remoteWalletService;
@Mock
private RemoteMchService remoteMchService;
@Mock
private RemoteHardOrderService remoteHardOrderService;
@InjectMocks
private ThirdAppPlusServiceImpl service;
private ThirdOrderCodePayDto request;
private MchManageVo merchant;
@BeforeEach
void setUp() {
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
lenient().when(valueOperations.setIfAbsent(any(), any(), any())).thenReturn(true);
AppPlusPayConfig.AliPayConfig aliPayConfig = new AppPlusPayConfig.AliPayConfig();
aliPayConfig.setAppId("2026000000000001");
aliPayConfig.setAppPrivateKey("test-private-key");
aliPayConfig.setAlipayPublicKey("test-public-key");
aliPayConfig.setBaseCallbackUrl("https://pay.example.com");
aliPayConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
lenient().when(appPlusPayConfig.getAliPayConfig()).thenReturn(aliPayConfig);
AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig();
wechatConfig.setAppPlatformAppId("wx-service-app-id");
wechatConfig.setMerchantId("1900000001");
wechatConfig.setMchPrivatePath("/tmp/test-private-key.pem");
wechatConfig.setMerchantSerialNumber("test-serial-number");
wechatConfig.setApiV3Key("test-api-v3-key");
wechatConfig.setBaseCallbackUrl("https://pay.example.com");
wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
lenient().when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig);
request = new ThirdOrderCodePayDto();
request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
request.setBizId("order-1001");
request.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY);
request.setAmount(1234);
request.setMchId(88L);
merchant = enabledAliPayMerchant();
}
@Test
void orderCodePayShouldRejectUnsupportedBizPlatformAndAmount() {
request.setBizType(ThirdPayOrderBizTypeEnum.APP_SHOP_PAY);
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
request.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
request.setMiniPlatformType(AppPlusPlatformTypeEnum.ZAX_APP_WALLET);
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
request.setMiniPlatformType(AppPlusPlatformTypeEnum.ALI_PAY);
request.setAmount(0);
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any());
}
@Test
void orderCodePayShouldRejectMissingMerchantAndAliPayConfiguration() {
mockNoExistingOrder();
when(remoteMchService.getById(88L)).thenReturn(R.ok());
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
merchant.setPaySettingList(List.of());
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
merchant = enabledAliPayMerchant();
merchant.getPaySettingList().get(0).setSettingJson("{}");
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
}
@Test
void orderCodePayShouldCreateNewOrderAndUseAnonymousUser() {
mockNoExistingOrder();
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
ThirdOrderCodePayVo expected = orderCodePayVo("new-pay-no", "new-qr", LocalDateTime.now().plusMinutes(2));
when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected);
ThirdOrderCodePayVo result = service.orderCodePay(request);
Assertions.assertSame(expected, result);
ArgumentCaptor<ThirdAppPlusPayDto> payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class);
ArgumentCaptor<LocalDateTime> expireCaptor = ArgumentCaptor.forClass(LocalDateTime.class);
verify(thirdMiniAppAliPayService).precreate(payDtoCaptor.capture(), any(), expireCaptor.capture());
Assertions.assertEquals(-1L, payDtoCaptor.getValue().getUserId());
Assertions.assertEquals(1234, payDtoCaptor.getValue().getAmount());
Assertions.assertEquals(88L, payDtoCaptor.getValue().getMchId());
Assertions.assertTrue(expireCaptor.getValue().isAfter(LocalDateTime.now().plusSeconds(110)));
Assertions.assertTrue(expireCaptor.getValue().isBefore(LocalDateTime.now().plusSeconds(130)));
ArgumentCaptor<String> lockKeyCaptor = ArgumentCaptor.forClass(String.class);
verify(valueOperations).setIfAbsent(lockKeyCaptor.capture(), any(), any());
Assertions.assertEquals("thirdparty:order-code-pay:lock:20:order-1001", lockKeyCaptor.getValue());
}
@Test
void orderCodePayShouldCreateWechatNativeOrder() {
request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT);
merchant = enabledWechatMerchant();
mockNoExistingOrder();
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
ThirdOrderCodePayVo expected = orderCodePayVo("wechat-pay-no", "wechat-code-url", LocalDateTime.now().plusMinutes(2));
when(thirdAppPlusWechatNativePayService.precreate(any(), any(), any())).thenReturn(expected);
ThirdOrderCodePayVo result = service.orderCodePay(request);
Assertions.assertSame(expected, result);
ArgumentCaptor<ThirdAppPlusPayDto> payDtoCaptor = ArgumentCaptor.forClass(ThirdAppPlusPayDto.class);
verify(thirdAppPlusWechatNativePayService).precreate(payDtoCaptor.capture(), any(), any());
Assertions.assertEquals(-1L, payDtoCaptor.getValue().getUserId());
Assertions.assertEquals(1234, payDtoCaptor.getValue().getAmount());
verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any());
}
@Test
void orderCodePayShouldReuseValidUnpaidQrCode() {
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L);
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
unpaidOrder.setPayRes(JSONUtil.createObj().set("qrCode", "existing-qr").toString());
when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(unpaidOrder);
ThirdOrderCodePayVo result = service.orderCodePay(request);
Assertions.assertEquals(unpaidOrder.getPayNo(), result.getPayNo());
Assertions.assertEquals("existing-qr", result.getQrCode());
Assertions.assertEquals(unpaidOrder.getExpireTime(), result.getExpireTime());
verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any());
}
@Test
void orderCodePayShouldRejectAlreadyPaidBusinessOrder() {
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(1L);
RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
Assertions.assertTrue(exception.getMessage().contains("已支付"));
verify(thirdMiniAppAliPayService, never()).precreate(any(), any(), any());
}
@Test
void orderCodePayShouldRejectAnotherPlatformsValidQrCode() {
request.setMiniPlatformType(AppPlusPlatformTypeEnum.WECHAT);
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L);
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
unpaidOrder.setPayRes(JSONUtil.createObj().set("qrCode", "alipay-existing-qr").toString());
when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(unpaidOrder);
RuntimeException exception = Assertions.assertThrows(RuntimeException.class, () -> service.orderCodePay(request));
Assertions.assertTrue(exception.getMessage().contains("其他平台"));
verify(thirdAppPlusWechatNativePayService, never()).precreate(any(), any(), any());
}
@Test
void getOrderCodePayStatusShouldReturnOnlyInternalState() {
ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, LocalDateTime.now().plusMinutes(1));
paidOrder.setMchPaySettingJson("sensitive-setting-snapshot");
paidOrder.setPayRes("sensitive-alipay-response");
when(thirdPayOrderService.getByPayNo(paidOrder.getPayNo())).thenReturn(paidOrder);
ThirdOrderCodePayStatusVo result = service.getOrderCodePayStatus(paidOrder.getPayNo());
Assertions.assertEquals(paidOrder.getPayNo(), result.getPayNo());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), result.getStatus());
Assertions.assertTrue(result.getPaySuccess());
Assertions.assertEquals(paidOrder.getExpireTime(), result.getExpireTime());
}
@Test
void orderCodePayShouldCloseExpiredOrderBeforeCreatingReplacement() {
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L);
ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1));
when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(expiredOrder);
AlipayTradeQueryResponse queryResponse = queryResponse("WAIT_BUYER_PAY", null);
when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(queryResponse);
AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse();
cancelResponse.setCode("10000");
cancelResponse.setRetryFlag("N");
cancelResponse.setAction("close");
when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity closedOrder = order(ThirdPayOrderStatusEnum.PAY_CLOSED, expiredOrder.getExpireTime());
when(thirdPayOrderService.getById(expiredOrder.getId())).thenReturn(closedOrder);
when(remoteMchService.getById(88L)).thenReturn(R.ok(merchant));
ThirdOrderCodePayVo expected = orderCodePayVo("replacement-pay-no", "replacement-qr", LocalDateTime.now().plusMinutes(2));
when(thirdMiniAppAliPayService.precreate(any(), any(), any())).thenReturn(expected);
ThirdOrderCodePayVo result = service.orderCodePay(request);
Assertions.assertSame(expected, result);
verify(thirdMiniAppAliPayService).query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson());
verify(thirdMiniAppAliPayService).cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson());
verify(thirdMiniAppAliPayService).precreate(any(), any(), any());
}
@Test
void queryPayResultShouldKeepWaitingOrderBeforeExpiry() {
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("WAIT_BUYER_PAY", null));
service.queryPayResult(unpaidOrder);
verify(thirdMiniAppAliPayService, never()).cancel(any(), any());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void queryPayResultShouldMarkTradeFinishedAsSuccess() {
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("TRADE_FINISHED", "alipay-trade-no"));
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime());
when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder);
service.queryPayResult(unpaidOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService, org.mockito.Mockito.atLeastOnce()).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getAllValues().get(0).getStatus());
Assertions.assertEquals("alipay-trade-no", updateCaptor.getAllValues().get(0).getThirdPayNo());
}
@Test
void queryPayResultShouldMarkTradeSuccessAsPaid() {
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("TRADE_SUCCESS", "alipay-success-no"));
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime());
paidOrder.setIsBizCallback(YesNoEnum.YES.getCode());
when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder);
service.queryPayResult(unpaidOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("alipay-success-no", updateCaptor.getValue().getThirdPayNo());
}
@Test
void queryPayResultShouldMarkTradeClosedAsClosed() {
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
when(thirdMiniAppAliPayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("TRADE_CLOSED", null));
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
service.queryPayResult(unpaidOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus());
}
@Test
void queryPayResultShouldCloseExpiredOrderAfterCancelSucceeds() {
ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1));
when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("WAIT_BUYER_PAY", null));
AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse();
cancelResponse.setCode("10000");
cancelResponse.setRetryFlag("N");
cancelResponse.setAction("close");
when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
service.queryPayResult(expiredOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus());
}
@Test
void queryPayResultShouldRetryCancelAndKeepUnpaidState() {
ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1));
when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()))
.thenReturn(queryResponse("WAIT_BUYER_PAY", null));
AlipayTradeCancelResponse cancelResponse = new AlipayTradeCancelResponse();
cancelResponse.setCode("10000");
cancelResponse.setRetryFlag("Y");
when(thirdMiniAppAliPayService.cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(cancelResponse);
service.queryPayResult(expiredOrder);
verify(thirdMiniAppAliPayService).cancel(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void queryPayResultShouldKeepUnpaidStateOnNetworkFailure() {
ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1));
when(thirdMiniAppAliPayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson())).thenReturn(null);
service.queryPayResult(expiredOrder);
verify(thirdMiniAppAliPayService, never()).cancel(any(), any());
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void queryPayResultShouldMarkWechatNativeSuccess() {
ThirdPayOrderEntity unpaidOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().plusMinutes(1));
unpaidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction =
new com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction();
transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.SUCCESS);
transaction.setTransactionId("wechat-transaction-id");
when(thirdAppPlusWechatNativePayService.query(unpaidOrder.getPayNo(), unpaidOrder.getMchPaySettingJson()))
.thenReturn(transaction);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = order(ThirdPayOrderStatusEnum.PAY_SUCCESS, unpaidOrder.getExpireTime());
paidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
paidOrder.setIsBizCallback(YesNoEnum.YES.getCode());
when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder);
service.queryPayResult(unpaidOrder);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("wechat-transaction-id", updateCaptor.getValue().getThirdPayNo());
}
@Test
void queryPayResultShouldCloseExpiredWechatNativeOrder() {
ThirdPayOrderEntity expiredOrder = order(ThirdPayOrderStatusEnum.PAY_UNPAID, LocalDateTime.now().minusSeconds(1));
expiredOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction transaction =
new com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction();
transaction.setTradeState(com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction.TradeStateEnum.NOTPAY);
when(thirdAppPlusWechatNativePayService.query(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()))
.thenReturn(transaction);
when(thirdAppPlusWechatNativePayService.close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson()))
.thenReturn(true);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
service.queryPayResult(expiredOrder);
verify(thirdAppPlusWechatNativePayService).close(expiredOrder.getPayNo(), expiredOrder.getMchPaySettingJson());
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_CLOSED.getType(), updateCaptor.getValue().getStatus());
}
private void mockNoExistingOrder() {
when(thirdPayOrderService.count(any(Wrapper.class))).thenReturn(0L);
when(thirdPayOrderService.getOne(any(Wrapper.class))).thenReturn(null);
}
private MchManageVo enabledAliPayMerchant() {
MchPaySettingEntity.AliPaySetting aliPaySetting = new MchPaySettingEntity.AliPaySetting();
aliPaySetting.setAppAuthToken("merchant-auth-token");
MchPaySettingVo setting = new MchPaySettingVo();
setting.setPlatformType(AppPlusPlatformPayType.ALI_PAY.getType());
setting.setEnable(YesNoEnum.YES.getCode());
setting.setSettingJson(JSONUtil.toJsonStr(aliPaySetting));
MchManageVo result = new MchManageVo();
result.setId(88L);
result.setPaySettingList(List.of(setting));
return result;
}
private MchManageVo enabledWechatMerchant() {
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("1900000109");
MchPaySettingVo setting = new MchPaySettingVo();
setting.setPlatformType(AppPlusPlatformPayType.WECHAT.getType());
setting.setEnable(YesNoEnum.YES.getCode());
setting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
MchManageVo result = new MchManageVo();
result.setId(88L);
result.setPaySettingList(List.of(setting));
return result;
}
private ThirdPayOrderEntity order(ThirdPayOrderStatusEnum status, LocalDateTime expireTime) {
ThirdPayOrderEntity order = new ThirdPayOrderEntity();
order.setId(99L);
order.setPayNo("existing-pay-no");
order.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType());
order.setBizId(request.getBizId());
order.setPayPlatformType(AppPlusPlatformTypeEnum.ALI_PAY.getType());
order.setStatus(status.getType());
order.setPrice(request.getAmount());
order.setMchId(request.getMchId());
order.setMchPaySettingJson("merchant-setting-snapshot");
order.setExpireTime(expireTime);
return order;
}
private AlipayTradeQueryResponse queryResponse(String tradeStatus, String tradeNo) {
AlipayTradeQueryResponse response = new AlipayTradeQueryResponse();
response.setCode("10000");
response.setTradeStatus(tradeStatus);
response.setTradeNo(tradeNo);
return response;
}
private ThirdOrderCodePayVo orderCodePayVo(String payNo, String qrCode, LocalDateTime expireTime) {
ThirdOrderCodePayVo result = new ThirdOrderCodePayVo();
result.setPayNo(payNo);
result.setQrCode(qrCode);
result.setExpireTime(expireTime);
return result;
}
}
@@ -0,0 +1,168 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.hutool.json.JSONUtil;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.dto.appplus.pay.wechat.ThirdAppPlusPayDto;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.vo.appplus.pay.ThirdOrderCodePayVo;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.core.exception.ServiceException;
import com.wechat.pay.java.service.partnerpayments.nativepay.NativePayService;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.CloseOrderRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.PrepayResponse;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.QueryOrderByOutTradeNoRequest;
import com.wechat.pay.java.service.partnerpayments.nativepay.model.Transaction;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 微信服务商 Native 订单码服务参数和异常持久化测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAppPlusWechatNativePayServiceImplTest {
@Mock
private NativePayService nativePayService;
@Mock
private AppPlusPayConfig appPlusPayConfig;
@Mock
private ThirdPayOrderService thirdPayOrderService;
@InjectMocks
private ThirdAppPlusWechatNativePayServiceImpl service;
private ThirdAppPlusPayDto payDto;
private MchPaySettingVo paySetting;
@BeforeEach
void setUp() {
AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig();
wechatConfig.setAppPlatformAppId("wx-service-app-id");
wechatConfig.setMerchantId("1900000001");
wechatConfig.setBaseCallbackUrl("https://pay.example.com");
wechatConfig.setPayCallbackUrl("/third-app-pay/app/pay-callback");
when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig);
payDto = new ThirdAppPlusPayDto();
payDto.setOutTradeNo("202608050001");
payDto.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY);
payDto.setBizId("order-1001");
payDto.setUserId(-1L);
payDto.setMchId(88L);
payDto.setAmount(1234);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("1900000109");
paySetting = new MchPaySettingVo();
paySetting.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
}
@Test
void precreateShouldBuildPartnerNativeRequestAndSaveUnpaidOrder() {
PrepayResponse response = new PrepayResponse();
response.setCodeUrl("weixin://wxpay/bizpayurl/up?pr=test");
when(nativePayService.prepay(any())).thenReturn(response);
LocalDateTime expireTime = LocalDateTime.now().plusMinutes(2);
ThirdOrderCodePayVo result = service.precreate(payDto, paySetting, expireTime);
ArgumentCaptor<PrepayRequest> requestCaptor = ArgumentCaptor.forClass(PrepayRequest.class);
verify(nativePayService).prepay(requestCaptor.capture());
PrepayRequest request = requestCaptor.getValue();
Assertions.assertEquals("wx-service-app-id", request.getSpAppid());
Assertions.assertEquals("1900000001", request.getSpMchid());
Assertions.assertEquals("1900000109", request.getSubMchid());
Assertions.assertEquals(1234, request.getAmount().getTotal());
Assertions.assertEquals("CNY", request.getAmount().getCurrency());
Assertions.assertEquals("资安信消费支付", request.getDescription());
Assertions.assertEquals("https://pay.example.com/third-app-pay/app/pay-callback", request.getNotifyUrl());
Assertions.assertDoesNotThrow(() -> OffsetDateTime.parse(request.getTimeExpire()));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
ThirdPayOrderEntity savedOrder = orderCaptor.getValue();
Assertions.assertEquals(AppPlusPlatformTypeEnum.WECHAT.getType(), savedOrder.getPayPlatformType());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_UNPAID.getType(), savedOrder.getStatus());
Assertions.assertEquals(expireTime, savedOrder.getExpireTime());
Assertions.assertEquals(response.getCodeUrl(), JSONUtil.parseObj(savedOrder.getPayRes()).getStr("qrCode"));
Assertions.assertNull(savedOrder.getPayTime());
Assertions.assertEquals(response.getCodeUrl(), result.getQrCode());
}
@Test
void precreateShouldSaveFailureWhenWechatReturnsEmptyCodeUrl() {
when(nativePayService.prepay(any())).thenReturn(new PrepayResponse());
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2)));
Assertions.assertTrue(exception.getMessage().contains("未返回二维码"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_FAILED.getType(), orderCaptor.getValue().getStatus());
Assertions.assertEquals("EMPTY_CODE_URL", JSONUtil.parseObj(orderCaptor.getValue().getPayRes()).getStr("code"));
Assertions.assertNull(orderCaptor.getValue().getPayTime());
}
@Test
void precreateShouldSaveWechatErrorCode() {
ServiceException serviceException = org.mockito.Mockito.mock(ServiceException.class);
when(serviceException.getErrorCode()).thenReturn("APPID_MCHID_NOT_MATCH");
when(nativePayService.prepay(any())).thenThrow(serviceException);
RuntimeException exception = Assertions.assertThrows(RuntimeException.class,
() -> service.precreate(payDto, paySetting, LocalDateTime.now().plusMinutes(2)));
Assertions.assertTrue(exception.getMessage().contains("APPID_MCHID_NOT_MATCH"));
ArgumentCaptor<ThirdPayOrderEntity> orderCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).save(orderCaptor.capture());
Assertions.assertEquals("APPID_MCHID_NOT_MATCH", JSONUtil.parseObj(orderCaptor.getValue().getPayRes()).getStr("code"));
}
@Test
void queryAndCloseShouldUseServiceProviderAndSubMerchantNumbers() {
MchPaySettingVo settingSnapshot = new MchPaySettingVo();
settingSnapshot.setSettingJson(paySetting.getSettingJson());
String settingJson = JSONUtil.toJsonStr(settingSnapshot);
Transaction transaction = new Transaction();
transaction.setTradeState(Transaction.TradeStateEnum.NOTPAY);
when(nativePayService.queryOrderByOutTradeNo(any())).thenReturn(transaction);
Assertions.assertSame(transaction, service.query(payDto.getOutTradeNo(), settingJson));
Assertions.assertTrue(service.close(payDto.getOutTradeNo(), settingJson));
ArgumentCaptor<QueryOrderByOutTradeNoRequest> queryCaptor = ArgumentCaptor.forClass(QueryOrderByOutTradeNoRequest.class);
verify(nativePayService).queryOrderByOutTradeNo(queryCaptor.capture());
Assertions.assertEquals("1900000001", queryCaptor.getValue().getSpMchid());
Assertions.assertEquals("1900000109", queryCaptor.getValue().getSubMchid());
Assertions.assertEquals(payDto.getOutTradeNo(), queryCaptor.getValue().getOutTradeNo());
ArgumentCaptor<CloseOrderRequest> closeCaptor = ArgumentCaptor.forClass(CloseOrderRequest.class);
verify(nativePayService).closeOrder(closeCaptor.capture());
Assertions.assertEquals("1900000001", closeCaptor.getValue().getSpMchid());
Assertions.assertEquals("1900000109", closeCaptor.getValue().getSubMchid());
Assertions.assertEquals(payDto.getOutTradeNo(), closeCaptor.getValue().getOutTradeNo());
}
}
@@ -0,0 +1,244 @@
package com.bsd.pjcloud.thirdparty.service.impl.appplus;
import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.thirdparty.config.AppPlusPayConfig;
import com.bsd.pjcloud.thirdparty.constant.miniplatform.AppPlusPlatformTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderBizTypeEnum;
import com.bsd.pjcloud.thirdparty.constant.pay.ThirdPayOrderStatusEnum;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdAggregatePaySessionEntity;
import com.bsd.pjcloud.thirdparty.entity.pay.ThirdPayOrderEntity;
import com.bsd.pjcloud.thirdparty.service.ThirdAggregatePaySessionService;
import com.bsd.pjcloud.thirdparty.service.ThirdPayOrderService;
import com.bsd.pjcloud.thirdparty.service.impl.appplus.fz.lifecycle.ThirdAppPlusWechatFzLifecycle;
import com.bsd.pjcloud.wallet.api.entity.mch.MchPaySettingEntity;
import com.bsd.pjcloud.wallet.api.vo.mch.MchPaySettingVo;
import com.wechat.pay.java.core.notification.NotificationParser;
import com.wechat.pay.java.core.notification.RequestParam;
import com.wechat.pay.java.service.partnerpayments.app.AppServiceExtension;
import com.wechat.pay.java.service.partnerpayments.app.model.Transaction;
import com.wechat.pay.java.service.partnerpayments.app.model.TransactionAmount;
import com.wechat.pay.java.service.refund.RefundService;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 微信支付回调关键字段校验与幂等状态更新测试。
*/
@ExtendWith(MockitoExtension.class)
class ThirdAppPlusWechatServiceImplPayCallbackTest {
@Mock
private AppPlusPayConfig appPlusPayConfig;
@Mock
private WxMaService wxMaService;
@Mock
private ThirdPayOrderService thirdPayOrderService;
@Mock
private AppServiceExtension appServiceExtension;
@Mock
private RefundService refundService;
@Mock
private NotificationParser notificationParser;
@Mock
private ThirdAppPlusWechatFzLifecycle appPlusWechatFzLifecycle;
/**
* 聚合支付会话服务 Mock,用于严格区分旧 Native 和新 JSAPI 子订单回调。
*/
@Mock
private ThirdAggregatePaySessionService aggregatePaySessionService;
@InjectMocks
private ThirdAppPlusWechatServiceImpl service;
private ThirdPayOrderEntity unpaidOrder;
private Transaction callbackTransaction;
private Map<String, String> callbackHeaders;
@BeforeEach
void setUp() {
AppPlusPayConfig.WechatConfig wechatConfig = new AppPlusPayConfig.WechatConfig();
wechatConfig.setAppPlatformAppId("wx-service-app-id");
wechatConfig.setGzhAppId("wx-service-jsapi-appid");
wechatConfig.setMerchantId("1900000001");
org.mockito.Mockito.lenient().when(appPlusPayConfig.getWechatConfig()).thenReturn(wechatConfig);
MchPaySettingEntity.WechatPaySetting wechatPaySetting = new MchPaySettingEntity.WechatPaySetting();
wechatPaySetting.setSubMchid("1900000109");
MchPaySettingVo settingSnapshot = new MchPaySettingVo();
settingSnapshot.setSettingJson(JSONUtil.toJsonStr(wechatPaySetting));
unpaidOrder = new ThirdPayOrderEntity();
unpaidOrder.setId(99L);
unpaidOrder.setPayNo("202608050001");
unpaidOrder.setPayPlatformType(AppPlusPlatformTypeEnum.WECHAT.getType());
unpaidOrder.setBizType(ThirdPayOrderBizTypeEnum.YXFJ_PAY.getType());
unpaidOrder.setBizId("order-1001");
unpaidOrder.setMchId(88L);
unpaidOrder.setPrice(1234);
unpaidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_UNPAID.getType());
unpaidOrder.setExpireTime(LocalDateTime.now().plusMinutes(1));
unpaidOrder.setMchPaySettingJson(JSONUtil.toJsonStr(settingSnapshot));
TransactionAmount amount = new TransactionAmount();
amount.setTotal(1234);
callbackTransaction = new Transaction();
callbackTransaction.setSpAppid("wx-service-app-id");
callbackTransaction.setSpMchid("1900000001");
callbackTransaction.setSubMchid("1900000109");
callbackTransaction.setOutTradeNo(unpaidOrder.getPayNo());
callbackTransaction.setTransactionId("wechat-transaction-id");
callbackTransaction.setTradeType(Transaction.TradeTypeEnum.NATIVE);
callbackTransaction.setTradeState(Transaction.TradeStateEnum.SUCCESS);
callbackTransaction.setAmount(amount);
callbackHeaders = Map.of(
"wechatpay-serial", "serial",
"wechatpay-nonce", "nonce",
"wechatpay-signature", "signature",
"wechatpay-timestamp", "1785912000");
}
@Test
void payCallbackShouldValidateAndAtomicallyMarkOrderPaid() {
when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction);
when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder);
when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder);
ThirdPayOrderEntity result = service.payCallback("encrypted-body", callbackHeaders);
Assertions.assertSame(paidOrder, result);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertEquals(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType(), updateCaptor.getValue().getStatus());
Assertions.assertEquals("wechat-transaction-id", updateCaptor.getValue().getThirdPayNo());
Assertions.assertEquals(YesNoEnum.NO.getCode(), updateCaptor.getValue().getIsBizCallback());
}
@Test
void payCallbackShouldRejectAmountMismatch() {
callbackTransaction.getAmount().setTotal(1235);
when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction);
when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder);
Assertions.assertNull(service.payCallback("encrypted-body", callbackHeaders));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
@Test
void payCallbackShouldAcknowledgeRepeatedSuccessWithoutAnotherStateUpdate() {
ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder);
when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction);
when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(paidOrder);
Assertions.assertSame(paidOrder, service.payCallback("encrypted-body", callbackHeaders));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
/**
* 验证聚合回调只接受统一服务商公众号 JSAPI 应用和绑定会话组合。
*/
@Test
void payCallbackShouldAcceptBoundAggregateJsapiOrder() {
unpaidOrder.setPayRep(JSONUtil.createObj()
.set("aggregatePay", true)
.set("tradeType", "JSAPI")
.set("spAppid", "wx-service-jsapi-appid").toString());
callbackTransaction.setSpAppid("wx-service-jsapi-appid");
callbackTransaction.setSubAppid(null);
callbackTransaction.setTradeType(Transaction.TradeTypeEnum.JSAPI);
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setPayNo(unpaidOrder.getPayNo());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.WECHAT.getType());
session.setBizType(unpaidOrder.getBizType());
session.setBizId(unpaidOrder.getBizId());
session.setMchId(unpaidOrder.getMchId());
session.setPrice(unpaidOrder.getPrice());
when(aggregatePaySessionService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(session);
when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction);
when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder);
when(thirdPayOrderService.update(any(ThirdPayOrderEntity.class), any(Wrapper.class))).thenReturn(true);
ThirdPayOrderEntity paidOrder = copyAsPaid(unpaidOrder);
when(thirdPayOrderService.getById(unpaidOrder.getId())).thenReturn(paidOrder);
ThirdPayOrderEntity result = service.payCallback("encrypted-body", callbackHeaders);
Assertions.assertSame(paidOrder, result);
ArgumentCaptor<ThirdPayOrderEntity> updateCaptor = ArgumentCaptor.forClass(ThirdPayOrderEntity.class);
verify(thirdPayOrderService).update(updateCaptor.capture(), any(Wrapper.class));
Assertions.assertNull(updateCaptor.getValue().getIsBizCallback());
}
/**
* 验证聚合订单误带子商户公众号 AppID 时拒绝回调,确保统一使用服务商平台公众号身份。
*/
@Test
void payCallbackShouldRejectAggregateOrderWithSubAppid() {
unpaidOrder.setPayRep(JSONUtil.createObj()
.set("aggregatePay", true)
.set("tradeType", "JSAPI")
.set("spAppid", "wx-service-jsapi-appid").toString());
callbackTransaction.setSpAppid("wx-service-jsapi-appid");
callbackTransaction.setSubAppid("wx-sub-merchant-appid");
callbackTransaction.setTradeType(Transaction.TradeTypeEnum.JSAPI);
ThirdAggregatePaySessionEntity session = new ThirdAggregatePaySessionEntity();
session.setPayNo(unpaidOrder.getPayNo());
session.setSelectedPlatform(AppPlusPlatformTypeEnum.WECHAT.getType());
session.setBizType(unpaidOrder.getBizType());
session.setBizId(unpaidOrder.getBizId());
session.setMchId(unpaidOrder.getMchId());
session.setPrice(unpaidOrder.getPrice());
when(aggregatePaySessionService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(session);
when(notificationParser.parse(any(RequestParam.class), eq(Transaction.class))).thenReturn(callbackTransaction);
when(thirdPayOrderService.getByPayNo(unpaidOrder.getPayNo())).thenReturn(unpaidOrder);
Assertions.assertNull(service.payCallback("encrypted-body", callbackHeaders));
verify(thirdPayOrderService, never()).update(any(ThirdPayOrderEntity.class), any(Wrapper.class));
}
private ThirdPayOrderEntity copyAsPaid(ThirdPayOrderEntity source) {
ThirdPayOrderEntity paidOrder = new ThirdPayOrderEntity();
paidOrder.setId(source.getId());
paidOrder.setPayNo(source.getPayNo());
paidOrder.setPayPlatformType(source.getPayPlatformType());
paidOrder.setBizType(source.getBizType());
paidOrder.setBizId(source.getBizId());
paidOrder.setMchId(source.getMchId());
paidOrder.setPrice(source.getPrice());
paidOrder.setExpireTime(source.getExpireTime());
paidOrder.setMchPaySettingJson(source.getMchPaySettingJson());
paidOrder.setStatus(ThirdPayOrderStatusEnum.PAY_SUCCESS.getType());
return paidOrder;
}
}
@@ -0,0 +1,48 @@
package com.bsd.pjcloud.admin.handler;
/**
* 用户默认钱包创建事件。
*
* <p>事件在用户保存事务中发布,只携带异步调用所需的稳定标识,避免跨线程持有已结束的 HTTP 请求对象。</p>
*/
public final class UserWalletCreateEvent {
/**
* 已保存的用户主键;钱包服务使用该主键回查用户及其自动创建钱包角色。
*/
private final Long userId;
/**
* 发布事件时的租户主键;为空时沿用下游服务的默认租户处理规则。
*/
private final Long tenantId;
/**
* 创建用户默认钱包事件。
*
* @param userId 已保存的用户主键
* @param tenantId 用户所属租户主键,非 Web 调用场景下允许为空
*/
public UserWalletCreateEvent(Long userId, Long tenantId) {
this.userId = userId;
this.tenantId = tenantId;
}
/**
* 获取需要创建默认钱包的用户主键。
*
* @return 已保存的用户主键
*/
public Long getUserId() {
return userId;
}
/**
* 获取发布事件时的租户主键。
*
* @return 租户主键,非 Web 调用场景下可能为空
*/
public Long getTenantId() {
return tenantId;
}
}
@@ -0,0 +1,72 @@
package com.bsd.pjcloud.admin.handler;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.tenant.TenantBroker;
import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 用户默认钱包创建事件监听器。
*
* <p>监听器在用户事务提交成功后才进入 Spring 管理的异步线程池,避免钱包服务回查到未提交的用户数据。</p>
*/
@Slf4j
@Component
public class UserWalletCreateEventListener {
/**
* 钱包服务远程接口,用于创建默认钱包并同步用户部门等关联信息。
*/
private final RemoteWalletService remoteWalletService;
/**
* 创建用户默认钱包事件监听器。
*
* @param remoteWalletService 钱包服务远程接口
*/
public UserWalletCreateEventListener(RemoteWalletService remoteWalletService) {
this.remoteWalletService = remoteWalletService;
}
/**
* 在用户事务提交成功后异步创建默认钱包并通知钱包服务刷新用户信息。
*
* <p>无事务调用使用 fallbackExecution 直接进入异步线程;此类调用中的用户插入已按单条语句提交,
* 不需要等待事务同步回调。异步线程通过 TenantBroker 恢复事件发布时的租户,确保 Feign 请求携带正确租户头。</p>
*
* @param event 用户主键及事件发布时租户信息
*/
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void createDefaultWallet(UserWalletCreateEvent event) {
try {
TenantBroker.runAs(event.getTenantId(), tenantId -> {
R<?> createResult = remoteWalletService.createWallet(event.getUserId());
if (createResult == null) {
log.error("用户事务提交后创建默认钱包失败,userId:{},钱包服务未返回结果", event.getUserId());
return;
}
if (!createResult.isOk()) {
log.error("用户事务提交后创建默认钱包失败,userId:{},code:{},msg:{}", event.getUserId(), createResult.getCode(), createResult.getMsg());
return;
}
R<Boolean> changeResult = remoteWalletService.userChange(event.getUserId(), Boolean.FALSE);
if (changeResult == null) {
log.error("默认钱包创建后同步用户信息失败,userId:{},钱包服务未返回结果", event.getUserId());
return;
}
if (!changeResult.isOk()) {
log.error("默认钱包创建后同步用户信息失败,userId:{},code:{},msg:{}", event.getUserId(), changeResult.getCode(), changeResult.getMsg());
}
});
} catch (Exception exception) {
// 异步异常无法反馈给原创建用户请求,必须保留完整堆栈供后续补偿和排查。
log.error("用户事务提交后调用钱包服务异常,userId:{},tenantId:{}", event.getUserId(), event.getTenantId(), exception);
}
}
}
@@ -53,6 +53,7 @@ import com.bsd.pjcloud.admin.api.entity.*;
import com.bsd.pjcloud.admin.api.entity.user.SysUserTagEntity;
import com.bsd.pjcloud.admin.api.utils.DataUtil;
import com.bsd.pjcloud.admin.api.vo.*;
import com.bsd.pjcloud.admin.handler.UserWalletCreateEvent;
import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
import com.bsd.pjcloud.admin.mapper.SysUserMapper;
import com.bsd.pjcloud.admin.mapper.SysUserPostMapper;
@@ -74,6 +75,7 @@ import com.bsd.pjcloud.common.core.util.MsgUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.common.data.resolver.ParamResolver;
import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
import com.bsd.pjcloud.common.excel.vo.ErrorMessage;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
@@ -91,6 +93,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.mock.web.MockMultipartFile;
@@ -173,6 +176,11 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
private final FlowUtil flowUtil;
/**
* Spring 业务事件发布器,用于把钱包创建延迟到用户事务成功提交之后执行。
*/
private final ApplicationEventPublisher applicationEventPublisher;
/**
* 申请注销用户
*/
@@ -207,6 +215,9 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
return saveUser(userDto, true);
}
/**
* 保存用户主表、身份、岗位和角色关系,并按审核结果安排后续钱包创建。
*/
private Long saveUser(UserDTO userDto, boolean skipAudit) {
sysUserIdentityService.validateDto(userDto);
SysUser sysUser = new SysUser();
@@ -272,15 +283,8 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
if (isDeptAdmin && !skipAudit) {
getSelf().applyFlow(sysUser);
} else {
// 创建用户默认钱包、保证线程header
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture.runAsync(() -> {
RequestContextHolder.setRequestAttributes(requestAttributes);
// 创建钱包
remoteWalletService.createWallet(sysUser.getUserId());
// 通知钱包
remoteWalletService.userChange(sysUser.getUserId(), Boolean.FALSE);
});
// 事务提交前其他连接无法读取新用户,因此仅发布事件,由监听器在提交成功后异步创建钱包。
applicationEventPublisher.publishEvent(new UserWalletCreateEvent(sysUser.getUserId(), TenantContextHolder.getTenantId()));
}
return sysUser.getUserId();
@@ -422,8 +426,11 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
for (SysUser sysUser : userList) {
cache.evict(sysUser.getUsername());
}
// 删除角色
sysUserRoleMapper.delete(Wrappers.<SysUserRole>lambdaQuery().in(SysUserRole::getUserId, userIds));
// 删除关系
sysUserRelationService.deleteUserRelationByUserIds(userIds);
// 删除身份
sysUserIdentityService.deleteUserIdentityByUserIds(userIds);
this.removeBatchByIds(userIds);
// 删除钱包
@@ -258,14 +258,12 @@ public class SysUserRelationServiceImpl extends ServiceImpl<SysUserRelationMappe
private void validateCreateUserAndRelation(SysUserRelationCreateUserDto dto) {
Long existingUserId = dto.getSubjectUserId() == null ? dto.getObjectUserId() : dto.getSubjectUserId();
validateExistingUserAndRelationType(existingUserId, dto.getRelationTypeId());
boolean usernameExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getUsername, dto.getUsername()));
boolean usernameExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getUsername, dto.getUsername()));
if (usernameExists) {
throw new RuntimeException(MsgUtils.getMessage(ErrorCodes.SYS_USER_USERNAME_EXISTING, dto.getUsername()));
}
if (StrUtil.isNotBlank(dto.getPhone())) {
boolean phoneExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery()
.eq(SysUser::getPhone, dto.getPhone()));
boolean phoneExists = sysUserService.exists(Wrappers.<SysUser>lambdaQuery().eq(SysUser::getPhone, dto.getPhone()));
if (phoneExists) {
throw new RuntimeException(MsgUtils.getMessage(ErrorCodes.SYS_USER_PHONE_EXISTING, dto.getPhone()));
}
@@ -19,30 +19,21 @@ import java.util.List;
public interface WalletActiveService extends IService<WalletActiveEntity> {
/**
* 一键开卡
*
* @param dto
*/
void fastCreateCard(WalletActiveDto dto);
/**
* 开卡成功的通知
*
* @param id 开卡id
*/
Boolean openCardSuccess(Long id);
/**
* 重新激活
*
* @param id 开卡id
*/
Boolean refreshAvatar(Long id);
/**
* 绑定饭卡
*
* @param dto
* @return
*/
WalletActiveEntity bindCard(WalletActiveBindCardDto dto);
@@ -52,22 +43,16 @@ public interface WalletActiveService extends IService<WalletActiveEntity> {
/**
* 取消绑定饭卡
*
* @param activeId 开卡id
*/
Boolean cancelAdminBindCard(Long activeId);
/**
* 卡号是否存在
*
* @return
*/
Boolean exitCardNumber(Long id, String CardNumber);
/**
* 更具卡号查询开卡信息
*
* @return
*/
WalletActiveEntity getByCardNumber(@NotEmpty String cardNumber);
@@ -90,8 +75,14 @@ public interface WalletActiveService extends IService<WalletActiveEntity> {
*/
Long edit(WalletActiveEntity walletActive);
/**
* 根据卡号删除卡
*/
void delCardByCardNumber(String cardNumber);
/**
* 导入开卡信息,会直接覆盖
*/
R importWalletActive(List<WalletImportActiveDto> excelVOList, BindingResult bindingResult);
}
@@ -339,6 +339,19 @@ public class WalletActiveServiceImpl extends ServiceImpl<WalletActiveMapper, Wal
return walletActive.getId();
}
/**
* 根据卡号删除卡
*/
@Override
public void delCardByCardNumber(String cardNumber) {
if (ObjectUtil.isEmpty(cardNumber)) return;
Long count = baseMapper.selectCount(new LambdaQueryWrapper<WalletActiveEntity>()
.eq(WalletActiveEntity::getCardNumber, cardNumber)
);
if (count <= 0) return;
baseMapper.delete(new LambdaQueryWrapper<WalletActiveEntity>().eq(WalletActiveEntity::getCardNumber, cardNumber));
}
/**
* 导入开卡信息,会直接覆盖
*/
@@ -218,6 +218,11 @@ public class WalletManageServiceImpl extends ServiceImpl<WalletManageMapper, Wal
.eq(WalletManageEntity::getUserId, userId)
);
if (ObjectUtil.isEmpty(list)) return false;
// 查询钱包list有没有绑定饭卡,如果绑定了饭卡连饭卡一起删除
for (WalletManageEntity wallet : list) {
if (ObjectUtil.isEmpty(wallet.getCardNumber())) continue;
walletActiveService.delCardByCardNumber(wallet.getCardNumber());
}
baseMapper.deleteByIds(CollectionUtils.convertList(list, WalletManageEntity::getId));
list.forEach(v -> redisTemplate.opsForValue().getAndDelete(CacheConstants.WALLET_DETAIL + v.getId()));
}