学成在线 第06章 讲义-学生选课

1. 学生选课


在学生选课中,需要学员通过学成在线门户网站实现,查看学生的课程购买记录、学生购买课程的下单、学生支付收费课程、分布式任务调度查询支付结果等操作。本次主要是对学成的学生群体来开发对应的功能。

1.1 需求分析


本次主要针对学生学科的业务操作,主要功能包括:
●学生进入首页或课程搜索找到目标课程,进入某个课程的详情页,需要判断是否收费,如果收费需要查询改学员对此课程的购买记录。
●课程收费的情况下,学生对此课程会创建出购买课程的订单,完成课程购买的操作。
●学生需要进行支付,来完成对课程的购买。
●学生可以在学习中心中查看自己所有的所学的课程,包括收费的课程和免费课程。

1.1.1 业务流程


学生选课流程如下:
1.通过首页或课程搜索找到目标课程,进入课程详情页.
●免费课程的显示

Day13-第六章-学生选课-支付结果通知 - 图1

●收费课程的显示

Day13-第六章-学生选课-支付结果通知 - 图2

2.点击“课程价格”按钮,可进入提交订单页面

Day13-第六章-学生选课-支付结果通知 - 图3

3.确认订单无误,并进行课程支付成功后,跳转到课程详情页面,同时按钮变为”马上学习”,若已包含学习进度,该按钮显示为“继续学习”。

Day13-第六章-学生选课-支付结果通知 - 图4

Day13-第六章-学生选课-支付结果通知 - 图5

4.学生可在个人中心-我的课程中浏览已选课程信息

Day13-第六章-学生选课-支付结果通知 - 图6

学生选课流程图如下:
●学生选课流程图

Day13-第六章-学生选课-支付结果通知 - 图7

1.2 获取支付结果


在用户进行完订单支付后,由学成在线后端微服务向第三方支付系统平台查询用户支付的结果信息。如果支付完成,我们就要修改订单表中的业务数据。如果没有支付完,则不做操作。

1.2.1 系统交互流程


根据微信流程支付完成的交互流程图,学成可以对其 实现解决方案来实现:

Day13-第六章-学生选课-支付结果通知 - 图8

●学成获得支付结果交互流程图

Day13-第六章-学生选课-支付结果通知 - 图9

分析操作:
0.搭建分布式事务Seata环境
0.1 搭建Seata的TC服务并注册到Nacos中
0.2 将参与到服务添加Seata依赖并注册到TC服务中
1.微信支付成功后的回调
1.1 微信通知里的参数
1.2 如何解析微信通知数据(Github工具包—对WX的封装)
1.3 使用 RequestMapping 来获得数据(不知道微信的请求方式)
1.4 如何来告诉微信回调Controller地址
在统一下单时已经对WxPayConfig NotifyURL在Nacos已经配置
配置内网穿透的地址
1.5 外网的请求如何来调用局域网的接口地址
内网穿透(cpolar)
2.业务层操作:PayService(订单服务)
2.1.处理通知结果方法
解析通知结果
发送事务消息
2.2 处理本地事务
修改订单和订单支付记录的状态
3.业务层操作:学习记录(学习中心服务)
3.1 处理消息并执行本地方法
3.2 处理本地事务
创建用户的学习记录
判断课程的学习模式来给paid赋值
Seata 的 at 模式

Day13-第六章-学生选课-支付结果通知 - 图10

1.搭建TC(事务协调器)环境
搭建TC数据源环境(3张表)
TC服务在Docker环境中启动
TC注册到Nacos
2.搭建服务RM环境
各个服务导入Seata依赖
添加Seata配置
数据库添加快照表

1.2.2 构建Seata服务

1.2.2.1 构建Seata 容器环境


在 Centos7 中docker容器中完成下面操作:
1.下拉容器和创建容器

  1. #下拉镜像
  2. docker pull seataio/seata-server:1.4.2
  3. #创建容器
  4. docker run \
  5. -e SEATA_IP=192.168.94.129 \
  6. -e SEATA_PORT=8091 \
  7. --name seata-server \
  8. -p 8091:8091 \
  9. -d \
  10. seataio/seata-server:1.4.2
  11. #创建容器后可以进入到容器中
  12. docker exec -it seata-server sh
  13. #查看运行的日志
  14. docker logs -f seata-server

2.创建Seata的数据库和表
在mysql服务中添加数据库,导入今天 “资料/seata/seata.sql” 文件到数据库中,导入后的结果:

Day13-第六章-学生选课-支付结果通知 - 图11

3.配置seata的tc配置信息
在nacos中namespace下创建配置信息:seataServer.properties

  1. # 数据存储方式,db代表数据库
  2. store.mode=db
  3. store.db.datasource=druid
  4. store.db.dbType=mysql
  5. store.db.driverClassName=com.mysql.jdbc.Driver
  6. store.db.url=jdbc:mysql://192.168.94.129:3306/seata?useUnicode=true&rewriteBatchedStatements=true
  7. store.db.user=root
  8. store.db.password=root
  9. store.db.minConn=5
  10. store.db.maxConn=30
  11. store.db.globalTable=global_table
  12. store.db.branchTable=branch_table
  13. store.db.queryLimit=100
  14. store.db.lockTable=lock_table
  15. store.db.maxWait=5000
  16. # 事务、日志等配置
  17. server.recovery.committingRetryPeriod=1000
  18. server.recovery.asynCommittingRetryPeriod=1000
  19. server.recovery.rollbackingRetryPeriod=1000
  20. server.recovery.timeoutRetryPeriod=1000
  21. server.maxCommitRetryTimeout=-1
  22. server.maxRollbackRetryTimeout=-1
  23. server.rollbackRetryTimeoutUnlockEnable=false
  24. server.undo.logSaveDays=7
  25. server.undo.logDeletePeriod=86400000
  26. # 客户端与服务端传输方式
  27. transport.serialization=seata
  28. transport.compressor=none
  29. # 关闭metrics功能,提高性能
  30. metrics.enabled=false
  31. metrics.registryType=compact
  32. metrics.exporterList=prometheus
  33. metrics.exporterPrometheusPort=9898

将导入今天 “资料/seata/registry.conf” 导入到Centos环境中,并执行下面的操作:

  1. #将配置文件拷贝到seata容器中
  2. docker cp registry.conf seata-server:/seata-server/resources
  3. #退出容器
  4. exit
  5. #重新启动seata服务
  6. docker restart seata-server

PS:一定要修改registry.conf 配置信息
nacos中的 id地址、组名、namespace
重新启动后,seata-server 的tc服务已经注册到 nacos,并检查信息。

Day13-第六章-学生选课-支付结果通知 - 图12

4.通过Docker日志命令查看是否运行成功

Day13-第六章-学生选课-支付结果通知 - 图13

1.2.2.4 服务注册到TC服务中


1.微服务注册到 TC 服务中
在 order-service 和 learning-service 服务中的pom文件中添加seata的依赖,并添加配置。

  1. <!--seata-->
  2. <dependency>
  3. <groupId>com.alibaba.cloud</groupId>
  4. <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
  5. <exclusions>
  6. <!--版本较低,1.3.0,因此排除-->
  7. <exclusion>
  8. <artifactId>seata-spring-boot-starter</artifactId>
  9. <groupId>io.seata</groupId>
  10. </exclusion>
  11. </exclusions>
  12. </dependency>
  13. <dependency>
  14. <groupId>io.seata</groupId>
  15. <artifactId>seata-spring-boot-starter</artifactId>
  16. <!--seata starter 采用1.4.2版本-->
  17. <version>1.4.2</version>
  18. </dependency>

Seata中模式较为多,如下:
XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
TCC模式:最终一致的分阶段事务模式,有业务侵入
SAGA模式:长事务模式,有业务侵入
处于本项目的业务操作,AT模式完全满足本次操作,AT模式如下:

Day13-第六章-学生选课-支付结果通知 - 图14

在 order-service 和 learning-service 服务中的bootstrap.yml文件中添加seata配置,如下。

  1. seata:
  2. registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
  3. type: nacos # 注册中心类型 nacos
  4. nacos:
  5. server-addr: 192.168.94.129:8848 # nacos地址
  6. namespace: 2393d63b-3ef1-4764-ad32-767f8fea31e3 # namespace,默认为空
  7. group: ${group.name} # 分组,默认是DEFAULT_GROUP
  8. application: seata-tc-server # seata服务名称
  9. username: nacos
  10. password: nacos
  11. tx-service-group: seata-xc # 事务组名称
  12. service:
  13. vgroup-mapping: # 事务组与cluster的映射关系
  14. seata-xc: SH
  15. data-source-proxy-mode: AT

2.业务服务添加undo-log表
在 xc_order 和 xc_learning 数据添加 undo 表,sql文件如下:

  1. DROP TABLE IF EXISTS `undo_log`;
  2. CREATE TABLE `undo_log` (
  3. `branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
  4. `xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
  5. `context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'undo_log context,such as serialization',
  6. `rollback_info` longblob NOT NULL COMMENT 'rollback info',
  7. `log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
  8. `log_created` datetime(6) NOT NULL COMMENT 'create datetime',
  9. `log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
  10. UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
  11. ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;

1.2.3 添加支付后课程学习记录


在订单支付成功后,需要在学习中心服务添加用户的学习记录,在 xc-learing-service 中定义服务接口,如下:
在xc-api工程下 com.xuecheng.api.learning 添加接口信息:

1.2.3.1 接口定义


根据支付平台通知信息来定义接口
Http接口地址
post /learning/l/course-record/paid/{username}/{coursePubId}
1.接口参数列表
接口传入传出列表
传入参数:
Path方式:
username String 购课人的名称
coursePubId Long 课程发布id
传出参数:
无数据
在xc-api工程下的com.xuecheng.api.learning.CourseRecordApi 下定义方法

  1. /**
  2. * <p></p>
  3. *
  4. * @Description:
  5. */
  6. @Api(value = "学习记录api文档",tags = "学习记录Api接口文档信息")
  7. public interface CourseRecordApi {
  8. @ApiOperation("支付后创建用户学习记录")
  9. @ApiImplicitParams({
  10. @ApiImplicitParam(name = "username",value = "购课人账号",required = true,dataType = "String",paramType = "path"),
  11. @ApiImplicitParam(name = "coursePubId",value = "课程发布Id",required = true,dataType = "Long",paramType = "path")
  12. })
  13. void createCourseRecord4s(String username, Long coursePubId);
  14. }

1.2.3.2 接口开发


在 xc-learning-service 下的 CourseRecordService 业务类中实现

  1. /**
  2. * <p>
  3. * 选课记录 服务类
  4. * </p>
  5. */
  6. public interface CourseRecordService extends IService<CourseRecord> {
  7. /**
  8. * 订单支付后创建用户学习记录
  9. * @param courseRecordDTO
  10. * @return
  11. */
  12. void createCourseRecord4S(String username, Long coursePubId);
  13. }

其实现类 com.xuecheng.learning.service.impl.CourseRecordServiceImpl 实现代码如下:

  1. /*
  2. * 业务分析:
  3. * 1.判断关键数据
  4. * username coursePubId
  5. * 2.判断业务数据
  6. * 课程发布信息
  7. * 判断是否存在
  8. * 课程计划数据获得
  9. * 从课程发布信息中
  10. *
  11. * 3.保存用户的学习记录
  12. * 由于是先执行订单服务修改订单状态,在没有问题的情况下再创建学习记录
  13. * 创建学习记录
  14. * 默认章节为:第一章第一小节
  15. * 要给paid赋值为已经支付:1
  16. *
  17. * 4.将新增的数据查询并转化为dto并返回
  18. *
  19. * PS:
  20. * 如果出现业务上出操作数据问题,本业务层直接抛出异常,无需返回RestResponse规范接口数据
  21. 原因:本次的操作事务都是由Seata环境来控制,如果抛出异常,会是的整个的事务进行回滚操作
  22. *
  23. * */
  24. @Transactional
  25. public RestResponse<CourseRecordDTO> createCourseRecord4S(String username, Long coursePubId) {
  26. //1.判断关键数据
  27. // username coursePubId
  28. //
  29. //
  30. if (StringUtil.isBlank(username)||
  31. ObjectUtils.isEmpty(coursePubId)
  32. ) {
  33. ExceptionCast.cast(CommonErrorCode.E_100101);
  34. }
  35. // 2.判断业务数据
  36. // 课程发布信息
  37. // 判断是否存在
  38. RestResponse<CoursePubIndexDTO> pubIndexResponse = coursePubSearchApi.getCoursePubIndexById4s(coursePubId);
  39. if (!(pubIndexResponse.isSuccessful())) {
  40. ExceptionCast.castWithCodeAndDesc(pubIndexResponse.getCode(),pubIndexResponse.getMsg());
  41. }
  42. CoursePubIndexDTO coursePub = pubIndexResponse.getResult();
  43. // 课程计划数据获得
  44. // 从课程发布信息中
  45. String teachplanJsonString = coursePub.getTeachplan();
  46. TeachplanDTO teachplanDTO = JsonUtil.jsonToObject(teachplanJsonString, TeachplanDTO.class);
  47. // 获得第一章节
  48. List<TeachplanDTO> secPlanTreeNodes = teachplanDTO.getTeachPlanTreeNodes();
  49. if (CollectionUtils.isEmpty(secPlanTreeNodes)) {
  50. ExceptionCast.cast(LearningErrorCode.E_202205);
  51. }
  52. TeachplanDTO secTeachplan = secPlanTreeNodes.get(0);
  53. if (ObjectUtils.isEmpty(secTeachplan)) {
  54. ExceptionCast.cast(LearningErrorCode.E_202205);
  55. }
  56. // 获得三级课程计划数据
  57. List<TeachplanDTO> thridTeachplans = secTeachplan.getTeachPlanTreeNodes();
  58. if (CollectionUtils.isEmpty(thridTeachplans)) {
  59. ExceptionCast.cast(LearningErrorCode.E_202206);
  60. }
  61. TeachplanDTO thridTeachplan = thridTeachplans.get(0);
  62. if (ObjectUtils.isEmpty(thridTeachplan)) {
  63. ExceptionCast.cast(LearningErrorCode.E_202206);
  64. }
  65. // 3.保存用户的学习记录
  66. // 如果学员已经有学习记录--收费课程支付后,要重置数据(用户体验不好,但是可以解决大部分的问题)
  67. LambdaQueryWrapper<CourseRecord> queryWrapper = new LambdaQueryWrapper<>();
  68. queryWrapper.eq(CourseRecord::getUserName, username);
  69. queryWrapper.eq(CourseRecord::getCoursePubId, coursePubId);
  70. CourseRecord courseRecord = this.getOne(queryWrapper);
  71. boolean result = false;
  72. if (ObjectUtils.isEmpty(courseRecord)) {
  73. // 如果已经支付--判断用户是否已经支付
  74. // 无需操作-订单支付后的操作,由于是先执行订单服务修改订单状态,在没有问题的情况下再创建学习记录
  75. // 创建学习记录
  76. // 默认章节为:第一章第一小节
  77. // 要给paid赋值为已经支付:1
  78. courseRecord = new CourseRecord();
  79. courseRecord.setUserName(username);
  80. courseRecord.setCompanyId(coursePub.getCompanyId());
  81. courseRecord.setCourseId(coursePub.getCourseId());
  82. courseRecord.setCoursePubId(coursePub.getIndexId());
  83. courseRecord.setCoursePubName(coursePub.getName());
  84. courseRecord.setTeachmode(coursePub.getTeachmode());
  85. courseRecord.setTeachplanId(thridTeachplan.getTeachPlanId());
  86. courseRecord.setTeachplanName(thridTeachplan.getPname());
  87. courseRecord.setPaid(new Integer(PayCodeUrlResult.PAIED));
  88. result = this.save(courseRecord);
  89. } else {
  90. //要重置数据(用户体验不好,但是可以解决大部分的问题)
  91. LambdaUpdateWrapper<CourseRecord> updateWrapper = new LambdaUpdateWrapper<>();
  92. updateWrapper.set(CourseRecord::getTeachplanId, thridTeachplan.getTeachPlanId());
  93. updateWrapper.set(CourseRecord::getTeachplanName, thridTeachplan.getPname());
  94. updateWrapper.set(CourseRecord::getPaid,new Integer(PayCodeUrlResult.PAIED) );
  95. updateWrapper.set(CourseRecord::getChangeDate, LocalDateTime.now());
  96. updateWrapper.eq(CourseRecord::getId,courseRecord.getId() );
  97. result = this.update(updateWrapper);
  98. }
  99. if (!result) {
  100. ExceptionCast.cast(LearningErrorCode.E_202204);
  101. }
  102. // 4.将新增的数据查询并转化为dto并返回
  103. CourseRecord po = this.getById(courseRecord.getId());
  104. CourseRecordDTO resultDTO = CourseRecordConvert.INSTANCE.entity2dto(po);
  105. return RestResponse.success(resultDTO);
  106. }

Controller层实现

  1. package com.xuecheng.learning.controller;
  2. import com.xuecheng.api.learning.CourseRecordApi;
  3. import com.xuecheng.api.learning.model.dto.CourseRecordDTO;
  4. import com.xuecheng.learning.common.utils.UAASecurityUtil;
  5. import com.xuecheng.learning.service.CourseRecordService;
  6. import lombok.extern.slf4j.Slf4j;
  7. import org.springframework.beans.factory.annotation.Autowired;
  8. import org.springframework.web.bind.annotation.GetMapping;
  9. import org.springframework.web.bind.annotation.PathVariable;
  10. import org.springframework.web.bind.annotation.RestController;
  11. /**
  12. * <p>
  13. * 选课记录 前端控制器
  14. * </p>
  15. *
  16. * @author itcast
  17. */
  18. @Slf4j
  19. @RestController
  20. public class CourseRecordController implements CourseRecordApi {
  21. @Autowired
  22. private CourseRecordService courseRecordService;
  23. @PostMapping("l/course-record/paid/{username}/{coursePubId}")
  24. public RestResponse<CourseRecordDTO> createCourseRecord4S(@PathVariable String username,
  25. @PathVariable Long coursePubId) {
  26. return courseRecordService.createCourseRecord4S(username, coursePubId);
  27. }
  28. }

1.2.4 支付通知功能


本功能将实现第三方支付平台通知学成在线订单服务接口支付成功消息。

1.2.4.1 支付通知功能实现接口定义


根据支付平台通知信息来定义接口
Http接口地址
无请求方式 /order/order-pay/wx-pay/notify-result
1.接口参数列表
接口传入传出列表
根据微信平台 支付通知接口 ,接口接收和响应的参数都为xml格式,再次可以使用相关依赖包将其进行封装处理,如下:
●传入传出参数依赖包封装Api: 代码中Result为自定义返回对象,WxPayUnifiedOrderRequest 中还有一些参数会根据配置文件的配置自动填充,不需要单独设置,例如:appId、partnerId、notifyURL、tradeType等参数。 订单创建成功之后会自动调用校验方法,校验微信返回的结果。例如校验签名,校验返回的业务代码是否正常。所以代码中不需要手动校验订单创建结果,只需要处理下异常情况即可!

  1. @ResponseBody
  2. @RequestMapping("接口地址(需要开发者自行定义)")
  3. public String payNotify(HttpServletRequest request, HttpServletResponse response) {
  4. try {
  5. String xmlResult = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
  6. WxPayOrderNotifyResult result = wxPayService.parseOrderNotifyResult(xmlResult);
  7. // 加入自己处理订单的业务逻辑,需要判断订单是否已经支付过,否则可能会重复调用
  8. String orderId = result.getOutTradeNo();
  9. String tradeNo = result.getTransactionId();
  10. String totalFee = BaseWxPayResult.fenToYuan(result.getTotalFee());
  11. return WxPayNotifyResponse.success("处理成功!");
  12. } catch (Exception e) {
  13. log.error("微信回调结果异常,异常原因{}", e.getMessage());
  14. return WxPayNotifyResponse.fail(e.getMessage());
  15. }
  16. }

参考文档连接:https://github.com/Wechat-Group/WxJava/wiki/微信支付
2. 接口编写
在 xc-order-service 工程的 OrderController 定义接口:

  1. /**
  2. * <p>
  3. * 订单 前端控制器
  4. * </p>
  5. *
  6. * @author itcast
  7. */
  8. @RestController
  9. public class OrdersController implements OrderApi {
  10. @Autowired
  11. private OrdersService ordersService;
  12. @Autowired
  13. private PayService payService;
  14. // 其他代码省略
  15. /* 给wx支付平台来调用的方法,没有在Api接口中定义方法 */
  16. @RequestMapping("order-pay/wx-pay/notify-result")
  17. public String notifyPayment(HttpServletRequest request) {
  18. System.out.println("订单支付通知方法开始执行");
  19. try {
  20. String xmlResult = IOUtils.toString(request.getInputStream(), request.getCharacterEncoding());
  21. // 加入自己处理订单的业务逻辑,需要判断订单是否已经支付过,否则可能会重复调用
  22. // 调用service层处理业务
  23. payService.notifyPayment(xmlResult);
  24. return WxPayNotifyResponse.success("处理成功!");
  25. } catch (Exception e) {
  26. log.error("微信回调结果异常,异常原因{}", e.getMessage());
  27. return WxPayNotifyResponse.fail(e.getMessage());
  28. }
  29. }
  30. }

PS:由于此接口是给第三方支付系统来使用,并不是在给前端来使用,所有无需在 OrderApi 接口中定义方法。

1.2.4.2 支付通知功能实现接口实现


在支付通知实现中,需要根据流程图完成下面的件事:
1将第三方支付结果数据进行解析
2修改订单服务中订单状态和订单支付状态
3调用学习中心添加用户收费课程的学习记录
4返回消息给支付平台
下面就来通过 PayService 来实现此功能。
0.编写关联Feign接口调用查询
在 xc-order-service 服务添加 Feign 接口

  1. package com.xuecheng.order.agent;
  2. import com.xuecheng.api.search.model.dto.CoursePubIndexDTO;
  3. import com.xuecheng.common.constant.XcFeignServiceNameList;
  4. import com.xuecheng.common.domain.response.RestResponse;
  5. import org.springframework.cloud.openfeign.FeignClient;
  6. import org.springframework.web.bind.annotation.GetMapping;
  7. import org.springframework.web.bind.annotation.PathVariable;
  8. /**
  9. * <p></p>
  10. *
  11. * @Description:
  12. */
  13. @FeignClient(XcFeignServiceNameList.XC_SEARCH_SERVICE)
  14. public interface CoursePubIndexApiAgent {
  15. @GetMapping("/search/l/course-index/{coursePubId}")
  16. RestResponse<CoursePubIndexDTO> getCoursePubIndexById4s(@PathVariable
  17. Long coursePubId);
  18. }
  1. package com.xuecheng.order.agent;
  2. import com.xuecheng.api.learning.model.dto.CourseRecordDTO;
  3. import com.xuecheng.common.constant.XcFeignServiceNameList;
  4. import com.xuecheng.common.domain.response.RestResponse;
  5. import org.springframework.cloud.openfeign.FeignClient;
  6. import org.springframework.web.bind.annotation.PathVariable;
  7. import org.springframework.web.bind.annotation.PostMapping;
  8. /**
  9. * <p></p>
  10. *
  11. * @Description:
  12. */
  13. @FeignClient(value = XcFeignServiceNameList.XC_LEARNING_SERVICE)
  14. public interface LearningApiAgent {
  15. String PREFIX_FLAG = "/learning/l/";
  16. @PostMapping(PREFIX_FLAG+"course-record/paid/{username}/{coursePubId}")
  17. RestResponse<CourseRecordDTO> createCourseRecord4S(@PathVariable String username, @PathVariable Long coursePubId);
  18. }

1.编写业务处理实现
在 xc-order-service 工程的 PayService 声明下面接口。

  1. package com.xuecheng.order.service;
  2. import com.baomidou.mybatisplus.extension.service.IService;
  3. import com.xuecheng.api.order.model.PayResultModel;
  4. import com.xuecheng.order.entity.Pay;
  5. import java.util.Map;
  6. /**
  7. * <p>
  8. * 订单支付信息 服务类
  9. * </p>
  10. */
  11. public interface PayService extends IService<Pay> {
  12. /**
  13. * 处理 WX 支付平台支付结果消息
  14. * @param xmlResult
  15. */
  16. String notifyPayResult(String xmlResult) throws Exception;
  17. }

实现类:

  1. package com.xuecheng.order.service.impl;
  2. /**
  3. * <p>
  4. * 订单支付信息 服务实现类
  5. * </p>
  6. *
  7. * @author itcast
  8. */
  9. @Slf4j
  10. @Service
  11. public class PayServiceImpl extends ServiceImpl<PayMapper, Pay> implements PayService {
  12. @Autowired
  13. private WxPayService wxPayService;
  14. @Autowired
  15. private OrdersService ordersService;
  16. @Autowired
  17. private LearningApiAgent learningApiAgent;
  18. /*
  19. * 业务操作
  20. * 1.判断并解析wx通知内容
  21. * 保证wx支付是成功:returncode和resultcode为SUCCESS
  22. * 如果支付失败,无需完成订单服务的业务逻辑操作
  23. * 2.修改订单状态
  24. *
  25. * 完成消息幂等(wx支付平台会重复发消息)
  26. * 判断订单是否已经支付
  27. * 通过订单编号:out_trade_no
  28. *
  29. * PS:如果业务数据操作失败,需要让wx支付平台继续通知,继续通知的方式是业务层对外抛出异常
  30. *
  31. * 订单数据:status
  32. * 判断订单是否存在
  33. * 判断订单的状态:支付前状态必须初始态
  34. * 修改内容:
  35. * status
  36. * 订单支付数据:支付后的结果
  37. * 判断订单支付是否存在
  38. * 判断订单支付的状态:status
  39. *
  40. * 修改内容:
  41. * status
  42. * pay_number
  43. * pay_date
  44. * receipt_amount
  45. * buyer_pay_amount
  46. * pay_response
  47. *
  48. * 3.给用户支付后的内容创建一个默认的学习记录
  49. * 在学习中心完成:Feign远程调用
  50. *
  51. *
  52. * 4.返回通知结果-wx平台
  53. * 如果正常,执行代码就可以
  54. * 如果数据不正常,业务需要抛出异常
  55. * controller会针对service的执行结果来返回数据给wx支付平台
  56. * */
  57. @Transactional
  58. public void notifyPayment(String xmlResultString) {
  59. // 1.判断并解析wx通知内容
  60. // 保证wx支付是成功:returncode和resultcode为SUCCESS
  61. // 如果支付失败,无需完成订单服务的业务逻辑操作
  62. if (StringUtil.isBlank(xmlResultString)) {
  63. ExceptionCast.cast(CommonErrorCode.E_100101);
  64. }
  65. WxPayOrderNotifyResult notifyResult = null;
  66. try {
  67. notifyResult = wxPayService.parseOrderNotifyResult(xmlResultString);
  68. } catch (WxPayException e) {
  69. log.error(OrderErrorCode.E_160018.getDesc()+" : errMsg {}",e.getMessage());
  70. ExceptionCast.cast(OrderErrorCode.E_160018);
  71. }
  72. String returnCode = notifyResult.getReturnCode();
  73. String resultCode = notifyResult.getResultCode();
  74. String outTradeNo = notifyResult.getOutTradeNo();
  75. // 支付通知成功
  76. if (PayCodeUrlResult.WX_PAY_SUCCESS_FLAG.equalsIgnoreCase(returnCode)&&
  77. PayCodeUrlResult.WX_PAY_SUCCESS_FLAG.equalsIgnoreCase(resultCode)
  78. ) {
  79. // 2.修改订单状态
  80. // 完成消息幂等(wx支付平台会重复发消息)
  81. // 判断订单是否已经支付
  82. // 通过订单编号:out_trade_no
  83. LambdaQueryWrapper<Orders> ordersQueryWrapper = new LambdaQueryWrapper<>();
  84. // 创建订单没有支付的查询条件
  85. ordersQueryWrapper.eq(Orders::getOrderNo, outTradeNo);
  86. ordersQueryWrapper.eq(Orders::getStatus,new Integer(OrderDealStatusEnum.ORDER_DEAL_INIT_STATUS.getCode()));
  87. int count = ordersService.count(ordersQueryWrapper);
  88. if (count < 1) {
  89. log.error("订单信息不是初始态,订单编号:{}", outTradeNo);
  90. return;
  91. }
  92. // 订单数据:status
  93. // 判断订单是否存在
  94. // 判断订单的状态:支付前状态必须初始态
  95. // 修改内容:
  96. // status
  97. LambdaUpdateWrapper<Orders> ordersUpdateWrapper = new LambdaUpdateWrapper<>();
  98. ordersUpdateWrapper.set(Orders::getStatus, new Integer(OrderDealStatusEnum.ORDER_DEAL_PAID_STATUS.getCode()));
  99. ordersUpdateWrapper.set(Orders::getChangeDate, LocalDateTime.now());
  100. ordersUpdateWrapper.eq(Orders::getOrderNo,outTradeNo);
  101. boolean orderResult = ordersService.update(ordersUpdateWrapper);
  102. // 如果业务数据操作失败,需要让wx支付平台继续通知,继续通知的方式是业务层对外抛出异常
  103. if (!orderResult) {
  104. ExceptionCast.cast(OrderErrorCode.E_160015);
  105. }
  106. // 订单支付数据:支付后的结果
  107. // 判断订单支付是否存在
  108. // 判断订单支付的状态:status 0
  109. // 修改内容:
  110. // status
  111. // pay_number
  112. // pay_date
  113. // receipt_amount
  114. // buyer_pay_amount
  115. // pay_response
  116. ordersQueryWrapper = new LambdaQueryWrapper<>();
  117. ordersQueryWrapper.eq(Orders::getOrderNo, outTradeNo);
  118. Orders orders = ordersService.getOne(ordersQueryWrapper);
  119. LambdaQueryWrapper<Pay> payQueryWrapper = new LambdaQueryWrapper<>();
  120. payQueryWrapper.eq(Pay::getOrderId,orders.getId());
  121. payQueryWrapper.eq(Pay::getStatus,PayCodeUrlResult.NOT_PAY);
  122. Pay pay = this.getOne(payQueryWrapper);
  123. if (ObjectUtils.isEmpty(pay)) {
  124. log.error("订单支付信息不是未支付,订单编号:{}", outTradeNo);
  125. return;
  126. }
  127. pay.setStatus(PayCodeUrlResult.PAIED);
  128. // 记录wx的支付编号
  129. pay.setPayNumber(notifyResult.getTransactionId());
  130. // 记录wx支付平台的用户支付时间
  131. String timeEnd = notifyResult.getTimeEnd();
  132. DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
  133. LocalDateTime payTime = LocalDateTime.parse(timeEnd, dateTimeFormatter);
  134. pay.setPayDate(payTime);
  135. Integer settlementTotalFee = notifyResult.getSettlementTotalFee();
  136. if (!(ObjectUtils.isEmpty(settlementTotalFee))) {
  137. String settlementTotalFeeString = BaseWxPayResult.fenToYuan(settlementTotalFee);
  138. pay.setReceiptAmount(new BigDecimal(settlementTotalFeeString));
  139. }
  140. Integer cashFee = notifyResult.getCashFee();
  141. if (!(ObjectUtils.isEmpty(cashFee))) {
  142. String cashFeeString = BaseWxPayResult.fenToYuan(cashFee);
  143. pay.setBuyerPayAmount(new BigDecimal(cashFeeString));
  144. }
  145. pay.setPayResponse(xmlResultString);
  146. boolean payResult = this.updateById(pay);
  147. if (!payResult) {
  148. ExceptionCast.cast(OrderErrorCode.E_160017);
  149. }
  150. // 3.给用户支付后的内容创建一个默认的学习记录
  151. // 在学习中心完成:Feign远程调用
  152. learningApiAgent.createCourseRecord4S(orders.getUserName(),orders.getCoursePubId());
  153. // 支付通知失败
  154. } else {
  155. log.error("支付通过内容失败,订单编号:{}", outTradeNo);
  156. }
  157. }
  158. }

1.2.4.3 支付通知接口内网穿透

1.内网和外网的介绍
现如今的网络环境是分为内网和外网,下面我们来简单来说明下它们的区别:
内网:通俗的说就是局域网, 内网的计算机以NAT(Network Address Translation )网络地址转换协议,通过一个公共的网关访问Internet。内网的计算机可向Internet上的其他计算机发送连接请求,但Internet上其他的计算机无法向内网的计算机发送连接请求。
外网:通俗的说就是与因特网相通的WAN(Wide Area Network )广域网,外网的计算机和Internet上的其他计算机可随意互相访问。
内网和外网示意图:

Day13-第六章-学生选课-支付结果通知 - 图15

根据网络环境的特点得出下面的结果:
1.内网可以访问外网的服务吗?
可以通过 modem 调制解调器来访问互联网服务上的资源信息。
2.外网可以访问内网的终端吗?
互联网中通过 WAN 来相互访问,WAN 网无法通过直接访问 NAT 协议下的内网。
3.学成测试开发是在内网环境下和还是外网环境下开发?
学成是在内网环境下进行开发。
4.第三方支付平台可以调用学成测试环境下的支付通知接口吗?
不能,第三方支付平台是在外网环境下,无法直接访问内网环境下的学成支付通知接口路径地址。

2.外网调用支付通知接口解决方案
大体有两种解决方案:
1.购买第三方云服务
第三方的云服务是具有内网和外网两个地址,支付平台可以通过云服务的外网地址来访问云服务上的接口。
2.使用内网穿透工具
在项目开发阶段,可以使用内网穿透工具使得外网服务间接调用内网环境的接口。
针对上面两个方案的对比:
1.云服务:云服务需要进行购买,学成环境要求云服务的配置加高,开发阶段成本较大,所以开发阶段不会考虑。项目正式上线会购买云服务,并将项目部署到云服务环境中。
2.内网穿透工具:几乎为零成本,只需要本机可以访问互联网,并能按照软件即可。

3.内网穿透介绍
内网穿透,也即 NAT 穿透 。它可以让外网主机在网络中与 内网 NAT 设备进行相互访问穿透方法。

Day13-第六章-学生选课-支付结果通知 - 图16

4.内网穿透实现
内容穿透需要在本机中相关的软件来实现,本次我们将采用 cpolar 来实现,参考下发资料中的 cpolar 的安装和配置。
配置好后,需要对本地的网关进行内容穿透

Day13-第六章-学生选课-支付结果通知 - 图17

内网穿透订单的端口号

Day13-第六章-学生选课-支付结果通知 - 图18

在 nacos 中的 order-service-dev.properties 中修改支付通知消息:

  1. #微信回调商户的地址
  2. weixinpay.notify-url = http://自己的域名/order/order-pay/wx-pay/notify-result

1.2.8 业务功能集成测试


启动下面服务
●后端服务
○xc-uaa-gateway-sever(UAA网关中心:63010)
○xc-user-service(用户中心:63130)
○xc-uaa(认证中心:63020)
○xc-teaching-service(教学管理微服务:63060)
○xc-content-search-service(内容搜索微服务:63080)
○xc-order-service(订单管理微服务:63090)
○xc-system-service(系统管理微服务:63110)
○xc-learning-service (学习中心:63070)
●其他服务
○Mysql
○Nacos
○rocketMQ
○Nginx
○ElasticSearch
●前端工程
○project-xczx2-portal-vue-ts 门户前端工程

1.3 查询已选课程


本功能需要在学习中心微服务中进行开发,来查询学生已经购买的课程数据。

3.7.1 系统交互流程

Day13-第六章-学生选课-支付结果通知 - 图19

  1. 1)用户进入 **用户中心-我的课程** 页面,前端向 学习中心 发起**课程记录**检索<br /> 2)**学习中心** 检索当前登录用户的课程记录返回给前端<br /> 3)前端展示当前登录用户已选课程的列表

3.7.2 已选课程接口定义


根据前后端传入参数列表来定义接口
Http接口地址

Day13-第六章-学生选课-支付结果通知 - 图20

1.接口参数列表
接口传入传出列表

Day13-第六章-学生选课-支付结果通知 - 图21

2.接口参数定义
传入参数为分页数据,会使用 PageRequestParams 来封装参数。
传出参数无需定义,传出参数 CourseRecordDTO,之前已经通过代码生成器生成,无需再次定义。
3. 接口编写
在 xc-api 工程的 com.xuecheng.api.learning 包中增加 CourseRecordApi 定义:

  1. package com.xuecheng.api.learning;
  2. import com.xuecheng.api.learning.model.CourseRecordDTO;
  3. import io.swagger.annotations.Api;
  4. import io.swagger.annotations.ApiImplicitParam;
  5. import io.swagger.annotations.ApiOperation;
  6. @Api(value = "用户的学习课程(选课)列表、 一个课程的学习情况 及更新课程的进度")
  7. public interface CourseRecordApi {
  8. @ApiOperation(value = "查询用户课程记录列表(我的选课信息列表)")
  9. PageVO<CourseRecordDTO> queryCourseRecordList(PageRequestParams pageParams);
  10. }

3.7.3 已选课程接口开发


1.DAO编写
Mybatis Plus 已经简化了单表操作,它提供的 Api 就可以完成添加数据操作,所有不需要进行编写。
2.service 编写
●接口
服务层接口定义,在com.xuecheng.learning.service.CourseRecordService中新增接口如下:

  1. package com.xuecheng.learning.service;
  2. import com.baomidou.mybatisplus.extension.service.IService;
  3. import com.xuecheng.api.learning.model.CourseRecordDTO;
  4. import com.xuecheng.learning.entity.CourseRecord;
  5. /**
  6. * 选课记录 服务类
  7. */
  8. public interface CourseRecordService extends IService<CourseRecord> {
  9. //其他代码省略
  10. /**
  11. * 根据用户名查询课程学习记录
  12. * @param userName 用户名
  13. * @param params 查询参数
  14. * @return
  15. */
  16. PageVO<CourseRecordDTO> queryCourseRecordList(String userName, PageRequestParams params);
  17. }

●实现类
服务层实现,在com.xuecheng.learning.service.CourseRecordServiceImpl中新增接口实现如下:

  1. /**
  2. * 选课记录 服务实现类
  3. */
  4. @Slf4j
  5. @Service
  6. public class CourseRecordServiceImpl extends ServiceImpl<CourseRecordMapper, CourseRecord> implements CourseRecordService {
  7. @Override
  8. public PageVO<CourseRecordDTO> queryCourseRecordList(String username,
  9. PageRequestParams pageParams) {
  10. // 1.判断传入参数
  11. if (pageParams.getPageNo() < 1) {
  12. pageParams.setPageNo(1L);
  13. }
  14. if (pageParams.getPageSize() < 1) {
  15. pageParams.setPageSize(5);
  16. }
  17. //2.创建查询条件
  18. LambdaQueryWrapper<CourseRecord> queryWrapper = new LambdaQueryWrapper<>();
  19. queryWrapper.eq(CourseRecord::getUserName, username);
  20. queryWrapper.orderByDesc(CourseRecord::getChangeDate);
  21. //3.创建分页数据
  22. Page page = new Page<>(pageParams.getPageNo(),pageParams.getPageSize());
  23. //4.调用方法进行条件分页查询
  24. IPage<CourseRecord> pageResult = this.page(page, queryWrapper);
  25. //5.将 PO 数据转为 DTO 数据
  26. List<CourseRecordDTO> dtos = new ArrayList<>();
  27. if (!(ObjectUtils.isEmpty(pageResult.getRecords())) || pageResult.getSize() > 0) {
  28. dtos = CourseRecordConvert.INSTANCE.entitys2dtos(pageResult.getRecords());
  29. }
  30. //6. 构建 PageVO数据
  31. PageVO<CourseRecordDTO> resultPage =
  32. new PageVO<>(dtos, pageResult.getTotal(),
  33. pageParams.getPageNo(), pageParams.getPageSize());
  34. return resultPage;
  35. }
  36. }

(2)Controller实现

  1. package com.xuecheng.learning.controller;
  2. import com.xuecheng.api.learning.LearnedRecordAPI;
  3. import com.xuecheng.api.learning.model.CourseRecordDTO;
  4. import com.xuecheng.api.uaa.model.LoginUser;
  5. import com.xuecheng.learning.common.SecurityUtil;
  6. import com.xuecheng.learning.service.CourseRecordService;
  7. import org.springframework.beans.factory.annotation.Autowired;
  8. import org.springframework.web.bind.annotation.GetMapping;
  9. import org.springframework.web.bind.annotation.PathVariable;
  10. /**
  11. * 选课记录 前端控制器
  12. */
  13. public class CourseRecordController implements CourseRecordAPI {
  14. @Autowired
  15. private CourseRecordService courseRecordService;
  16. //其他代码省略
  17. @PostMapping("learnedRecords/list")
  18. public PageVO<CourseRecordDTO> queryCourseRecordList(PageRequestParams pageParams) {
  19. LoginUser user = UAASecurityUtil.getUser();
  20. return courseRecordService.queryCourseRecordList(user.getUsername(), pageParams);
  21. }
  22. }

3.7.4 已选课程接口测试


1.按照接口定义,请求Method为POST
2.参数是通过 Restful 方法进行传递
3.接口地址如下:http://127.0.0.1:63070/learning/learnedRecored/list
使用postman进行接口测试,请求界面如下:

Day13-第六章-学生选课-支付结果通知 - 图22

响应内容如下:

Day13-第六章-学生选课-支付结果通知 - 图23