【云岚到家-即刻体检】-day07-3-实战项目-即刻体检-预约及订单管理
3 预约管理(实战)
在预约业务中,管理后台需要设置每日预约人数上限,用户端需要查询可预约日期,同时用户下单也需要选择预约日期,下单依赖预约设置,所以该模块需要在实战中优先完成。
3.1 需求分析
管理员需要在后台设置每日预约人数上限,目前有两种方式可以设置:手动编辑某个日期预约设置、上传预约设置文件。
手动编辑某个日期预约设置便于精细化调整预约设置,上传预约设置文件用于批量更新预约人数上限。
3.1.1 管理端查询预约设置
在预约管理页面可以按月查询到预约设置信息,预约设置信息以日历方式呈现,如下图:
这里会按月查询预约设置数据,并在查询界面上显示。
3.1.2 管理端编辑预约设置
管理员登录管理端(前端),点击“预约管理”查询预约设置信息。
任选今日以后日期点击“设置”,打开预约人数设置窗口:
填写预约人数,然后点击“确定”:
注意:
(1)填写的预约人数需要大于0,小于1000
(2)填写的预约人数需要大于等于已预约人数
3.1.3 管理端批量预约设置
管理端通过上传预约设置的excel文件,进行批量设置。
管理端登录后,在“预约管理”–>“模板下载”中下载模板文件:
从浏览器下载文件中,打开刚刚下载的模板文件,填写预约设置:
注意:当前及之前的日期不可设置,即使设置了也不生效
之后需要将填写好的文件进行上传:
批量预约设置的逻辑是:
如果已存在预约设置数据则更新,否则进行新增。
3.1.4 用户端查询可预约时间
用户端在预约时需要选择预约日期,在预约界面查询可预约日期。
选择好套餐,点击“立即预约”,进入预约信息填写页面,如下图:
点击“体检日期”,可以查询到可预约日期,如下图:
如果对应日期仍有预约名额,则显示“充足”;如果没有预约名额或后台未设置该日期预约人数,则显示“不可约”。
3.2 数据表设计
预约设置信息保存在reservation_setting表:
结构如下:
CREATE TABLE `reservation_setting` (
`id` INT(10) NOT NULL AUTO_INCREMENT COMMENT '主键',
`order_date` DATE NOT NULL COMMENT '预约日期',
`number` INT(10) NOT NULL COMMENT '可预约人数',
`reservations` INT(10) NOT NULL DEFAULT '0' COMMENT '已预约人数',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `order_date` (`order_date`) USING BTREE
)
COMMENT='预约设置'
COLLATE='utf8_general_ci'
ENGINE=InnoDB;
3.3 接口设计
详细内容参考接口文档,地址:http://localhost:21500/health/doc.html#/home
3.3.1 管理端查询预约设置
接口名称:按月查询预约设置
接口路径:GET /health/admin/reservation-setting/getReservationSettingByMonth
请求数据类型 application/x-www-form-urlencoded
示例:
{
"code":200,
"msg":"OK",
"data":[
{
"date":"2023-12-01",
"number":20,
"reservations":0
},
{
"date":"2023-12-02",
"number":50,
"reservations":0
}
]
}
3.3.1.1 Mapper
使用mybatisplus自带的mapper
public interface ReservationSettingMapper extends BaseMapper<ReservationSetting> {
}
3.3.1.2 Service
创建com.jzo2o.health.service.IReservationSettingService接口
public interface IReservationSettingService extends IService<ReservationSetting> {
List<ReservationSettingResDTO> getReservationSettingByMonth(String date);
}
实现
@Service
public class ReservationSettingServiceImpl extends ServiceImpl<ReservationSettingMapper, ReservationSetting> implements IReservationSettingService {
@Override
public List<ReservationSettingResDTO> getReservationSettingByMonth(String date) {
//获取月份
String month = date.split("-")[1];
if(month.length()==1) {
month = "0" + month;
}
date=date.split("-")[0]+"-"+month;
//转化为LocalDate
LocalDate beginDate = LocalDate.parse(date + "-01");
//查询
LambdaQueryWrapper<ReservationSetting> queryWrapper = Wrappers.<ReservationSetting>lambdaQuery()
.ge(ReservationSetting::getOrderDate, beginDate.withDayOfMonth(1))
.le(ReservationSetting::getOrderDate, beginDate.withDayOfMonth(beginDate.lengthOfMonth()));
List<ReservationSetting> list = super.list(queryWrapper);
//转化为ReservationSettingResDTO
List<ReservationSettingResDTO> dtoList=list.stream().map(reservationSetting -> ReservationSettingResDTO.builder()
.date(reservationSetting.getOrderDate().toString())
.number(reservationSetting.getNumber())
.reservations(reservationSetting.getReservations())
.build()).collect(Collectors.toList());
return dtoList;
}
3.3.1.3 Controller
@Slf4j
@RestController("adminReservationSettingController")
@RequestMapping("/admin/reservation-setting")
@Api(tags = "管理端 - 预约设置相关接口")
public class ReservationSettingController {
@Resource
private IReservationSettingService reservationSettingService;
@GetMapping("/getReservationSettingByMonth")
@ApiOperation("按月查询预约设置")
@ApiImplicitParam(name = "date", value = "月份,格式:yyyy-MM", required = true, dataTypeClass = String.class)
public List<ReservationSettingResDTO> getReservationSettingByMonth(@RequestParam("date") String date) {
return reservationSettingService.getReservationSettingByMonth(date);
}
3.3.1.4 测试
数据库中只有这么一条
启动jzo2o-gateway网关
启动jzo2o-publics公共服务
启动jzo2o-health体检服务
启动管理端(前端)
测试成功
手动修改数据库增加几个新的日期
测试成功,说明list没问题
3.3.2 管理端编辑预约设置
接口名称:编辑预约设置
接口路径:PUT /health/admin/reservation-setting/editNumberByDate
请求数据类型 application/json
3.3.2.1 Mapper
使用mybatisplus自带的mapper
public interface ReservationSettingMapper extends BaseMapper<ReservationSetting> {
}
3.3.2.2 Service
在com.jzo2o.health.service.IReservationSettingService接口添加方法
public interface IReservationSettingService extends IService<ReservationSetting> {
List<ReservationSettingResDTO> getReservationSettingByMonth(String date);
void editNumberByDate(ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO);
}
实现
@Override
@Transactional(rollbackFor = Exception.class)
public void editNumberByDate(ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO) {
//校验人数
if(reservationSettingUpsertReqDTO.getNumber()>1000||reservationSettingUpsertReqDTO.getNumber()<0) {
throw new RuntimeException("预约人数不能大于1000或小于0");
}
//获取日期
LocalDate orderDate = reservationSettingUpsertReqDTO.getOrderDate();
//查询
LambdaQueryWrapper<ReservationSetting> queryWrapper = Wrappers.<ReservationSetting>lambdaQuery()
.eq(ReservationSetting::getOrderDate, orderDate);
if(super.count(queryWrapper)>0) {
//更新
//1.查询当前预约人数
ReservationSetting reservationSetting = super.getOne(queryWrapper);
int reservations = reservationSetting.getReservations();
if(reservations>reservationSettingUpsertReqDTO.getNumber()) {
throw new RuntimeException("预约人数不能小于已预约人数");
}
//2.更新
super.update(Wrappers.<ReservationSetting>lambdaUpdate()
.set(ReservationSetting::getNumber, reservationSettingUpsertReqDTO.getNumber())
.eq(ReservationSetting::getOrderDate, orderDate));
}else {
//新增
ReservationSetting reservationSetting = new ReservationSetting();
reservationSetting.setOrderDate(orderDate);
reservationSetting.setNumber(reservationSettingUpsertReqDTO.getNumber());
reservationSetting.setReservations(0);
baseMapper.insert(reservationSetting);
}
}
3.3.2.3 Controller
@PutMapping("/editNumberByDate")
@ApiOperation("编辑预约设置")
public void editNumberByDate(@RequestBody ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO) {
reservationSettingService.editNumberByDate(reservationSettingUpsertReqDTO);
return;
}
3.3.2.4 测试
我们测试一个新增一个修改
今日2024.9.19所以新增2024.9.20 666个名额 修改2024.9.20为999个名额
新增
修改
查看数据库,修改成功
3.3.3 管理端批量预约设置
接口名称:上传预约设置文件
接口路径:POST /health/admin/reservation-setting/upload
请求数据类型 multipart/form-data
3.3.3.1 Mapper
使用mybatisplus自带的mapper
public interface ReservationSettingMapper extends BaseMapper<ReservationSetting> {
}
3.3.3.2 Service
在com.jzo2o.health.service.IReservationSettingService接口添加方法
public interface IReservationSettingService extends IService<ReservationSetting> {
List<ReservationSettingResDTO> getReservationSettingByMonth(String date);
void editNumberByDate(ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO);
void uploadBatchSetting(MultipartFile file);
}
实现
@Resource
private ReservationSettingServiceImpl owner;
@Override
public void uploadBatchSetting(MultipartFile file) {
//解析xlsx文件
//1.获取文件名
String filename = file.getOriginalFilename();
//2.校验文件类型
if(!filename.endsWith(".xlsx")) {
throw new RuntimeException("文件类型错误");
}
List<ReservationSettingUpsertReqDTO>list=new ArrayList<>();
//3.解析xlsx行数据
// 获取输入流,使用 Apache POI 读取 .xlsx 文件
try (InputStream inputStream = file.getInputStream();
Workbook workbook = new XSSFWorkbook(inputStream)) {
// 获取第一个工作表
Sheet sheet = workbook.getSheetAt(0);
// 遍历行和单元格
int cellindex=0;
for (Row row : sheet) {
LocalDate orderDate = null;
Integer number = null;
String value=null;
for (Cell cell : row) {
// 获取单元格内容
if(cellindex>=2){
if(cellindex%2==0){
value = cell.getStringCellValue();
String date=value.split(",")[0];
String month = date.split("-")[1];
if(month.length()==1) {
month = "0" + month;
}
date=date.split("-")[0]+"-"+month+"-"+date.split("-")[2];
orderDate = LocalDate.parse(date);
}
else{
number= (int) cell.getNumericCellValue();
}
}
// 跳过表头
if (cell.getRowIndex() == 0) {
cellindex++;
continue;
}
cellindex++;
}
if(cellindex>2&&orderDate != null&&number != null){
// 封装ReservationSettingUpsertReqDTO
ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO = new ReservationSettingUpsertReqDTO();
reservationSettingUpsertReqDTO.setOrderDate(orderDate);
reservationSettingUpsertReqDTO.setNumber(number);
list.add(reservationSettingUpsertReqDTO);
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
//去掉当前日期之前的数据,当天也去掉
list.removeIf(reservationSettingUpsertReqDTO -> !reservationSettingUpsertReqDTO.getOrderDate().isAfter(LocalDate.now()));
//批量更新
for (ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO : list) {
owner.editNumberByDate(reservationSettingUpsertReqDTO);
}
}
3.3.3.3 Controller
@Slf4j
@RestController("adminReservationBatchSettingController")
@RequestMapping("/admin/reservation-setting")
@Api(tags = "管理端 - 批量预约设置相关接口")
public class ReservationBatchSettingController {
@Resource
private IReservationSettingService reservationSettingService;
@PostMapping("/upload")
@ApiOperation("上传文件批量预约设置")
public void upload(@RequestPart("file") MultipartFile file) {
reservationSettingService.uploadBatchSetting(file);
return;
}
}
3.3.3.4 测试
下载xlxs文件
今天是9-20,数据库长这样
9-20不修改,修改后面的日期
修改成功
3.3.4 用户端预约设置查询
接口名称:按月查询可预约日期
接口路径:GET /health/user/reservation-setting/getReservationDateByMonth
请求数据类型 application/x-www-form-urlencoded
示例:
{
"code":200,
"msg":"OK",
"data":{
"dateList":[
"2023-12-01",
"2023-12-02",
"2023-12-03"
]
}
}
3.3.4.1 Mapper
使用mybatisplus自带的mapper
public interface ReservationSettingMapper extends BaseMapper<ReservationSetting> {
}
3.3.4.2 Service
在com.jzo2o.health.service.IReservationSettingService接口添加方法getReservationDateByMonth(String month)
public interface IReservationSettingService extends IService<ReservationSetting> {
List<ReservationSettingResDTO> getReservationSettingByMonth(String date);
void editNumberByDate(ReservationSettingUpsertReqDTO reservationSettingUpsertReqDTO);
void uploadBatchSetting(MultipartFile file);
ReservationDateResDTO getReservationDateByMonth(String month);
}
实现
@Override
public ReservationDateResDTO getReservationDateByMonth(String month) {
//获取月份
if(month.length()==1) {
month = "0" + month;
}
month=month+"-01";
//转化为LocalDate
LocalDate beginDate = LocalDate.parse(month);
//查询
LambdaQueryWrapper<ReservationSetting> queryWrapper = Wrappers.<ReservationSetting>lambdaQuery()
.ge(ReservationSetting::getOrderDate, beginDate.withDayOfMonth(1))
.le(ReservationSetting::getOrderDate, beginDate.withDayOfMonth(beginDate.lengthOfMonth()));
List<ReservationSetting> list = super.list(queryWrapper);
//转化为ReservationDateResDTO
List<String> dateList=new ArrayList<>();
for (ReservationSetting reservationSetting : list) {
if (reservationSetting.getReservations() < reservationSetting.getNumber()) {
dateList.add(reservationSetting.getOrderDate().toString());
}
}
ReservationDateResDTO reservationDateResDTO = new ReservationDateResDTO();
reservationDateResDTO.setDateList(dateList);
return reservationDateResDTO;
}
3.3.4.3 Controller
在com.jzo2o.health.controller.user.ReservationSettingController中
@Slf4j
@RestController("userReservationSettingController")
@RequestMapping("/user/reservation-setting")
@Api(tags = "用户端 - 预约设置相关接口")
public class ReservationSettingController {
@Resource
private IReservationSettingService reservationSettingService;
@GetMapping("/getReservationDateByMonth")
@IgnoreToken
@ApiOperation("按月查询可预约日期")
@ApiImplicitParam(name = "month", value = "月份,格式:yyyy-MM", required = true, dataTypeClass = String.class)
public ReservationDateResDTO getReservationDateByMonth(@RequestParam("month") String month) {
return reservationSettingService.getReservationDateByMonth(month);
}
}
3.3.4.4 测试
打开用户端,测试成功
4 订单管理(实战)
4.1 整体需求分析
完成了套餐管理、预约管理,接下来进入订单管理模块。
4.1.1业务流转
首先我们需要熟悉订单的业务流转,如下图:
说明:
(1)立即预约这一步需要用户进行登录,如果用户没有登录则会跳转到登录页面
(2)因为立即预约可能是并发情况,需要校验是否还有预约名额,名额耗尽则会失败
(3)从“立即预约”到“订单支付”有15分钟倒计时,必须在15分钟内支付,否则会被系统认为支付超时,自动取消
(4)从“订单支付”到“到院核销”过程不作为实战内容
(5)只实现用户端的订单取消和订单退款
4.1.2状态流转
- 订单状态流转
订单数据中有一个属性关系到订单的整个流程,就是订单状态。
本项目中订单有5种状态,如下图:
待支付:用户填写完预约信息后,提交订单但未进行支付
待体检:用户已支付成功,未线下体检
已体检:已线下核销预约信息(已体检的订单不能发起退款)
已关闭:在待体检时取消预约,订单自动退款
已取消:在待支付的状态下,手动取消订单 或 订单超时15分钟
- 支付状态流转
另外订单流转过程中涉及支付和退款,所以我们使用支付状态标记订单的支付和退款情况。
本项目中存在5种支付状态,其对照订单状态流转,如下图:
未支付:用户未支付订单
已支付:用户支付订单,且支付成功
退款中:用户已发起退款,但钱未原路退回
退款成功:发起退款后,钱已原路返回
退款失败:发起退款后,钱未退回
4.2详细需求分析
4.2.1用户端预约下单
用户在登录以后,选择套餐即可预约下单,下面我们分析具体流程。
首先选择好套餐,填写预约信息:
点击“体检日期”需要选择可以预约的日期(即显示“充足”的日期):
填写完成,点击“支付并预约”即可完成下单:
4.2.2用户端生成支付二维码
用户完成“支付并预约”后,需要进行支付,支付入口有三个:预约购买页面点击“支付并预约”按钮、订单列表页面点击“去支付”、订单详情页面点击“去支付”。它们支付页面一致,如下图:
选择任意支付渠道后,会生成支付二维码,用户需扫码支付,如下图:
4.2.3用户端确认支付结果
用户扫码支付完成后,需要点击“支付完成”,后端查询支付结果进行确认:
支付结果被确认成功后,跳转到支付成功页面:
4.2.4接收支付通知
经过了“云岚到家”项目的学习,我们知道支付结果我们不仅能够通过主动请求的方式获取,还可以通过支付服务通知的方式被动获取。
用户支付成功后,支付服务会获取支付结果,之后通过MQ发消息的方式通知业务系统,业务系统收到消息后处理属于自己的支付通知。
注:
(1)请求支付服务获取二维码需要传入product_app_id
(2)不同的业务系统接收MQ消息,需要定义不同的队列名称
上述两者本项目均已定义好,参见:****com.jzo2o.health.constant.TradeConstants
4.2.5用户端取消未支付订单
用户下单后为待支付状态,可以取消订单,在此情况下订单状态变化:待支付–>已取消
同时该操作需要取消预约,例如:用户预约日期为2023年10月1日,已预约人数为100人,该操作会导致已预约人数变更为99人。
该操作可以在订单列表页面点击“取消预约”或者在订单详情页面点击“取消预约”来完成,如下图:
订单列表页面:
订单详情页面:
点击“取消预约”后,需要选择取消原因:
点击“确认提交”即完成取消订单操作。
4.2.6自动取消未支付订单
取消未支付订单除了人工操作外还可以自动取消,下单超过15分钟未支付自动取消订单。这个需求在很多业务场景都有用到,比如:购买火车票、购买电影票在一定时间内未支付将自动取消。
本项目中我们通过定时任务加被处理的方式来完成该操作:
定时任务:每分钟将支付过期的订单查询出来进行取消操作。
懒加载方式:当用户查看订单详情时判断是否到达过期时间,如果到达过期时间则执行取消操作。
基于需求分析,我们需要分别在用户查询订单操作和定时任务中加入取消订单的代码,实现逻辑可参考云岚到家项目。
4.2.7用户端订单退款
我们定义订单支付状态为“已支付”状态下才可以发起退款,本项目中,用户付款后订单状态进入“待体检”状态,此状态下用户可发起退款。
执行该操作后,订单状态变化为:待体检–>已关闭,支付状态变化为:已支付–>退款中
同样的,该操作也会导致预约取消,对应日期已预约人数自减1。
该操作可以在订单列表页面点击“申请退款”或者在订单详情页面点击“申请退款”来完成,如下图:
订单列表页面:
订单详情页面:
4.2.8退款定时任务
在4.2.7任务中用户执行订单退款操作,此时订单支付状态转变为“退款中”状态,在数据存储层面,订单数据进入了订单退款表。接下来,需要通过定时任务,根据退款记录去请求第三方支付服务的退款接口,根据退款结果进行处理,如果退款成功将更新订单的退款状态、删除退款记录。
@Slf4j
@Component
public class OrdersHandler {
@Resource
private RefundRecordApi refundRecordApi;
//解决同级方法调用,事务失效问题
@Resource
private OrdersHandler orderHandler;
@Resource
private IOrdersRefundService ordersRefundService;
@Resource
private OrdersJobProperties ordersJobProperties;
@Resource
private IOrdersService ordersService;
/**
* 订单退款异步任务
*/
@XxlJob(value = "handleRefundOrders")
public void handleRefundOrders() {
//订单退款程序
}
}
4.2.9用户端分页查询订单
上述流程已经分析完了全部的订单状态流转,现在我们需要查询订单。在用户端,用户查询的是自己下的单,需要通过“首页”–>“我的订单”来查看,同时基于订单状态划分了不同的tab标签(全部、待支付、待体检、已体检、已关闭、已取消),用户可点击不同的tab标签查询不同状态下的订单列表。
需要注意的是用户端订单列表排序方式为按照下单时间倒序,在数据库中我们定义了sort_by字段,这个字段是订单创建时间的时间戳。用户每次进行翻页,前端需要传入最后一个订单的sort_by字段值(查询第一页,无需传值)。
4.2.10用户端查询订单详情
在“我的订单”列表页点击任一订单即可进入订单详情,如下图:
4.2.11管理端分页查询订单
管理端登录后在订单管理页面即可查看订单列表:
4.2.12管理端根据状态统计订单数量
管理端订单管理页面在每个状态上展示对应订单数量
4.2.13管理端查询订单详情
在订单列表中,点击左侧操作中的“查看”即可查看对应订单的详细信息,其中包含了订单信息、支付记录、退款信记录。
需要注意的是,在支付记录中,其支付状态和数据库中订单表的支付状态字段不一致,其对应关系为:
当订单表中支付状态为“未支付”时,这里支付状态为“未支付”;
当订单表中支付状态不是“未支付”时,这里支付状态为“已支付”;
4.3 数据表设计
4.3.1订单表
CREATE TABLE `orders` (
`id` BIGINT(19) NOT NULL COMMENT '订单id',
`order_status` INT(10) NOT NULL COMMENT '订单状态,0:未支付,100:待体检,200:已体检,300:已关闭,400:已取消',
`pay_status` INT(10) NOT NULL COMMENT '支付状态,0:未支付,1:已支付,2:退款中,3:退款成功,4:退款失败',
`setmeal_id` BIGINT(19) NOT NULL DEFAULT '0' COMMENT '套餐id',
`setmeal_name` VARCHAR(50) NOT NULL COMMENT '套餐名称' COLLATE 'utf8mb4_general_ci',
`setmeal_sex` INT(10) NOT NULL COMMENT '套餐适用性别,0:不限,1:男,2:女',
`setmeal_age` VARCHAR(50) NOT NULL COMMENT '套餐适用年龄' COLLATE 'utf8mb4_general_ci',
`setmeal_img` VARCHAR(100) NOT NULL COMMENT '套餐图片' COLLATE 'utf8mb4_general_ci',
`setmeal_remark` VARCHAR(100) NOT NULL COMMENT '套餐说明' COLLATE 'utf8mb4_general_ci',
`setmeal_price` DECIMAL(10,2) NOT NULL COMMENT '套餐价格',
`reservation_date` DATE NOT NULL COMMENT '预约日期,格式:yyyy-MM',
`checkup_person_name` VARCHAR(50) NOT NULL COMMENT '体检人姓名' COLLATE 'utf8mb4_general_ci',
`checkup_person_sex` INT(10) NOT NULL COMMENT '体检人性别,0:不限,1:男,2女',
`checkup_person_phone` VARCHAR(50) NOT NULL COMMENT '体检人电话' COLLATE 'utf8mb4_general_ci',
`checkup_person_idcard` VARCHAR(50) NOT NULL COMMENT '体检人身份证号' COLLATE 'utf8mb4_general_ci',
`member_id` BIGINT(19) NOT NULL COMMENT '用户id',
`member_phone` VARCHAR(50) NOT NULL COMMENT '用户电话' COLLATE 'utf8mb4_general_ci',
`pay_time` DATETIME NULL DEFAULT NULL COMMENT '支付时间',
`trading_channel` VARCHAR(50) NULL DEFAULT NULL COMMENT '支付渠道' COLLATE 'utf8mb4_general_ci',
`trading_order_no` BIGINT(19) NULL DEFAULT NULL COMMENT '支付服务交易单号',
`transaction_id` VARCHAR(50) NULL DEFAULT NULL COMMENT '第三方支付的交易号' COLLATE 'utf8mb4_general_ci',
`refund_no` BIGINT(19) NULL DEFAULT NULL COMMENT '支付服务退款单号',
`refund_id` VARCHAR(50) NULL DEFAULT NULL COMMENT '第三方支付的退款单号' COLLATE 'utf8mb4_general_ci',
`sort_by` BIGINT(19) NULL DEFAULT NULL COMMENT '排序字段(取创建时间的时间戳)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE,
INDEX `order_status_member_id_sort_by` (`order_status`, `member_id`, `sort_by`) USING BTREE
)
COMMENT='订单表'
COLLATE='utf8mb4_general_ci'
ENGINE=InnoDB;
下单时会产生member_id字段,可以从UserThreadLocal中获取
4.3.2订单取消表
订单取消或退款时,会将取消/退款相关信息保存到该表中
CREATE TABLE `orders_cancelled` (
`id` BIGINT(19) NOT NULL COMMENT '订单id',
`canceller_id` BIGINT(19) NULL DEFAULT NULL COMMENT '取消人',
`canceller_name` VARCHAR(50) NULL DEFAULT NULL COMMENT '取消人名称' COLLATE 'utf8mb4_0900_ai_ci',
`canceller_type` INT(10) NULL DEFAULT NULL COMMENT '取消人类型,0:系统,1:普通用户,4管理员',
`cancel_reason` VARCHAR(50) NULL DEFAULT NULL COMMENT '取消原因' COLLATE 'utf8mb4_0900_ai_ci',
`cancel_time` DATETIME NULL DEFAULT NULL COMMENT '取消时间',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`) USING BTREE
)
COMMENT='订单取消表'
COLLATE='utf8mb4_0900_ai_ci'
ENGINE=InnoDB
ROW_FORMAT=DYNAMIC;
4.3.3订单退款表
订单退款时,会将退款信息保存到该表中,以便退款定时任务读取
CREATE TABLE `orders_refund` (
`id` BIGINT(19) NOT NULL COMMENT '订单id',
`trading_order_no` BIGINT(19) NULL DEFAULT NULL COMMENT '支付服务交易单号',
`real_pay_amount` DECIMAL(10,2) NULL DEFAULT NULL COMMENT '实付金额',
`create_time` DATETIME NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`) USING BTREE
)
COMMENT='订单退款表'
COLLATE='utf8mb4_0900_ai_ci'
ENGINE=InnoDB;
4.4接口设计
详细内容参考接口文档,地址:http://localhost:21500/health/doc.html#/home
4.4.1用户端预约下单
接口名称:下单接口
接口路径:POST /health/user/orders/place
请求数据类型 application/json
4.4.1.1 Mapper
采用mybatisplus自带的
4.4.1.2 Service
在com.jzo2o.health.service.IOrdersService中创建方法 PlaceOrderResDTO userPlaceOrder(PlaceOrderReqDTO placeOrderReqDTO);
public interface IOrdersService extends IService<Orders> {
PlaceOrderResDTO userPlaceOrder(PlaceOrderReqDTO placeOrderReqDTO);
}
实现
@Service
public class OrdersServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersService {
@Resource
private RedisTemplate<String, Long> redisTemplate;
@Resource
private ISetmealService setmealService;
@Resource
private MemberMapper memberMapper;
@Resource
private OrdersServiceImpl owner;
/**
* 生成订单id 格式:{yyMMdd}{13位id}
*
* @return
*/
private Long generateOrderId() {
//通过Redis自增序列得到序号
Long id = redisTemplate.opsForValue().increment(RedisConstants.ORDER_PAGE_QUERY, 1);
//生成订单号 2位年+2位月+2位日+13位序号
long orderId = DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd") * 10000000000000L + id;
return orderId;
}
@Override
public PlaceOrderResDTO userPlaceOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//1.远程调用获取套餐相关信息
SetmealDetailResDTO setmealDetail = setmealService.findDetail(placeOrderReqDTO.getSetmealId());
if (setmealDetail == null) {
throw new BadRequestException("套餐不存在");
}
//2 下单前数据准备
Orders orders = new Orders();
//2.1 生成订单id
Long orderId = generateOrderId();
//2.2 设置订单id
orders.setId(orderId);
//2.3 设置订单状态
orders.setOrderStatus(OrderStatusEnum.NO_PAY.getStatus());
//2.4 设置支付状态
orders.setPayStatus(OrderPayStatusEnum.NO_PAY.getStatus());
//3 套餐信息
//3.1 设置套餐id
orders.setSetmealId(placeOrderReqDTO.getSetmealId());
//3.2 设置套餐名称
orders.setSetmealName(setmealDetail.getName());
//3.3 设置套餐类型
orders.setSetmealSex(Integer.valueOf(setmealDetail.getSex()));
//3.4 设置套餐年龄
orders.setSetmealAge(setmealDetail.getAge());
//3.5 设置套餐服务
orders.setSetmealImg(setmealDetail.getImg());
//3.6 设置套餐服务
orders.setSetmealRemark(setmealDetail.getRemark());
//3.7 设置套餐价格
orders.setSetmealPrice(BigDecimal.valueOf(setmealDetail.getPrice()));
//4 用户信息
//4.1 设置预约时间
orders.setReservationDate(placeOrderReqDTO.getReservationDate());
//4.2 设置体检人姓名
orders.setCheckupPersonName(placeOrderReqDTO.getCheckupPersonName());
//4.3 设置体检人性别
orders.setCheckupPersonSex(placeOrderReqDTO.getCheckupPersonSex());
//4.4 设置体检人电话
orders.setCheckupPersonPhone(placeOrderReqDTO.getCheckupPersonPhone());
//4.5 设置体检人身份证
orders.setCheckupPersonIdcard(placeOrderReqDTO.getCheckupPersonIdcard());
//4.6 设置下单人id
orders.setMemberId(UserContext.currentUserId());
//4.7 设置下单人姓名
orders.setMemberPhone(memberMapper.selectById(UserContext.currentUserId()).getPhone());
//5 支付信息
//5.1 设置支付时间
orders.setPayTime(null);
//5.2 设置支付方式
orders.setTradingChannel(null);
//5.3 设置支付服务交易单号
orders.setTradingOrderNo(null);
//5.4 设置第三方支付的交易号
orders.setTransactionId(null);
//5.5 设置支付服务退款单号
orders.setRefundNo(null);
//5.6 设置第三方支付的退款单号
orders.setRefundId(null);
//6 其他信息
//6.1 设置排序字段
//LocalDate变为LocalDateTime
LocalDate date=placeOrderReqDTO.getReservationDate();
LocalDateTime localDateTime = date.atStartOfDay();
long sortBy = DateUtils.toEpochMilli(localDateTime) + orders.getId() % 100000;
orders.setSortBy(sortBy);
//6.2 设置创建时间
orders.setCreateTime(LocalDateTime.now());
//6.3 设置更新时间
orders.setUpdateTime(LocalDateTime.now());
//7 插入数据库
owner.add(orders);
//8 返回结果
return new PlaceOrderResDTO(orders.getId());
}
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
}
}
4.4.1.3 Controller
在com.jzo2o.health.controller.user.OrdersController中
@RestController("userOrdersController")
@RequestMapping("/user/orders")
@Api(tags = "用户端 - 下单支付相关接口")
public class OrdersController {
@Resource
private IOrdersService ordersService;
@ApiOperation("下单接口")
@PostMapping("/place")
public PlaceOrderResDTO place(@RequestBody PlaceOrderReqDTO placeOrderReqDTO) {
return ordersService.userPlaceOrder(placeOrderReqDTO);
}
4.4.1.4 测试
登录用户端
下单,选择23号
测试发现每次到获取当前用户id的时候,显示UserContext是null
查找原因,查看gateway网关获取,先看com.jzo2o.gateway.filter.TokenFilter
public class TokenFilter implements GatewayFilter {
/**
* token header名称
*/
private static final String HEADER_TOKEN = "Authorization";
private ApplicationProperties applicationProperties;
public TokenFilter(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.黑名单和白名单校验
// 1.1.黑名单校验
String uri = GatewayWebUtils.getUri(exchange);
log.info("uri : {}", uri);
if (applicationProperties.getAccessPathBlackList().contains(uri)) {
return GatewayWebUtils.toResponse(exchange,
HttpStatus.FORBIDDEN.value(),
ErrorInfo.Msg.REQUEST_FORBIDDEN);
}
// 1.2.访问白名单
if (applicationProperties.getAccessPathWhiteList().contains(uri)) {
return chain.filter(exchange);
}
getAccessPathWhiteList().contains(uri)
查看白名单jetbrains://idea/navigate/reference?project=jzo2o-gateway&path=bootstrap.yml
白名单中由我们的sms-end,说明不是白名单的问题
查看是否校验token
发现health微服务缺少校验token,/health/**
的路径没有经过 TokenFilter
验证,所以我们加上
- id: health
uri: lb://jzo2o-health
predicates:
- Path=/health/**
filters:
- Token
还发现我们发送验证码后,紧接着就是登录服务,这个也应该添加到白名单中
添加完成后我们再测试一下,插入成功,查看数据库
符合我们的预期,后面就只写关键代码了,mapper和serivce的接口我们就一笔带过了
4.4.2用户端生成支付二维码
接口名称:订单支付
接口路径:PUT /health/user/orders/pay/{id}
请求数据类型 application/x-www-form-urlencoded
4.4.2.1 实现代码
Controller
@PutMapping("/pay/{id}")
@ApiOperation("订单支付")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class),
@ApiImplicitParam(name = "tradingChannel", value = "支付渠道:ALI_PAY、WECHAT_PAY", required = true, dataTypeClass = PayChannelEnum.class),
})
public OrdersPayResDTO pay(@PathVariable("id") Long id, @RequestParam("tradingChannel") PayChannelEnum tradingChannel) {
OrdersPayResDTO pay = ordersService.pay(id, tradingChannel);
return pay;
}
Service接口
public interface IOrdersService extends IService<Orders> {
PlaceOrderResDTO userPlaceOrder(PlaceOrderReqDTO placeOrderReqDTO);
OrdersPayResDTO pay(Long id, PayChannelEnum tradingChannel);
}
实现
我们为health也引入sentinel
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-sentinel</artifactId>
</dependency>
创建com.jzo2o.health.service.client.NativePayClient
@Component
@Slf4j
public class NativePayClient {
@Resource
private NativePayApi nativePayApi;
@SentinelResource(value = "createHealthDownLineTrading", fallback = "createHealthDownLineTradingFallback", blockHandler = "createHealthDownLineTradingBlockHandler")
public NativePayResDTO createHealthDownLineTrading(NativePayReqDTO nativePayDTO) {
log.error("扫码支付,收银员通过收银台或商户后台调用此接口,生成二维码后,展示给用户,由用户扫描二维码完成订单支付。");
// 调用其他微服务方法
NativePayResDTO nativePayResDTO = nativePayApi.createDownLineTrading(nativePayDTO);
return nativePayResDTO;
}
//执行异常走
public NativePayResDTO createHealthDownLineTradingFallback(Long id, Throwable throwable) {
log.error("非限流、熔断等导致的异常执行的降级方法,id:{},throwable:", id, throwable);
return null;
}
//熔断后的降级逻辑
public NativePayResDTO createHealthDownLineTradingBlockHandler(Long id, BlockException blockException) {
log.error("触发限流、熔断时执行的降级方法,id:{},blockException:", id, blockException);
return null;
}
}
接口实现
@Override
public OrdersPayResDTO pay(Long id, PayChannelEnum tradingChannel) {
Orders orders = baseMapper.selectById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//订单的支付状态为成功直接返回
if (Objects.equals(OrderPayStatusEnum.PAY_SUCCESS.getStatus(), orders.getPayStatus())
&& ObjectUtil.isNotEmpty(orders.getTradingOrderNo())) {
OrdersPayResDTO ordersPayResDTO = new OrdersPayResDTO();
BeanUtil.copyProperties(orders, ordersPayResDTO);
ordersPayResDTO.setProductOrderNo(orders.getId());
return ordersPayResDTO;
} else {
//生成二维码
NativePayResDTO nativePayResDTO = generateQrCode(orders, tradingChannel);
OrdersPayResDTO ordersPayResDTO = BeanUtil.toBean(nativePayResDTO, OrdersPayResDTO.class);
return ordersPayResDTO;
}
}
//生成二维码
private NativePayResDTO generateQrCode(Orders orders, PayChannelEnum tradingChannel) {
//判断支付渠道
Long enterpriseId = ObjectUtil.equal(PayChannelEnum.ALI_PAY, tradingChannel) ?
tradeProperties.getAliEnterpriseId() : tradeProperties.getWechatEnterpriseId();
//构建支付请求参数
NativePayReqDTO nativePayReqDTO = new NativePayReqDTO();
//商户号
nativePayReqDTO.setEnterpriseId(enterpriseId);
//体检业务系统标识
nativePayReqDTO.setProductAppId("jzo2o.health");
//体检订单号
nativePayReqDTO.setProductOrderNo(orders.getId());
//支付渠道
nativePayReqDTO.setTradingChannel(tradingChannel);
//支付金额
nativePayReqDTO.setTradingAmount(orders.getSetmealPrice());
//备注信息
nativePayReqDTO.setMemo(orders.getSetmealName());
//判断是否切换支付渠道
if (ObjectUtil.isNotEmpty(orders.getTradingChannel())
&& ObjectUtil.notEqual(orders.getTradingChannel(), tradingChannel.toString())) {
nativePayReqDTO.setChangeChannel(true);
}
//生成支付二维码
NativePayResDTO downLineTrading = nativePayClient.createHealthDownLineTrading(nativePayReqDTO);
if(ObjectUtils.isNotNull(downLineTrading)){
log.info("订单:{}请求支付,生成二维码:{}",orders.getId(),downLineTrading.toString());
boolean update = lambdaUpdate()
.eq(Orders::getId, downLineTrading.getProductOrderNo())
.set(Orders::getTradingOrderNo, downLineTrading.getTradingOrderNo())
.set(Orders::getTradingChannel, downLineTrading.getTradingChannel())
.update();
if(!update){
throw new CommonException("订单:"+orders.getId()+"请求支付更新交易单号失败");
}
}
return downLineTrading;
}
4.4.2.2 测试
测试成功
4.4.3用户端确认支付结果
接口名称:查询订单支付结果
接口路径:GET /health/user/orders/pay/{id}/result
请求数据类型 application/x-www-form-urlencoded
4.4.3.1 实现代码
Controller
@GetMapping("/pay/{id}/result")
@ApiOperation("查询订单支付结果")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersPayResDTO payResult(@PathVariable("id") Long id) {
return ordersService.payResult(id);
}
4.4.3.2 主动获取
Service
接口
OrdersPayResDTO payResult(Long id);
void paySuccess(TradeStatusMsg tradeStatusMsg);
实现
创建com.jzo2o.health.service.client.TradingClient,针对TradingApi的熔断降级服务
@Component
@Slf4j
public class TradingClient {
@Resource
private TradingApi tradingApi;
@SentinelResource(value = "findTradResultByTradingOrderNo", fallback = "findTradResultByTradingOrderNoFallback", blockHandler = "findTradResultByTradingOrderNoBlockHandler")
public TradingResDTO findTradResultByTradingOrderNo(Long tradingOrderNo) {
log.error("根据订单号查询订单信息,拿到交易交易单号,tradingOrderNo:{}",tradingOrderNo);
// 调用其他微服务方法
TradingResDTO tradingResDTO = tradingApi.findTradResultByTradingOrderNo(tradingOrderNo);
return tradingResDTO;
}
//执行异常走
public TradingResDTO findTradResultByTradingOrderNoFallback(Long tradingOrderNo, Throwable throwable) {
log.error("非限流、熔断等导致的异常执行的降级方法,tradingOrderNo:{},throwable:", tradingOrderNo, throwable);
return null;
}
//熔断后的降级逻辑
public TradingResDTO findTradResultByTradingOrderNoBlockHandler(Long tradingOrderNo, BlockException blockException) {
log.error("触发限流、熔断时执行的降级方法,tradingOrderNo:{},blockException:", tradingOrderNo, blockException);
return null;
}
}
接口实现
@Override
public OrdersPayResDTO payResult(Long id) {
//查看订单是否存在
Orders orders = baseMapper.selectById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//如果当前订单支付状态为未支付,并且交易单号不为空,则调用支付服务查询支付结果
Integer payStatus = orders.getPayStatus();
if (Objects.equals(OrderPayStatusEnum.NO_PAY.getStatus(), orders.getPayStatus())
&& ObjectUtil.isNotEmpty(orders.getTradingOrderNo())) {
//拿到交易单号
Long tradingOrderNo = orders.getTradingOrderNo();
//根据交易单号请求支付结果接口
TradingResDTO tradResultByTradingOrderNo = tradingClient.findTradResultByTradingOrderNo(tradingOrderNo);
//如果支付结果为成功,则更新订单状态
if (ObjectUtil.isNotNull(tradResultByTradingOrderNo)
&& ObjectUtil.equals(tradResultByTradingOrderNo.getTradingState(), TradingStateEnum.YJS)) {
//设置订单的支付状态成功
TradeStatusMsg msg = TradeStatusMsg.builder()
.productOrderNo(orders.getId())
.tradingChannel(tradResultByTradingOrderNo.getTradingChannel())
.statusCode(TradingStateEnum.YJS.getCode())
.tradingOrderNo(tradResultByTradingOrderNo.getTradingOrderNo())
.transactionId(tradResultByTradingOrderNo.getTransactionId())
.build();
owner.paySuccess(msg);
//构造返回数据
OrdersPayResDTO ordersPayResDTO = BeanUtils.toBean(tradResultByTradingOrderNo, OrdersPayResDTO.class);
ordersPayResDTO.setPayStatus(OrderPayStatusEnum.PAY_SUCCESS.getStatus());
return ordersPayResDTO;
}
}
OrdersPayResDTO ordersPayResDTO = new OrdersPayResDTO();
ordersPayResDTO.setPayStatus(payStatus);
ordersPayResDTO.setProductOrderNo(orders.getId());
ordersPayResDTO.setTradingOrderNo(orders.getTradingOrderNo());
ordersPayResDTO.setTradingChannel(orders.getTradingChannel());
return ordersPayResDTO;
}
@Transactional(rollbackFor = Exception.class)
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
//查询订单
Orders orders = baseMapper.selectById(tradeStatusMsg.getProductOrderNo());
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//校验支付状态如果不是待支付状态则不作处理
if (ObjectUtil.notEqual(OrderPayStatusEnum.NO_PAY.getStatus(), orders.getPayStatus())) {
log.info("更新订单支付成功,当前订单:{}支付状态不是待支付状态", orders.getId());
return;
}
//校验订单状态如果不是待支付状态则不作处理
if (ObjectUtils.notEqual(OrderStatusEnum.NO_PAY.getStatus(),orders.getOrderStatus())) {
log.info("更新订单支付成功,当前订单:{}状态不是待支付状态", orders.getId());
}
//第三方支付单号校验
if (ObjectUtil.isEmpty(tradeStatusMsg.getTransactionId())) {
throw new CommonException("支付成功通知缺少第三方支付单号");
}
//更新订单的支付状态及第三方交易单号等信息
boolean update = lambdaUpdate()
.eq(Orders::getId, orders.getId())
.set(Orders::getPayTime, LocalDateTime.now())//支付时间
.set(Orders::getTradingOrderNo, tradeStatusMsg.getTradingOrderNo())//交易单号
.set(Orders::getTradingChannel, tradeStatusMsg.getTradingChannel())//支付渠道
.set(Orders::getTransactionId, tradeStatusMsg.getTransactionId())//第三方支付交易号
.set(Orders::getPayStatus, OrderPayStatusEnum.PAY_SUCCESS.getStatus())//支付状态
.set(Orders::getOrderStatus, OrderStatusEnum.WAITING_CHECKUP.getStatus())//更新订单状态为待体检
.update();
if(!update){
log.info("更新订单:{}支付成功失败", orders.getId());
throw new CommonException("更新订单"+orders.getId()+"支付成功失败");
}
}
4.4.3.3 被动监听mq获取
在com.jzo2o.health.listener.TradeStatusListener中
@Slf4j
@Component
public class TradeStatusListener {
@Resource
private IOrdersService ordersService;
/**
* 更新支付结果
* 支付成功
*
* @param msg 消息
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = TradeConstants.MQ_TRADE_QUEUE),
exchange = @Exchange(name = MqConstants.Exchanges.TRADE, type = ExchangeTypes.TOPIC),
key = MqConstants.RoutingKeys.TRADE_UPDATE_STATUS
))
public void listenTradeUpdatePayStatusMsg(String msg) {
log.info("接收到支付结果状态的消息 ({})-> {}", MqConstants.Queues.ORDERS_TRADE_UPDATE_STATUS, msg);
//解析消息,将msg转为java对象
List<TradeStatusMsg> tradeStatusMsgs = JSON.parseArray(msg, TradeStatusMsg.class);
// 只处理家政服务的订单且是支付成功的
List<TradeStatusMsg> msgList = tradeStatusMsgs.stream().filter(v ->
v.getStatusCode().equals(TradingStateEnum.YJS.getCode())
&&
"jzo2o.health".equals(v.getProductAppId())).collect(Collectors.toList());
if (CollUtil.isEmpty(msgList)) {
return;
}
//修改订单状态
msgList.forEach(m -> ordersService.paySuccess(m));
}
}
用mq监听trade微服务发送的广播,然后获取其中appid为jzo2o.health的消息即可
4.4.3.2 测试
我们支付一个0.01的
查看数据库,已经更新成功
4.4.4用户端取消未支付订单
接口名称:查询订单支付结果
接口路径:PUT /health/user/orders/cancel
请求数据类型 application/json
4.4.4.1 实现代码
创建dto,com.jzo2o.health.model.dto.OrdersCancelledDTO,原有dto没有金额和单号
@Data
@NoArgsConstructor
@AllArgsConstructor
public class OrdersCancelledDTO {
private static final long serialVersionUID = 1L;
/**
* 订单id
*/
@TableId(value = "id", type = IdType.ASSIGN_ID)
private Long id;
/**
* 取消人
*/
private Long cancellerId;
/**
* 取消人名称
*/
private String cancellerName;
/**
* 取消人类型,1:普通用户,4管理员
*/
private Integer cancellerType;
/**
* 取消原因
*/
private String cancelReason;
/**
* 取消时间
*/
private LocalDateTime cancelTime;
/**
* 实际支付金额
*/
private BigDecimal realPayAmount;
/**
* 支付服务交易单号
*/
private Long tradingOrderNo;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}
Controller
@PutMapping("/cancel")
@ApiOperation("订单取消")
public void cancel(@RequestBody OrdersCancelReqDTO ordersCancelReqDTO) {
OrdersCancelledDTO ordersCancelledDTO = BeanUtil.copyProperties(ordersCancelReqDTO, OrdersCancelledDTO.class);
CurrentUserInfo currentUserInfo = UserContext.currentUser();
ordersCancelledDTO.setCancellerId(currentUserInfo.getId());
ordersCancelledDTO.setCancellerName(currentUserInfo.getName());
ordersCancelledDTO.setCancellerType(currentUserInfo.getUserType());
ordersService.cancel(ordersCancelledDTO);
}
Service
总Service
void cancel(OrdersCancelledDTO ordersCancelledDTO);
4.4.4.2 取消逻辑
分为两种支付状态,待支付和待体检
@Override
public void cancel(OrdersCancelledDTO ordersCancelledDTO) {
//查询订单信息
Orders orders = getById(ordersCancelledDTO.getId());
BeanUtils.copyProperties(orders,ordersCancelledDTO);
if (ObjectUtil.isNull(orders)) {
throw new DbRuntimeException("找不到要取消的订单,订单号:{}",ordersCancelledDTO.getId());
}
//订单状态
Integer ordersStatus = orders.getOrderStatus();
if(Objects.equals(OrderStatusEnum.NO_PAY.getStatus(), ordersStatus)){ //订单状态为待支付
owner.cancelByNoPay(ordersCancelledDTO);
}else if(Objects.equals(OrderStatusEnum.WAITING_CHECKUP.getStatus(), ordersStatus)){ //订单状态为待体检
owner.cancelByDispatching(orderCancelDTO);
//新启动一个线程请求退款
ordersHandler.requestRefundNewThread(orders.getId());
}else{
throw new CommonException("当前订单状态不支持取消");
}
}
4.4.4.2.1 取消待支付
@Transactional(rollbackFor = Exception.class)
public void cancelByNoPay(OrdersCancelledDTO ordersCancelledDTO) {
//保存取消订单记录
OrdersCancelled ordersCanceled = BeanUtil.toBean(ordersCancelledDTO, OrdersCancelled.class);
ordersCancelledMapper.insert(ordersCanceled);
//更新订单状态为取消订单
Orders orders = getById(ordersCancelledDTO.getId());
orders.setOrderStatus(OrderStatusEnum.CANCELLED.getStatus());
orders.setUpdateTime(LocalDateTime.now());
boolean update = updateById(orders);
if(!update){
throw new CommonException("订单:"+orders.getId()+"取消失败");
}
}
4.4.4.2.2 取消待体检
@Transactional(rollbackFor = Exception.class)
public void cancelByDispatching(OrdersCancelledDTO ordersCancelledDTO) {
//保存取消订单记录
OrdersCancelled ordersCanceled = BeanUtil.toBean(ordersCancelledDTO, OrdersCancelled.class);
ordersCanceled.setCancelTime(LocalDateTime.now());
ordersCancelledMapper.insert(ordersCanceled);
//更新订单状态为关闭订单
Orders orders = getById(ordersCancelledDTO.getId());
orders.setOrderStatus(OrderStatusEnum.CLOSED.getStatus());
orders.setPayStatus(OrderPayStatusEnum.REFUNDING.getStatus());
orders.setUpdateTime(LocalDateTime.now());
boolean update = updateById(orders);
if(!update){
throw new CommonException("订单:"+orders.getId()+"取消失败");
}
//添加退款记录
OrdersRefund ordersRefund = new OrdersRefund();
ordersRefund.setId(ordersCancelledDTO.getId());
ordersRefund.setTradingOrderNo(ordersCancelledDTO.getTradingOrderNo());
ordersRefund.setRealPayAmount(ordersCancelledDTO.getRealPayAmount());
ordersRefundMapper.insert(ordersRefund);
}
4.4.4.2.3 自动任务+懒加载处理超时订单
自动任务,创建查询超时订单的service
/**
* 查询超时订单id列表
*
* @param count 数量
* @return 订单id列表
*/
public List<Orders> queryOverTimePayOrdersListByCount(Integer count);
实现
@Override
public List<Orders> queryOverTimePayOrdersListByCount(Integer count) {
//根据订单创建时间查询超过15分钟未支付的订单
List<Orders> list = lambdaQuery()
//查询待支付状态的订单
.eq(Orders::getOrderStatus, OrderStatusEnum.NO_PAY.getStatus())
//小于当前时间减去15分钟,即待支付状态已过15分钟
.lt(Orders::getCreateTime, LocalDateTime.now().minusMinutes(15))
.last("limit " + count)
.list();
return list;
}
设置定时任务,创建com.jzo2o.health.handler.OrdersHandler
@Slf4j
@Component
public class OrdersHandler {
@Resource
private RefundRecordApi refundRecordApi;
//解决同级方法调用,事务失效问题
@Resource
private OrdersHandler orderHandler;
@Resource
private OrdersJobProperties ordersJobProperties;
@Resource
private IOrdersService ordersService;
/**
* 支付超时取消订单
* 每分钟执行一次
*/
@XxlJob(value = "cancelOverTimeHealthPayOrder")
public void cancelOverTimeHealthPayOrder() {
//查询支付超时状态订单
List<Orders> ordersList = ordersService.queryOverTimePayOrdersListByCount(100);
if (CollUtil.isEmpty(ordersList)) {
XxlJobHelper.log("查询超时订单列表为空!");
return;
}
for (Orders order : ordersList) {
//取消订单
OrdersCancelledDTO orderCancelDTO = BeanUtil.toBean(order, OrdersCancelledDTO.class);
orderCancelDTO.setCancellerType(UserType.SYSTEM);
orderCancelDTO.setCancelReason("订单超时支付,自动取消");
ordersService.cancel(orderCancelDTO);
}
}
在xxl-job中注册一下
懒加载
我们在后续的查询订单详情的时候再进行编写
4.4.4.2.4 定时任务退款
在com.jzo2o.health.handler.OrdersHandler中
/**
* 订单退款异步任务
*/
@XxlJob(value = "handleRefundHealthOrders")
public void handleRefundHealthOrders() {
//查询退款中订单
List<OrdersRefund> ordersRefundList = ordersRefundMapper.selectRefundingOrders(100);
for (OrdersRefund ordersRefund : ordersRefundList) {
//请求退款
requestRefundOrder(ordersRefund);
}
}
/**
* 请求退款
* @param ordersRefund 退款记录
*/
public void requestRefundOrder(OrdersRefund ordersRefund){
//调用第三方进行退款
ExecutionResultResDTO executionResultResDTO = null;
try {
executionResultResDTO = refundRecordApi.refundTrading(ordersRefund.getTradingOrderNo(), ordersRefund.getRealPayAmount());
} catch (Exception e) {
e.printStackTrace();
}
if(executionResultResDTO!=null){
//退款后处理订单相关信息
owner.refundOrder(ordersRefund, executionResultResDTO);
}
}
/**
* 订单退款处理
*
* @param ordersRefund
* @param executionResultResDTO 第三方退款信息
*/
@Transactional(rollbackFor = Exception.class)
public void refundOrder(OrdersRefund ordersRefund, ExecutionResultResDTO executionResultResDTO) {
//根据响应结果更新退款状态
int refundStatus = OrderPayStatusEnum.REFUNDING.getStatus();//退款中
if (ObjectUtil.equal(RefundStatusEnum.SUCCESS.getCode(), executionResultResDTO.getRefundStatus())) {
//退款成功
refundStatus = OrderPayStatusEnum.REFUND_SUCCESS.getStatus();
} else if (ObjectUtil.equal(RefundStatusEnum.FAIL.getCode(), executionResultResDTO.getRefundStatus())) {
//退款失败
refundStatus = OrderPayStatusEnum.REFUND_FAIL.getStatus();
}
//如果是退款中状态,程序结束
if (ObjectUtil.equal(refundStatus, OrderPayStatusEnum.REFUNDING.getStatus())) {
return;
}
//非退款中状态,更新订单的退款状态
LambdaUpdateWrapper<Orders> updateWrapper = new LambdaUpdateWrapper<Orders>()
.eq(Orders::getId, ordersRefund.getId())
.ne(Orders::getPayStatus, refundStatus)
.set(Orders::getPayStatus, refundStatus)
.set(ObjectUtil.isNotEmpty(executionResultResDTO.getRefundId()), Orders::getRefundId, executionResultResDTO.getRefundId())
.set(ObjectUtil.isNotEmpty(executionResultResDTO.getRefundNo()), Orders::getRefundNo, executionResultResDTO.getRefundNo());
int update = ordersMapper.update(null, updateWrapper);
//非退款中状态,删除申请退款记录,删除后定时任务不再扫描
if(update>0){
//非退款中状态,删除申请退款记录,删除后定时任务不再扫描
ordersRefundMapper.removeById(ordersRefund.getId());
}
}
同样也需要在xxl-job在进行注册
4.4.4.2.5 及时退款
在com.jzo2o.health.handler.OrdersHandler中
/**
* 新启动一个线程请求退款
* @param ordersRefundId
*/
public void requestRefundNewThread(Long ordersRefundId){
//启动一个线程请求第三方退款接口
new Thread(()->{
//查询退款记录
OrdersRefund ordersRefund = ordersRefundMapper.getById(ordersRefundId);
if(ObjectUtil.isNotNull(ordersRefund)){
//请求退款
requestRefundOrder(ordersRefund);
}
}).start();
}
对应的ordersRefundMapper
public interface OrdersRefundMapper extends BaseMapper<OrdersRefund> {
//查询正在退款的订单100条
@Select("SELECT * FROM orders_refund LIMIT #{count} ORDER BY create_time ASC")
List<OrdersRefund> selectRefundingOrders(Integer count);
@Delete("DELETE FROM orders_refund WHERE id = #{id}")
void removeById(Long id);
@Select("SELECT * FROM orders_refund WHERE id = #{ordersRefundId}")
OrdersRefund getById(Long ordersRefundId);
}
我们写完查询订单详情再集中测试
4.4.5用户端订单退款
接口名称:订单退款
接口路径:PUT /health/user/orders/refund
请求数据类型 application/json
和4.4.4一样,只写Controller
@PutMapping("/refund")
@ApiOperation("订单退款")
public void refund(@RequestBody OrdersCancelReqDTO ordersCancelReqDTO) {
OrdersCancelledDTO ordersCancelledDTO = BeanUtil.copyProperties(ordersCancelReqDTO, OrdersCancelledDTO.class);
CurrentUserInfo currentUserInfo = UserContext.currentUser();
ordersCancelledDTO.setCancellerId(currentUserInfo.getId());
ordersCancelledDTO.setCancellerName(currentUserInfo.getName());
ordersCancelledDTO.setCancellerType(currentUserInfo.getUserType());
ordersService.cancel(ordersCancelledDTO);
}
4.4.6用户端分页查询订单
接口名称:滚动分页查询
接口路径:GET /health/user/orders/page
请求数据类型 application/x-www-form-urlencoded
4.4.6.1 实现代码
Controller
@ApiOperation("滚动分页查询")
@GetMapping("/page")
@ApiImplicitParams({
@ApiImplicitParam(name = "ordersStatus", value = "订单状态,0:未支付,100:待体检,200:已体检,300:已关闭,400:已取消", required = false, dataTypeClass = Integer.class),
@ApiImplicitParam(name = "sortBy", value = "排序字段", required = false, dataTypeClass = Long.class)
})
public List<OrdersResDTO> pageQuery(@RequestParam(value = "ordersStatus", required = false) Integer ordersStatus,
@RequestParam(value = "sortBy", required = false) Long sortBy) {
return ordersService.pageQuery(ordersStatus, sortBy);
}
实现
@Override
public List<OrdersResDTO> pageQuery(Integer ordersStatus, Long sortBy) {
LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery()
.eq(ObjectUtils.isNotNull(ordersStatus), Orders::getOrderStatus, ordersStatus)
.lt(ObjectUtils.isNotNull(sortBy), Orders::getSortBy, sortBy)
.eq(Orders::getMemberId, UserContext.currentUserId());
queryWrapper.orderByDesc(Orders::getSortBy);
List<Orders> ordersList = list(queryWrapper);
return ordersList.stream().map(orders -> BeanUtil.toBean(orders, OrdersResDTO.class)).collect(Collectors.toList());
}
4.4.7用户端查询订单详情
接口名称:根据订单id查询
接口路径:GET /health/user/orders/{id}
请求数据类型 application/x-www-form-urlencoded
4.4.7.1 实现代码
Controller
@GetMapping("/{id}")
@ApiOperation("根据订单id查询")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersDetailResDTO detail(@PathVariable("id") Long id) {
return ordersService.getDetailById(id);
}
实现
@Override
public OrdersDetailResDTO getDetailById(Long id) {
Orders orders = getById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException("订单不存在");
}
OrdersDetailResDTO ordersDetailResDTO = BeanUtil.toBean(orders, OrdersDetailResDTO.class);
//查询是否取消
OrdersCancelled ordersCancelled = ordersCancelledMapper.selectById(id);
if (ObjectUtil.isNotNull(ordersCancelled)) {
ordersDetailResDTO.setCancelReason(ordersCancelled.getCancelReason());
ordersDetailResDTO.setCancelTime(ordersCancelled.getCancelTime());
}
return ordersDetailResDTO;
}
4.4.7.2 用户端综合测试
查看我的订单
订单详情页
把所有订单取消掉或者退款
手机收到退款
测试成功
再弄个超时订单
手动修改下单时间
稍等一分钟,xxl-job执行到后自动取消
说明我们写的用户端一切正常。
4.4.8管理端分页查询订单
接口名称:分页查询
接口路径:GET /health/admin/orders/page
请求数据类型 application/x-www-form-urlencoded
4.4.8.1 实现代码
Controller
@ApiOperation("分页查询")
@GetMapping("/page")
public PageResult<OrdersResDTO> pageQuery(OrdersPageQueryReqDTO ordersPageQueryReqDTO) {
return ordersService.adminPageQuery(ordersPageQueryReqDTO);
}
实现
@Override
public PageResult adminPageQuery(OrdersPageQueryReqDTO ordersPageQueryReqDTO) {
// 初始化分页对象
Page<Orders> page = new Page<>(ordersPageQueryReqDTO.getPageNo(), ordersPageQueryReqDTO.getPageSize());
// 创建LambdaQueryWrapper,添加条件时确保非空判断
LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery()
.eq(ObjectUtils.isNotNull(ordersPageQueryReqDTO.getOrderStatus()), Orders::getOrderStatus, ordersPageQueryReqDTO.getOrderStatus())
.like(ObjectUtils.isNotEmpty(ordersPageQueryReqDTO.getMemberPhone()), Orders::getMemberPhone, ordersPageQueryReqDTO.getMemberPhone());
// 执行分页查询
this.page(page, queryWrapper); // 确认this.page方法调用正确
// 将查询结果转换为DTO列表
List<OrdersResDTO> ordersResDTOList = page.getRecords().stream()
.map(orders -> BeanUtil.toBean(orders, OrdersResDTO.class))
.collect(Collectors.toList());
// 返回分页结果
return new PageResult<>();
}
实现
@Override
public PageResult adminPageQuery(OrdersPageQueryReqDTO ordersPageQueryReqDTO) {
// 初始化分页对象
Page<Orders> page = new Page<>(ordersPageQueryReqDTO.getPageNo(), ordersPageQueryReqDTO.getPageSize());
// 创建LambdaQueryWrapper,添加条件时确保非空判断
LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery()
.eq(ObjectUtils.isNotNull(ordersPageQueryReqDTO.getOrderStatus()), Orders::getOrderStatus, ordersPageQueryReqDTO.getOrderStatus())
.like(ObjectUtils.isNotEmpty(ordersPageQueryReqDTO.getMemberPhone()), Orders::getMemberPhone, ordersPageQueryReqDTO.getMemberPhone());
// 执行分页查询
this.page(page, queryWrapper); // 确认this.page方法调用正确
// 将查询结果转换为DTO列表
List<OrdersResDTO> ordersResDTOList = page.getRecords().stream()
.map(orders -> BeanUtil.toBean(orders, OrdersResDTO.class))
.collect(Collectors.toList());
PageResult pageResult = new PageResult();
pageResult.setTotal(page.getTotal());
pageResult.setList(ordersResDTOList);
// 返回分页结果
return pageResult;
}
4.4.9管理端根据状态统计订单数量
接口名称:根据状态统计数量
接口路径:GET /health/admin/orders/countByStatus
请求数据类型 application/x-www-form-urlencoded
请求参数:无
4.4.9.1 实现代码
@GetMapping("/countByStatus")
@ApiOperation("根据状态统计数量")
public OrdersCountResDTO countByStatus() {
return ordersService.countByStatus();
}
实现
@Override
public OrdersCountResDTO countByStatus() {
OrdersCountResDTO ordersCountResDTO = new OrdersCountResDTO();
//查询已体检订单数量
Integer checkedCount = lambdaQuery().eq(Orders::getOrderStatus, OrderStatusEnum.COMPLETED_CHECKUP.getStatus()).count();
ordersCountResDTO.setCompletedCheckupCount(checkedCount);
//查询待体检订单数量
Integer waitingCheckupCount = lambdaQuery().eq(Orders::getOrderStatus, OrderStatusEnum.WAITING_CHECKUP.getStatus()).count();
ordersCountResDTO.setWaitingCheckupCount(waitingCheckupCount);
//查询已取消订单数量
Integer cancelledCount = lambdaQuery().eq(Orders::getOrderStatus, OrderStatusEnum.CANCELLED.getStatus()).count();
ordersCountResDTO.setCancelledCount(cancelledCount);
//查询待支付订单数量
Integer noPayCount = lambdaQuery().eq(Orders::getOrderStatus, OrderStatusEnum.COMPLETED_CHECKUP.getStatus()).count();
ordersCountResDTO.setNoPayCount(noPayCount);
//查询已关闭订单数量
Integer closedCount = lambdaQuery().eq(Orders::getOrderStatus, OrderStatusEnum.CLOSED.getStatus()).count();
ordersCountResDTO.setClosedCount(closedCount);
//全部订单数量
ordersCountResDTO.setTotalCount(checkedCount + waitingCheckupCount + cancelledCount + noPayCount + closedCount);
return ordersCountResDTO;
}
4.4.10管理端查询订单详情
接口名称:根据订单id查询
接口路径:GET /health/admin/orders/{id}
请求数据类型 application/x-www-form-urlencoded
4.4.10.1 实现代码
@GetMapping("/{id}")
@ApiOperation("根据订单id查询")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public AdminOrdersDetailResDTO aggregation(@PathVariable("id") Long id) {
return ordersService.adminAggregation(id);
}
实现
@Override
public AdminOrdersDetailResDTO adminAggregation(Long id) {
Orders orders = getById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException("订单不存在");
}
//查询订单信息
AdminOrdersDetailResDTO adminOrdersDetailResDTO = new AdminOrdersDetailResDTO();
AdminOrdersDetailResDTO.OrderInfo orderInfo = new AdminOrdersDetailResDTO.OrderInfo();
BeanUtil.copyProperties(orders, orderInfo);
//查询支付信息
AdminOrdersDetailResDTO.PayInfo payInfo = new AdminOrdersDetailResDTO.PayInfo();
BeanUtil.copyProperties(orders, payInfo);
payInfo.setThirdOrderId(orders.getTransactionId());
//查询退款信息
AdminOrdersDetailResDTO.RefundInfo refundInfo = new AdminOrdersDetailResDTO.RefundInfo();
AdminOrdersDetailResDTO.CancelInfo cancelInfo = new AdminOrdersDetailResDTO.CancelInfo();
OrdersCancelled ordersCancelled = ordersCancelledMapper.selectById(id);
if(ObjectUtil.isNotNull(ordersCancelled)) {
if (Objects.equals(orders.getOrderStatus(), OrderStatusEnum.CANCELLED.getStatus())) {
cancelInfo.setCancelReason(ordersCancelled.getCancelReason());
cancelInfo.setCancelTime(ordersCancelled.getCancelTime());
}
else if (Objects.equals(orders.getOrderStatus(), OrderStatusEnum.CLOSED.getStatus())) {
refundInfo.setRefundId(orders.getRefundId());
refundInfo.setRefundStatus(orders.getPayStatus());
refundInfo.setCancelTime(ordersCancelled.getCancelTime());
refundInfo.setTradingChannel(orders.getTradingChannel());
refundInfo.setCancelReason(ordersCancelled.getCancelReason());
}
}
adminOrdersDetailResDTO.setOrderInfo(orderInfo);
adminOrdersDetailResDTO.setPayInfo(payInfo);
adminOrdersDetailResDTO.setRefundInfo(refundInfo);
adminOrdersDetailResDTO.setCancelInfo(cancelInfo);
return adminOrdersDetailResDTO;
}
4.4.10.2 管理端综合测试
打开管理端登陆时发现拒绝访问,我们还要把 /health/open/login/admin
放到我们gateway的白名单中
jzo2o:
access-path-white-list:
- /foundations/open/login
- /customer/open/login/worker
- /customer/open/login/common/user
- /customer/open/serve-provider/institution/register
- /v3/api-docs/swagger-config
- /doc.html
- /operation/v2/api-docs
- /orders-dispatch/v2/api-docs
- /orders-manager/v2/api-docs
- /orders-seize/v2/api-docs
- /foundations/v2/api-docs
- /publics/v2/api-docs
- /publics/sms-code/send
- /customer/agency/serve-provider/institution/resetPassword
- /trade/open/notify/wx/1561414331
- /trade/open/notify/alipay/2088241317544335
- /health/open/login/user
- /health/open/login/admin
测试
运行成功,非常完美
4.4.11 懒加载
在查询时懒加载超时订单
@Override
public OrdersDetailResDTO getDetailById(Long id) {
Orders orders = getById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException("订单不存在");
}
//懒加载
//如果支付过期则取消订单
orders = canalIfPayOvertime(orders);
OrdersDetailResDTO ordersDetailResDTO = BeanUtil.toBean(orders, OrdersDetailResDTO.class);
//查询是否取消
OrdersCancelled ordersCancelled = ordersCancelledMapper.selectById(id);
if (ObjectUtil.isNotNull(ordersCancelled)) {
ordersDetailResDTO.setCancelReason(ordersCancelled.getCancelReason());
ordersDetailResDTO.setCancelTime(ordersCancelled.getCancelTime());
}
return ordersDetailResDTO;
}
/**
* 如果支付过期则取消订单
* @param orders
*/
private Orders canalIfPayOvertime(Orders orders){
//创建订单未支付15分钟后自动取消
if(Objects.equals(orders.getOrderStatus(), OrderStatusEnum.NO_PAY.getStatus()) && orders.getCreateTime().plusMinutes(15).isBefore(LocalDateTime.now())){
//查询支付结果,如果支付最新状态仍是未支付进行取消订单
OrdersPayResDTO ordersPayResDTO = payResult(orders.getId());
int payResultFromTradServer = ordersPayResDTO.getPayStatus();
if(payResultFromTradServer != OrderPayStatusEnum.PAY_SUCCESS.getStatus()){
//取消订单
OrdersCancelled orderCancelDTO = BeanUtil.toBean(orders, OrdersCancelled.class);
orderCancelDTO.setCancellerType(UserType.SYSTEM);
orderCancelDTO.setCancelReason("订单超时支付,自动取消");
OrdersCancelledDTO ordersCancelledDTO = BeanUtil.copyProperties(orderCancelDTO, OrdersCancelledDTO.class);
CurrentUserInfo currentUserInfo = UserContext.currentUser();
ordersCancelledDTO.setCancellerId(currentUserInfo.getId());
ordersCancelledDTO.setCancellerName(currentUserInfo.getName());
ordersCancelledDTO.setCancellerType(currentUserInfo.getUserType());
cancel(ordersCancelledDTO);
orders = getById(orders.getId());
}
}
return orders;
}
5 实战要求
实战初始代码: jzo2o-health-01-0.zip ,从课程资料下的“即刻体检项目实战”目录获取。
仔细阅读需求及设计内容,组长组织讨论,确定每个接口的具体实现逻辑。
实战形式:分组实战,具体参考“项目实战说明文档”。
最后讨论确定每人的分工:
可按下边的模块进行分工:
- 管理端预约管理(管理端预约查询、用户端查询预约设置、管理端手动预约设置)
- 管理端批量预约设置
- 管理端和用户端订单查询(订单列表、订单详情)
- 管理端根据订单状态统计数量
- 用户端下单及支付(预约下单接口、生成支付二维码接口、确认支付结果接口)
- 通过MQ接收支付结果
- 用户端订单取消与退款(取消未支付订单、订单退款接口、支付超时处理)