在此特别感谢黑马程序员提供的课程

写在最前

模块需求分析

模块介绍

  • 本模块实现了学生选课、下单支付、学习的整体流程
  • 网站的课程有收费和免费两种
    • 对于免费课程,学生选课后可以直接学习
    • 对于收费课程,学生需要下单且支付成功后,方可选课、学习
  • 选课:是将课程加入到我的课程表的过程
  • 我的课程表:记录我在网站学习的课程,我的课程表中有免费课程和收费课程两种
    • 对于免费课程可以直接添加到我的课程表
    • 对于收费课程需要下单、支付成功后自动添加到我的课程表
  • 模块流程如下

业务流程

学习引导

  • 用户通过搜索课程、课程推荐等信息进入课程详情页面,点击马上学习引导进入课程学习界面去学习,流程如下
    1. 进入课程详情,点击马上学习
    2. 课程免费时,引导加入我的课程表,或者进入学习界面
    3. 课程收费时,引导去支付、试学

选课流程

  • 选课是将课程加入我的课程表的过程
  • 对于免费课程,选课后可以直接加入到我的课程表
  • 对于收费课程,需要下单支付成功后,系统自动加入我的课程表

支付流程

  • 本项目与第三方支付平台对接完成支付操作

在线学习

  • 选课成功,用户可以在线学习,对于免费课程,无需选课即可在线学习

免费课程续期

  • 免费课程加入我的课程表后,默认为一年有效期,到期用户可申请续期

添加选课

需求分析

数据模型

  • 首先创建xc_learning数据库,导入黑马提供的sql脚本,里面包含三张表:xc_choose_course(选课记录表)、xc_course_tables(我的课程表)、xc_learn_record(学习记录表)
  • 选课是将课程加入我的课程表的过程,根据选课业务流程进行详细分析,业务流程如下
  • 选课信息存入选课记录表
    • 如果选的是免费课程,除了要将信息存入选课记录表,同时也要存入我的课程表
    • 如果选的是收费课程,将信息存入选课信息表后,要经过下单、支付成功后,才可以存入我的课程表
  • 选课记录表结构
    • 选课类型:免费课程、收费课程
    • 选课状态:选课成功、待支付、选课删除
    • 对于免费课程:课程价格为0,默认有效期为365天,开始服务时间为选课时间,结束服务时间为选课时间加一年后的时间,选课状态为选课成功
    • 对于收费课程:按课程的现价、有效期确定开始服务时间、结束服务时间、选课状态为待支付
    • 收费课程的选课记录需要支付成功后,选课状态为选课成功
  • 我的课程表
    • 对于免费课程:创建选课记录时,同时向我的课程表添加记录
    • 对于收费课程:创建选课记录后,需要下单支付成功后,自动向我的课程表添加记录
  • 选课流程如下

接口开发

创建学习中心工程

  • 拷贝黑马提供的xuecheng-plus-learning工程到项目根目录
  • 在nacos中添加配置文件learning-api-dev.yaml
1
2
3
4
server:
servlet:
context-path: /learning
port: 53020
  • 在nacos中添加配置文件learning-service-dev.yaml
1
2
3
4
5
6
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/xc_learning?serverTimezone=UTC&userUnicode=true&useSSL=false&
username: root
password: root

添加查询课程接口

  • 内容管理服务提供查询课程信息接口,此接口从课程发布表查询内容
  • 此接口主要供其他微服务远程调用,所以此接口不用授权,本项目标记此类接口统一以/r开头
  • 在内容管理服务中添加查询课程接口
1
2
3
4
5
@ApiOperation("查询课程发布信息")
@GetMapping("/r/coursepublish/{courseId}")
public CoursePublish getCoursePublish(@PathVariable("courseId") Long courseId) {
return coursePublishService.getCoursePunlish(courseId);
}
  • 对应的Service实现方法如下
1
2
3
4
@Override
public CoursePublish getCoursePunlish(Long courseId) {
return coursePublishMapper.selectById(courseId);
}
  • 使用HttpClient测试接口
1
2
3
#### 查询课程发布信息
GET {{content_host}}/content/r/coursepublish/160
Content-Type: application/json
  • 如果这里报未授权,请检查内容管理模块下的ResourceServerConfig中是否配置了/r/**的授权策略,如果配了,注释掉
1
2
3
4
5
6
7
@Override
public void configure(HttpSecurity http) throws Exception {
http.csrf().disable() // 禁用 CSRF 保护
.authorizeRequests() //配置对请求的授权策略
//.antMatchers("/r/**", "/course/**").authenticated() // 指定 "/r/" 和 "/course/" 这两个路径需要进行身份认证才能访问。
.anyRequest().permitAll(); // 允许所有其他请求(除了上面指定的路径之外)都可以被访问,不需要进行身份认证。
}

远程调用查询课程信息接口

  • 学习中心服务远程调用内容管理服务的查询课程发布信息接口
  1. 在学习中心service工程添加Feign接口
1
2
3
4
5
6
7
@FeignClient(value = "content-api", fallbackFactory = ContentServiceClientFallbackFactory.class)
@RequestMapping("/content")
public interface ContentServiceClient {

@GetMapping("/r/coursepublish/{courseId}")
CoursePublish getCoursePublish(@PathVariable("courseId") Long courseId);
}
  1. 编写降级方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
@Component
public class ContentServiceClientFallbackFactory implements FallbackFactory<ContentServiceClient> {
@Override
public ContentServiceClient create(Throwable throwable) {
return new ContentServiceClient() {
@Override
public CoursePublish getCoursePublish(Long courseId) {
log.error("远程调用内容管理服务熔断异常:{}",throwable.getMessage());
return new CoursePublish();
}
};
}
}
  1. 在启动类添加@EnableFeignClients(basePackages={“com.xuecheng.*.feignclient”})
1
2
3
4
5
6
7
8
@EnableFeignClients(basePackages = {"com.xuecheng.*.feignclient"})
@SpringBootApplication
public class LearningApiApplication {

public static void main(String[] args) {
SpringApplication.run(LearningApiApplication.class, args);
}
}
  1. 编写测试接口
1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootTest
public class FeignClientTest {

@Autowired
ContentServiceClient contentServiceClient;

@Test
public void testContentServiceClient() {
CoursePublish coursepublish = contentServiceClient.getCoursePublish(160L);
System.out.println(coursepublish);
}
}
  • 在进行feign远程调用时,会将字符串转成LocalDateTime,在CoursePublish类中的LocalDateTime类型的属性上添加如下代码
1
@JsonFormat(shape = JsonFormat.Shape.STRING,pattern = "yyyy-MM-dd HH:mm:ss")
  • 启动测试,控制台可以输出对应的课程发布信息

添加选课接口

######## 接口分析

  • 本接口支持免费课程选课、收费课程选课
    • 免费课程选课:添加选课记录、添加我的课程表
    • 收费课程选课:添加选课记录(由于支付功能我们还没做,所以暂时不添加到我的课程表)

######## 接口定义

  1. 请求参数:课程id、当前用户id
  2. 响应结果:选课记录信息、学习资格
  • 定义的接口如下
1
2
3
4
5
6
7
8
9
10
11
@Api(value = "我的课程表接口", tags = "我的课程表接口")
@Slf4j
@RestController
public class MyCourseTablesController {
@ApiOperation("添加选课")
@PostMapping("/choosecourse/{courseId}")
public XcChooseCourseDto addChooseCourse(@PathVariable("courseId") Long courseId) {

return null;
}
}
  • Service接口定义
1
2
3
4
5
6
7
8
public interface MyCourseTablesService {
/**
* 添加选课
* @param userId 用户id
* @param courseId 课程id
*/
XcChooseCourseDto addChooseCourse(String userId, Long courseId);
}
  • 对应的接口实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Slf4j
@Service
@Transactional
public class MyCourseTablesServiceImpl implements MyCourseTablesService {
@Autowired
ContentServiceClient contentServiceClient;

@Override
public XcChooseCourseDto addChooseCourse(String userId, Long courseId) {
// 1. 选课调用内容管理服务提供的查询课程接口,查询课程收费规则
// 1.1 查询课程
CoursePublish coursePublish = contentServiceClient.getCoursePublish(courseId);
// 1.2 获取收费规则
String charge = coursePublish.getCharge();
XcChooseCourse chooseCourse = null;
if ("201000".equals(charge)) {
// 2. 如果是免费课程,向选课记录表、我的课程表添加数据
chooseCourse = addFreeCourse(userId, coursePublish);
XcCourseTables courseTables = addCourseTables(chooseCourse);
} else {
// 3. 如果是收费课程,向选课记录表添加数据
chooseCourse = addChargeCourse(userId, coursePublish);
}

// 4. 判断学生的学习资格

return null;
}

/**
* 添加到我的课程表
* @param chooseCourse 选课记录
*/
private XcChooseCourse addCourseTables(XcChooseCourse chooseCourse) {

return null;
}

/**
* 将付费课程加入到选课记录表
*
* @param userId 用户id
* @param courseId 课程id
* @return 选课记录
*/
private XcChooseCourse addChargeCourse(String userId, CoursePublish coursePublish) {

return null;
}

/**
* 将免费课程加入到选课表
*
* @param userId 用户id
* @param courseId 课程id
* @return 选课记录
*/
private XcChooseCourse addFreeCourse(String userId, CoursePublish coursePublish) {

return null;
}
}

######## 添加免费课程

  • 由于数据库中没有约束,所以可能存在重复添加的情况,我们需要事先做一下判断
    • 如果已经存在了数据,则直接返回
    • 如果不存在数据,则构造封装返回结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 将免费课程加入到选课表
*
* @param userId 用户id
* @param coursePublish 课程发布信息
* @return 选课记录
*/
public XcChooseCourse addFreeCourse(String userId, CoursePublish coursePublish) {
// 1. 先判断是否已经存在对应的选课,因为数据库中没有约束,所以可能存在相同数据的选课
LambdaQueryWrapper<XcChooseCourse> lambdaQueryWrapper = new LambdaQueryWrapper<XcChooseCourse>()
.eq(XcChooseCourse::getUserId, userId)
.eq(XcChooseCourse::getCourseId, coursePublish.getId())
.eq(XcChooseCourse::getOrderType, "700001") // 免费课程
.eq(XcChooseCourse::getStatus, "701007");// 选课成功
// 1.1 由于可能存在多条,所以这里用selectList
List<XcChooseCourse> chooseCourses = chooseCourseMapper.selectList(lambdaQueryWrapper);
// 1.2 如果已经存在对应的选课数据,返回一条即可
if (!chooseCourses.isEmpty()) {
return chooseCourses.get(0);
}
// 2. 数据库中不存在数据,添加选课信息,对照着数据库中的属性挨个set即可
XcChooseCourse chooseCourse = new XcChooseCourse();
chooseCourse.setCourseId(coursePublish.getId());
chooseCourse.setCourseName(coursePublish.getName());
chooseCourse.setUserId(userId);
chooseCourse.setCompanyId(coursePublish.getCompanyId());
chooseCourse.setOrderType("700001");
chooseCourse.setCreateDate(LocalDateTime.now());
chooseCourse.setCoursePrice(coursePublish.getPrice());
chooseCourse.setValidDays(365);
chooseCourse.setStatus("701001");
chooseCourse.setValidtimeStart(LocalDateTime.now());
chooseCourse.setValidtimeEnd(LocalDateTime.now().plusDays(365));
chooseCourseMapper.insert(chooseCourse);
return chooseCourse;
}

######## 添加我的选课表

  • 我的选课表的记录来源于选课记录,选课记录成功,将课程信息添加到我的课程表
  • 如果我的课程表已经存在课程,课程可能已经过期,如果有新的选课记录,则需要更新我的课程表中的现有信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 添加到我的课程表
*
* @param chooseCourse 选课记录
*/
public XcCourseTables addCourseTables(XcChooseCourse chooseCourse) {
String status = chooseCourse.getStatus();
if (!"701001".equals(status)) {
XueChengPlusException.cast("选课未成功,无法添加到课程表");
}
XcCourseTables courseTables = getXcCourseTables(chooseCourse.getUserId(), chooseCourse.getCourseId());
if (courseTables != null) {
return courseTables;
}
courseTables = new XcCourseTables();
BeanUtils.copyProperties(chooseCourse, courseTables);
courseTables.setChooseCourseId(chooseCourse.getId());
courseTables.setCourseType(chooseCourse.getOrderType());
courseTables.setUpdateDate(LocalDateTime.now());
int insert = courseTablesMapper.insert(courseTables);
if (insert <= 0) {
XueChengPlusException.cast("添加我的课程表失败");
}
return courseTables;
}

/**
* 根据用户id和课程id查询我的课程表中的某一门课程
*
* @param userId 用户id
* @param courseId 课程id
* @return 我的课程表中的课程
*/
public XcCourseTables getXcCourseTables(String userId, Long courseId) {
return courseTablesMapper.selectOne(new LambdaQueryWrapper<XcCourseTables>()
.eq(XcCourseTables::getUserId, userId)
.eq(XcCourseTables::getCourseId, courseId));
}

######## 添加收费课程

  • 此方法与添加免费课程几乎没有区别,只是选课状态和选课类型不同
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 将付费课程加入到选课记录表
*
* @param userId 用户id
* @param coursePublish 课程发布信息
* @return 选课记录
*/
public XcChooseCourse addChargeCourse(String userId, CoursePublish coursePublish) {
// 1. 先判断是否已经存在对应的选课,因为数据库中没有约束,所以可能存在相同数据的选课
LambdaQueryWrapper<XcChooseCourse> lambdaQueryWrapper = new LambdaQueryWrapper<XcChooseCourse>()
.eq(XcChooseCourse::getUserId, userId)
.eq(XcChooseCourse::getCourseId, coursePublish.getId())
.eq(XcChooseCourse::getOrderType, "700002") // 收费课程
.eq(XcChooseCourse::getStatus, "701002");// 待支付
// 1.1 由于可能存在多条,所以这里用selectList
List<XcChooseCourse> chooseCourses = chooseCourseMapper.selectList(lambdaQueryWrapper);
// 1.2 如果已经存在对应的选课数据,返回一条即可
if (!chooseCourses.isEmpty()) {
return chooseCourses.get(0);
}
// 2. 数据库中不存在数据,添加选课信息,对照着数据库中的属性挨个set即可
XcChooseCourse chooseCourse = new XcChooseCourse();
chooseCourse.setCourseId(coursePublish.getId());
chooseCourse.setCourseName(coursePublish.getName());
chooseCourse.setUserId(userId);
chooseCourse.setCompanyId(coursePublish.getCompanyId());
chooseCourse.setOrderType("700002");
chooseCourse.setCreateDate(LocalDateTime.now());
chooseCourse.setCoursePrice(coursePublish.getPrice());
chooseCourse.setValidDays(365);
chooseCourse.setStatus("701002");
chooseCourse.setValidtimeStart(LocalDateTime.now());
chooseCourse.setValidtimeEnd(LocalDateTime.now().plusDays(365));
int insert = chooseCourseMapper.insert(chooseCourse);
if (insert<=0){
XueChengPlusException.cast("添加选课记录失败");
}
return chooseCourse;
}

######## 获取学习资格

  • 定义获取学习资格接口
1
2
3
4
5
6
7
/**
* 获取学习资格
* @param userId 用户id
* @param courseId 课程id
* @return 学习资格状态
*/
XcCourseTablesDto getLearningStatus(String userId, Long courseId);
  • 对应的接口实现思路
    1. 查询我的课程表,如果查不到,则说明没有选课,返回状态码为"702002"的对象
    2. 如果查到了选课,判断是否过期
      • 如果过期则不能学习,返回状态码为"702003"的对象
      • 未过期可以学习
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* 判断学习资格
* @param userId 用户id
* @param courseId 课程id
* @return 学习资格状态:查询数据字典 [{"code":"702001","desc":"正常学习"},{"code":"702002","desc":"没有选课或选课后没有支付"},{"code":"702003","desc":"已过期需要申请续期或重新支付"}]
*/
@Override
public XcCourseTablesDto getLearningStatus(String userId, Long courseId) {
XcCourseTablesDto courseTablesDto = new XcCourseTablesDto();
// 1. 查询我的课程表
XcCourseTables courseTables = getXcCourseTables(userId, courseId);
// 2. 未查到,返回一个状态码为"702002"的对象
if (courseTables == null) {
courseTablesDto = new XcCourseTablesDto();
courseTablesDto.setLearnStatus("702002");
return courseTablesDto;
}
// 3. 查到了,判断是否过期
boolean isExpires = LocalDateTime.now().isAfter(courseTables.getValidtimeEnd());
// 3.1 已过期,返回状态码为"702003"的对象
if (isExpires) {
BeanUtils.copyProperties(courseTables, courseTablesDto);
courseTablesDto.setLearnStatus("702003");
return courseTablesDto;
}
// 3.2 未过期,返回状态码为"702001"的对象
else {
BeanUtils.copyProperties(courseTables, courseTablesDto);
courseTablesDto.setLearnStatus("702001");
return courseTablesDto;
}
}

######## 接口完善

  1. 完善Service接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
@Transactional
public XcChooseCourseDto addChooseCourse(String userId, Long courseId) {
// 1. 选课调用内容管理服务提供的查询课程接口,查询课程收费规则
// 1.1 查询课程
CoursePublish coursePublish = contentServiceClient.getCoursePublish(courseId);
if (coursePublish == null) {
XueChengPlusException.cast("课程不存在");
}
// 1.2 获取收费规则
String charge = coursePublish.getCharge();
XcChooseCourse chooseCourse = null;
if ("201000".equals(charge)) {
// 2. 如果是免费课程,向选课记录表、我的课程表添加数据
log.info("添加免费课程..");
chooseCourse = myCourseTablesService.addFreeCourse(userId, coursePublish);
addCourseTables(chooseCourse);
} else {
// 3. 如果是收费课程,向选课记录表添加数据
log.info("添加收费课程");
chooseCourse = myCourseTablesService.addChargeCourse(userId, coursePublish);
}

// 4. 获取学生的学习资格
XcCourseTablesDto courseTablesDto = getLearningStatus(userId, courseId);
// 5. 封装返回值
XcChooseCourseDto chooseCourseDto = new XcChooseCourseDto();
BeanUtils.copyProperties(chooseCourse, chooseCourseDto);
chooseCourseDto.setLearnStatus(courseTablesDto.learnStatus);
return chooseCourseDto;
}
  1. 完善Controller接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Api(value = "我的课程表接口", tags = "我的课程表接口")
@Slf4j
@RestController
public class MyCourseTablesController {
@Autowired
MyCourseTablesService myCourseTablesService;

@ApiOperation("添加选课")
@PostMapping("/choosecourse/{courseId}")
public XcChooseCourseDto addChooseCourse(@PathVariable("courseId") Long courseId) {
SecurityUtil.XcUser user = SecurityUtil.getUser();
if (user == null) {
XueChengPlusException.cast("请登录后继续选课");
}
String userId = user.getId();
return myCourseTablesService.addChooseCourse(userId, courseId);
}
}

查询学习资格接口

  • 当我们点击马上学习时,会查询用户的学习资格,我们需要添加一个查询学习资格的接口
1
2
3
4
5
6
7
8
9
10
@ApiOperation("查询学习资格")
@PostMapping("/choosecourse/learnstatus/{courseId}")
public XcCourseTablesDto getLearnstatus(@PathVariable Long courseId) {
SecurityUtil.XcUser user = SecurityUtil.getUser();
if (user == null) {
XueChengPlusException.cast("请登录后继续选课");
}
String userId = user.getId();
return myCourseTablesService.getLearningStatus(userId, courseId);
}

测试

  1. 将所有服务启动,并在nacos上的gateway-dev.yaml添加如下配置
1
2
3
4
- id: learning-api
uri: lb://learning-api
predicates:
- Path=/learning/**
  1. 发布一门免费课程和一门付费课程
  2. 进入课程详情界面,点击马上学习
  3. 免费课程点击加入我的课程表,会自动跳转至学习页面,同时数据库中有我的课程表记录和选课记录
  4. 付费课程会显示支付页面,点击微信支付/支付宝支付,仅会将数据加入到选课记录表中,且选课状态为701002(待支付)

支付

需求分析

执行流程

  • 用户去学习收费课程时,会被引导去支付
  • 当用户点击微信支付支付宝支付时,执行流程如下
    1. 请求学习中心服务创建选课记录
    2. 请求订单服务创建商品订单、生成支付二维码
    3. 用户扫码请求订单支付服务,订单支付服务请求第三方支付平台生成支付订单
    4. 前端唤起支付客户端,用户输入密码完成支付
    5. 第三方支付平台支付完成后,发起支付通知
    6. 订单支付服务接收支付通知结果
    7. 用户在前端查询支付结果,请求订单支付服务查询支付结果,如果订单服务还没有收到支付结果,则请求学习中心查询支付结果
    8. 订单支付服务向学习中心通知支付结果
    9. 学习中心服务收到支付结果,如果支付成功则更新选课记录,并添加到我的课程表

通用订单服务设计

  • 在本项目中不仅选课需要下单,购买学习资料、老师一对一答疑等所有收费项目都需要支付下单
    • 所以本项目设计通用的订单服务,通用的订单服务承接各业务模块的收费支付需求,当用户需要交费时,统一生成商品订单进行支付
  • 所有收费业务最终转换为订单记录,在订单服务的商品订单表中存储
  • 以选课为例,选课记录表的ID在商品订单表的out_business_id字段

准备开发环境

支付宝开发环境

  1. 配置沙箱环境
    • 沙箱环境是支付宝开放平台为开发者提供的与生产环境完全隔离的联调测试环境
    • 开发者在沙箱环境中完成的接口调用不会对生产环境中的数据造成任何影响
    • 沙箱环境配置文档:https://opendocs.alipay.com/common/02kkv7
  2. 模拟器
  3. 在模拟器中安装沙箱版本的支付宝
    • 使用沙箱环境的买家账号登录沙箱版本的支付宝

创建订单服务

  • 拷贝黑马提供的xuecheng-plus-orders到自己的项目根目录,然后修改bootstrap.yml文件中的nacos配置信息
  • 然后在nacos中添加配置文件
    1. orders-api-dev.yaml
    1
    2
    3
    4
    5
    6
    7
    8
    9
    server:
    servlet:
    context-path: /orders
    port: 53030

    spring:
    cloud:
    config:
    override-none: true
    1. orders-service-dev.yaml
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    spring:
    datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/xc_orders?serverTimezone=UTC&userUnicode=true&useSSL=false&
    username: root
    password: root

    xxl:
    job:
    admin:
    addresses: http://192.168.101.128:18088/xxl-job-admin/
    executor:
    appname: payresultnotify-job
    address:
    ip:
    port: 8989
    logpath: /data/applogs/xxl-job/jobhandler
    logretentiondays: 30
    accessToken: default_token
  • 创建xc_orders数据库,导入黑马提供的SQL脚本

支付接口测试

阅读接口定义

  • 手机网站支付接入流程详细参见:https://docs.open.alipay.com/203/105285/
    1. 接口交互流程如下
      1. 用户在商户的H5网站下单支付后,商户系统按照手机网站支付接口API的参数规范生成订单数据
      2. 前端页面通过Form表单的形式请求到支付宝,此时支付宝会自动将页面跳转至支付宝H5收银台页面,如果用户手机上安装了支付宝,则会自动唤起支付宝App
      3. 输入支付密码完成支付
      4. 用户在支付宝App或H5收银台完成支付后,会根据商户在手机网站支付API中传入的前台回调地址return_url自动跳转回商户页面,同时在URL请求中以QueryString的形式附带上支付结果参数,详细回调函数参见手机网站支付接口的前台回调函数
      5. 支付宝还会根据原始支付Api中传入的异步通知地址notify_url,通过POST请求的形式将支付结果作为参数通知到商户系统,详情见支付结果异步通知
    2. 接口定义
    3. 示例代码
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public void doPost(HttpServletRequest httpRequest,
    HttpServletResponse httpResponse) throws ServletException, IOException {
    AlipayClient alipayClient = ... //获得初始化的AlipayClient
    AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
    alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
    alipayRequest.setNotifyUrl("http://domain.com/CallBack/notify_url.jsp");//在公共参数中设置回跳和通知地址
    alipayRequest.setBizContent("{" +
    " \"out_trade_no\":\"20150320010101002\"," +
    " \"total_amount\":88.88," +
    " \"subject\":\"Iphone6 16G\"," +
    " \"product_code\":\"QUICK_WAP_WAY\"" +
    " }");//填充业务参数
    String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
    httpResponse.setContentType("text/html;charset=" + AlipayServiceEnvConstants.CHARSET);
    httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
    httpResponse.getWriter().flush();
    }

支付接口测试

######## 编写下单代码

  • 根据接口流程,首先在订单服务编写测试类请求支付宝下单的接口
    1. 在订单服务api工程中添加依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    <!-- 支付宝SDK -->
    <dependency>
    <groupId>com.alipay.sdk</groupId>
    <artifactId>alipay-sdk-java</artifactId>
    <version>3.7.73.ALL</version>
    </dependency>

    <!-- 支付宝SDK依赖的日志 -->
    <dependency>
    <groupId>commons-logging</groupId>
    <artifactId>commons-logging</artifactId>
    <version>1.2</version>
    </dependency>
    1. 拷贝黑马提供的AlipayConfig.java到订单服务的service工程的config包下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    public class AlipayConfig {
    // 商户appid
    // public static String APPID = "";
    // 私钥 pkcs8格式的
    // public static String RSA_PRIVATE_KEY = "";
    // 服务器异步通知页面路径 需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    public static String notify_url = "http://商户网关地址/alipay.trade.wap.pay-JAVA-UTF-8/notify_url.jsp";
    // 页面跳转同步通知页面路径 需http://或者https://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问 商户可以自定义同步跳转地址
    public static String return_url = "http://商户网关地址/alipay.trade.wap.pay-JAVA-UTF-8/return_url.jsp";
    // 请求网关地址
    public static String URL = "https://openapi.alipaydev.com/gateway.do";
    // 编码
    public static String CHARSET = "UTF-8";
    // 返回格式
    public static String FORMAT = "json";
    // 支付宝公钥
    // public static String ALIPAY_PUBLIC_KEY = "";
    // 日志记录目录
    public static String log_path = "/log";
    // RSA2
    public static String SIGNTYPE = "RSA2";
    }
    1. 在api工程下编写Controller类请求支付宝下单的接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    import com.alipay.api.AlipayApiException;
    import com.alipay.api.AlipayClient;
    import com.alipay.api.DefaultAlipayClient;
    import com.alipay.api.request.AlipayTradeWapPayRequest;
    import com.xuecheng.orders.config.AlipayConfig;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Controller;
    import org.springframework.web.bind.annotation.RequestMapping;

    import javax.servlet.ServletException;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.IOException;

    @Controller
    public class PayTestController {

    @Value("${pay.alipay.APP_ID}")
    String APP_ID;

    @Value("${pay.alipay.APP_PRIVATE_KEY}")
    String APP_PRIVATE_KEY;

    @Value("${pay.alipay.ALIPAY_PUBLIC_KEY}")
    String ALIPAY_PUBLIC_KEY;

    @RequestMapping("/alipaytest")
    public void doPost(HttpServletRequest httpRequest,
    HttpServletResponse httpResponse) throws ServletException, IOException, AlipayApiException {
    AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY,AlipayConfig.SIGNTYPE);
    //获得初始化的AlipayClient
    AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
    // alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
    // alipayRequest.setNotifyUrl("http://domain.com/CallBack/notify_url.jsp");//在公共参数中设置回跳和通知地址
    alipayRequest.setBizContent("{" +
    " \"out_trade_no\":\"202210100010101002\"," +
    " \"total_amount\":100000," +
    " \"subject\":\"Iphone6 16G\"," +
    " \"product_code\":\"QUICK_WAP_WAY\"" +
    " }");//填充业务参数
    String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
    httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
    httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
    httpResponse.getWriter().flush();
    }

    }
    1. 在nacos中的orders-service-dev.yaml中配置公钥和私钥
    1
    2
    3
    4
    5
    pay:
    alipay:
    APP_ID: 写你自己的AppID
    APP_PRIVATE_KEY: 写你自己的应用私钥
    ALIPAY_PUBLIC_KEY: 写你自己的支付宝公钥

    • 注意:应用公钥和支付宝公钥不一样,nacos配置里填支付宝公钥

######## 生成二维码

  • 用户在前端使用支付宝沙箱环境,通过扫码请求下单接口,我们需要生成订单服务的下单接口二维码
  • ZXing是一个开源的类库,是用Java编写的多格式的1D/2D条码图像处理库,使用ZXing可以生成、识别QR Code(二维码)
    1. 在base工程中引入ZXing的依赖
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <!-- 二维码生成&识别组件 -->
    <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>core</artifactId>
    <version>3.3.3</version>
    </dependency>

    <dependency>
    <groupId>com.google.zxing</groupId>
    <artifactId>javase</artifactId>
    <version>3.3.3</version>
    </dependency>
    <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    </dependency>
    1. 生成二维码
      • 拷贝黑马提供的工具类QRCodeUtil.java到base工程的utils包下
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      public class QRCodeUtil {
      /**
      * 生成二维码
      *
      * @param content 二维码对应的URL
      * @param width 二维码图片宽度
      * @param height 二维码图片高度
      * @return
      */
      public String createQRCode(String content, int width, int height) throws IOException {
      String resultImage = "";
      //除了尺寸,传入内容不能为空
      if (!StringUtils.isEmpty(content)) {
      ServletOutputStream stream = null;
      ByteArrayOutputStream os = new ByteArrayOutputStream();
      //二维码参数
      @SuppressWarnings("rawtypes")
      HashMap<EncodeHintType, Comparable> hints = new HashMap<>();
      //指定字符编码为“utf-8”
      hints.put(EncodeHintType.CHARACTER_SET, "utf-8");
      //L M Q H四个纠错等级从低到高,指定二维码的纠错等级为M
      //纠错级别越高,可以修正的错误就越多,需要的纠错码的数量也变多,相应的二维吗可储存的数据就会减少
      hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
      //设置图片的边距
      hints.put(EncodeHintType.MARGIN, 1);

      try {
      //zxing生成二维码核心类
      QRCodeWriter writer = new QRCodeWriter();
      //把输入文本按照指定规则转成二维吗
      BitMatrix bitMatrix = writer.encode(content, BarcodeFormat.QR_CODE, width, height, hints);
      //生成二维码图片流
      BufferedImage bufferedImage = MatrixToImageWriter.toBufferedImage(bitMatrix);
      //输出流
      ImageIO.write(bufferedImage, "png", os);
      /**
      * 原生转码前面没有 data:image/png;base64 这些字段,返回给前端是无法被解析,所以加上前缀
      */
      resultImage = new String("data:image/png;base64," + EncryptUtil.encodeBase64(os.toByteArray()));
      return resultImage;
      } catch (Exception e) {
      e.printStackTrace();
      throw new RuntimeException("生成二维码出错");
      } finally {
      if (stream != null) {
      stream.flush();
      stream.close();
      }
      }
      }
      return null;
      }
      }
      • 速览一遍代码,我们只需要传入跳转url、图片的长宽,就会生成一个base64编码的图片,我们可以在浏览器中打开并扫码,现在编写一个main方法进行测试
      1
      2
      3
      4
      public static void main(String[] args) throws IOException {
      QRCodeUtil qrCodeUtil = new QRCodeUtil();
      System.out.println(qrCodeUtil.createQRCode("https://cyborg2077.github.io/", 200, 200));
      }
      • 在浏览器输入生成的base64,扫描二维码,可以跳转到我的博客首页
      1
      data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADIAQAAAACFI5MzAAABbUlEQVR42u2YQY7DIAxFzSrH4KZAbppjsArz/zdJ00qzGY2EF0WocvpYuDb+dmPjt2Vf8kfSDSt1S2OcNHPTZwyS4eLeM3bbjgoDzz0KaYanwzZsOI4j+BGRSLeywVmj19HIQCyt0vcRijDb40jKs0693YOlxKsk79f+rJ+FxBfcZBHT9w/dWUlUuCiRcmkMolumuqwmFDy67OXbtD3b64k89Qy3bUaXRgiS71QbBSaft9fLiXSFjkv5Et0+kt/R9QRXbya8wtiGa0wQAn9h6juWS5JOxyBHUflWGlLlzlMhSPfylaIolpihShBCJWbz55XULhpVQhBpye51zBHPijfbCESDMLItVeZnC0R83oSiKJZaKQp5TQE84p3tc0JZRK7pCeGsz+k4BJkX0FuZRgArz39gS4lP62porGaT+AUid1zVb6+IBiFKMuuDfePN66VkDgJzdDI2t8dNXErm2wjvFVVBPYOQ7/uq/yU/1d3RTjWcm10AAAAASUVORK5CYII=

######## 接口测试

  • 生成订单服务下单接口的二维码
    • 修改我们之前的main方法,将url换成下单接口,注意这里不要用localhost,得用本机局域网ip
    1
    2
    3
    4
    public static void main(String[] args) throws IOException {
    QRCodeUtil qrCodeUtil = new QRCodeUtil();
    System.out.println(qrCodeUtil.createQRCode("http://192.168.43.204:53030/orders/alipaytest", 200, 200));
    }
    • 运行main方法,复制生成的base64串,在浏览器中打开,使用模拟器扫码支付

支付结果查询接口

1
2
3
4
5
6
7
8
9
10
11
12
AlipayClient alipayClient = new DefaultAlipayClient("https://openapi.alipaydev.com/gateway.do", "app_id", "your private_key", "json", "GBK", "alipay_public_key", "RSA2");
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", "20150320010101001");
//bizContent.put("trade_no", "2014112611001004680073956707");
request.setBizContent(bizContent.toString());
AlipayTradeQueryResponse response = alipayClient.execute(request);
if (response.isSuccess()) {
System.out.println("调用成功");
} else {
System.out.println("调用失败");
}
  • 刚刚的订单我们已经支付成功,现在可以使用out_trade_on商品订单号或支付宝的交易流水号trade_no去查询支付结果
    • out_trade_no:商品订单号,是在下单请求时指定的商品订单号
    • trade_no:支付宝交易流水号,是支付完成后,支付宝通知支付结果时发送的trade_no
    • 响应结果是Json格式
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    {
    "alipay_trade_query_response": {
    "code": "10000",
    "msg": "Success",
    "trade_no": "2013112011001004330000121536",
    "out_trade_no": "6823789339978248",
    "open_id": "2088102122524333",
    "buyer_logon_id": "159****5620",
    "trade_status": "TRADE_CLOSED",
    "total_amount": 88.88,
    "trans_currency": "TWD",
    "settle_currency": "USD",
    "settle_amount": 2.96,
    "pay_currency": 1,
    "pay_amount": "8.88",
    "settle_trans_rate": "30.025",
    "trans_pay_rate": "0.264",
    "alipay_store_id": "2015040900077001000100001232",
    "buyer_pay_amount": 8.88,
    "point_amount": 10,
    "invoice_amount": 12.11,
    "send_pay_date": "2014-11-27 15:45:57",
    "receipt_amount": "15.25",
    "store_id": "NJ_S_001",
    "terminal_id": "NJ_T_001",
    "fund_bill_list": [
    {
    "fund_channel": "ALIPAYACCOUNT",
    "bank_code": "CEB",
    "amount": 10,
    "real_amount": 11.21,
    "fund_type": "DEBIT_CARD"
    }
    ],
    "store_name": "证大五道口店",
    "buyer_user_id": "2088101117955611",
    "discount_goods_detail": "[{\"goods_id\":\"STANDARD1026181538\",\"goods_name\":\"雪碧\",\"discount_amount\":\"100.00\",\"voucher_id\":\"2015102600073002039000002D5O\"}]",
    "industry_sepc_detail": "{\"registration_order_pay\":{\"brlx\":\"1\",\"cblx\":\"1\"}}",
    "industry_sepc_detail_gov": "{\"registration_order_pay\":{\"brlx\":\"1\",\"cblx\":\"1\"}}",
    "industry_sepc_detail_acc": "{\"registration_order_pay\":{\"brlx\":\"1\",\"cblx\":\"1\"}}",
    "voucher_detail_list": [
    {
    "id": "2015102600073002039000002D5O",
    "name": "XX超市5折优惠",
    "type": "ALIPAY_FIX_VOUCHER",
    "amount": 10,
    "merchant_contribute": 9,
    "other_contribute": 1,
    "memo": "学生专用优惠",
    "template_id": "20171030000730015359000EMZP0",
    "other_contribute_detail": [
    {
    "contribute_type": "BRAND",
    "contribute_amount": 8
    }
    ],
    "purchase_buyer_contribute": 2.01,
    "purchase_merchant_contribute": 1.03,
    "purchase_ant_contribute": 0.82
    }
    ],
    "charge_amount": "8.88",
    "charge_flags": "bluesea_1",
    "settlement_id": "2018101610032004620239146945",
    "trade_settle_info": {
    "trade_settle_detail_list": [
    {
    "operation_type": "replenish",
    "operation_serial_no": "2321232323232",
    "operation_dt": "2019-05-16 09:59:17",
    "trans_out": "208811****111111",
    "trans_in": "208811****111111",
    "amount": 10,
    "ori_trans_out": "2088111111111111",
    "ori_trans_in": "2088111111111111"
    }
    ]
    },
    "auth_trade_pay_mode": "CREDIT_PREAUTH_PAY",
    "buyer_user_type": "PRIVATE",
    "mdiscount_amount": "88.88",
    "discount_amount": "88.88",
    "buyer_user_name": "菜鸟网络有限公司",
    "subject": "Iphone6 16G",
    "body": "Iphone6 16G",
    "alipay_sub_merchant_id": "2088301372182171",
    "ext_infos": "{\"action\":\"cancel\"}",
    "passback_params": "merchantBizType%3d3C%26merchantBizNo%3d2016010101111",
    "hb_fq_pay_info": {
    "user_install_num": "3",
    "fq_amount": "10.05"
    },
    "receipt_currency_type": "DC",
    "credit_pay_mode": "creditAdvanceV2",
    "credit_biz_order_id": "ZMCB99202103310000450000041833",
    "enterprise_pay_info": {
    "is_use_enterprise_pay": false,
    "invoice_amount": 80,
    "biz_info": "{\\\"enterprisePayAmount\\\":\\\"0.64\\\"}"
    },
    "hyb_amount": "10.24",
    "bkagent_resp_info": {
    "bindtrx_id": "123412341234",
    "bindclrissr_id": "01",
    "bindpyeracctbk_id": "123123123123",
    "bkpyeruser_code": "123451234512345",
    "estter_location": "+37.28/-121.268"
    },
    "charge_info_list": [
    {
    "charge_fee": 0.01,
    "original_charge_fee": 0.01,
    "switch_fee_rate": "0.03",
    "is_rating_on_trade_receiver": "Y",
    "is_rating_on_switch": "Y",
    "charge_type": "trade",
    "sub_fee_detail_list": [
    {
    "charge_fee": 0.1,
    "original_charge_fee": 0.2,
    "switch_fee_rate": "0.03"
    }
    ]
    }
    ]
    },
    "sign": "ERITJKEIJKJHKKKKKKKHJEREEEEEEEEEEE"
    }
  • 我们使用out_trade_no商品订单号去查询,代码如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 支付结果查询测试
*/
@SpringBootTest
public class Test1 {
@Value("${pay.alipay.APP_ID}")
String APP_ID;
@Value("${pay.alipay.APP_PRIVATE_KEY}")
String APP_PRIVATE_KEY;

@Value("${pay.alipay.ALIPAY_PUBLIC_KEY}")
String ALIPAY_PUBLIC_KEY;

@Test
public void queryPayResult() throws AlipayApiException {
System.out.println(APP_ID);
System.out.println(APP_PRIVATE_KEY);
System.out.println(ALIPAY_PUBLIC_KEY);
AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, "json", AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY, AlipayConfig.SIGNTYPE); //获得初始化的AlipayClient
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", "202310100010101002");
//bizContent.put("trade_no", "2014112611001004680073956707");
request.setBizContent(bizContent.toString());
AlipayTradeQueryResponse response = alipayClient.execute(request);
if (response.isSuccess()) {
System.out.println("调用成功");
String resultJson = response.getBody();
//转map
Map resultMap = JSON.parseObject(resultJson, Map.class);
Map alipay_trade_query_response = (Map) resultMap.get("alipay_trade_query_response");
//支付结果
System.out.println(alipay_trade_query_response);
} else {
System.out.println("调用失败");
}
}
}
  • 运行代码,控制台输出如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
调用成功 {
"msg": "Success",
"code": "10000",
"buyer_user_id": "2088722009945224",
"send_pay_date": "2023-03-18 11:39:51",
"invoice_amount": "0.00",
"out_trade_no": "202310100010101002",
"total_amount": "100000.00",
"buyer_user_type": "PRIVATE",
"trade_status": "TRADE_SUCCESS",
"trade_no": "2023031822001445220502614531",
"buyer_logon_id": "hex***@sandbox.com",
"receipt_amount": "0.00",
"point_amount": "0.00",
"buyer_pay_amount": "0.00"
}

支付结果通知接口

######## 准备环境

  • 对于手机网站支付产生的交易,支付宝会通知商户支付结果,有两种方式
    1. return_url:使用此方式时,不能保证通知到位,所以建议使用notify_url
    2. notify_url
return_url notify_url
支付成功后点击完成会自动跳转回商家页面地址,同时在URL地址上附带支付结果参数,回跳参数可查看本文前台回跳参数说明。在iOS系统中,唤起支付宝客户端支付完成后,不会自动回到浏览器或商家App。用户可手工切回到浏览器或商家App。 异步通知地址,用于接收支付宝推送给商户的支付/退款成功的消息。
  • 具体的使用方法是在调用下单接口的API中传入异步通知地址notify_url,通过POST请求的形式将支付结果作为参数通知到商户系统
  • 根据下单执行流程,订单服务收到支付结果需要对内容进行验签,验签过程如下
    1. 在通知返回参数列表中,出去sign、sign_type两个参数外,凡是通知返回胡来的参数均为待验签的参数。将剩下的参数进行url_decode,然后按照字典排序,组成字符串,得到待签名字符串;生活号异步通知组成的待验签串中须保留sign_type参数
    2. 将签名参数(sigin)使用base64解码为字节码串
    3. 使用RSA的验签方法,通过签名字符串、签名参数(通过base64解码)及支付宝公钥验证签名
    4. 验证签名正确后,必须再严格按照如下描述校验通知数据的正确性
      • 商户必须根据支付宝不同类型的业务通知,正确的进行不同的业务处理,并且过滤重复的通知结果数据。
      • 通过验证out_trade_no、total_amount、appid参数的正确性判断通知请求的合法性。

######## 编写测试代码

  1. 在下单请求时,设置通知地址request_setNotifyUrl(“商户自己的notify_url地址”)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    @RequestMapping("/alipaytest")
public void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws ServletException, IOException, AlipayApiException {
AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY, AlipayConfig.SIGNTYPE);
//获得初始化的AlipayClient
AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
// alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
alipayRequest.setNotifyUrl("http://7veh8s.natappfree.cc/orders/paynotify"); //在公共参数中设置回跳和通知地址
alipayRequest.setBizContent("{" +
" \"out_trade_no\":\"202310100011601002\"," +
" \"total_amount\":100000," +
" \"subject\":\"Iphone6 16G\"," +
" \"product_code\":\"QUICK_WAP_WAY\"" +
" }");//填充业务参数
String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
httpResponse.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
httpResponse.getWriter().write(form);//直接将完整的表单html输出到页面
httpResponse.getWriter().flush();
}
  • 由于回调地址必须外网可访问的地址,所以这里需要内网穿透工具(我这里用的是NatApp的免费隧道)
  1. 编写接收通知接口,接收参数并验签
    • 详情参考官方的demo
    • 代码如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    //接收通知
    @PostMapping("/paynotify")
    public void paynotify(HttpServletRequest request, HttpServletResponse response) throws IOException, AlipayApiException {
    Map<String, String> params = new HashMap<String, String>();
    Map requestParams = request.getParameterMap();
    for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext(); ) {
    String name = (String) iter.next();
    String[] values = (String[]) requestParams.get(name);
    String valueStr = "";
    for (int i = 0; i < values.length; i++) {
    valueStr = (i == values.length - 1) ? valueStr + values[i]
    : valueStr + values[i] + ",";
    }
    //乱码解决,这段代码在出现乱码时使用。如果mysign和sign不相等也可以使用这段代码转化
    //valueStr = new String(valueStr.getBytes("ISO-8859-1"), "gbk");
    params.put(name, valueStr);
    }


    //获取支付宝的通知返回参数,可参考技术文档中页面跳转同步通知参数列表(以上仅供参考)//
    //计算得出通知验证结果
    //boolean AlipaySignature.rsaCheckV1(Map<String, String> params, String publicKey, String charset, String sign_type)
    boolean verify_result = AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, AlipayConfig.CHARSET, "RSA2");

    if (verify_result) {//验证成功
    //////////////////////////////////////////////////////////////////////////////////////////
    //请在这里加上商户的业务逻辑程序代码

    //商户订单号
    String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"), "UTF-8");
    //支付宝交易号

    String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"), "UTF-8");

    //交易状态
    String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"), "UTF-8");


    //——请根据您的业务逻辑来编写程序(以下代码仅作参考)——

    if (trade_status.equals("TRADE_FINISHED")) {//交易结束
    //判断该笔订单是否在商户网站中已经做过处理
    //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
    //请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
    //如果有做过处理,不执行商户的业务程序

    //注意:
    //如果签约的是可退款协议,退款日期超过可退款期限后(如三个月可退款),支付宝系统发送该交易状态通知
    //如果没有签约可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
    } else if (trade_status.equals("TRADE_SUCCESS")) {//交易成功
    System.out.println(trade_status + "交易成功");
    //判断该笔订单是否在商户网站中已经做过处理
    //如果没有做过处理,根据订单号(out_trade_no)在商户网站的订单系统中查到该笔订单的详细,并执行商户的业务程序
    //请务必判断请求时的total_fee、seller_id与通知时获取的total_fee、seller_id为一致的
    //如果有做过处理,不执行商户的业务程序

    //注意:
    //如果签约的是可退款协议,那么付款完成后,支付宝系统发送该交易状态通知。
    }
    response.getWriter().write("success");
    } else {
    response.getWriter().write("fail");
    }
    }

######## 通知接口测试

  1. 修改订单号,重启订单服务,在接收通知接口上打个断点
  2. 配置内网穿透的本地端口为订单服务端口,启动内网穿透客户端
  3. 生成二维码,复制base64串到浏览器中打开
  4. 打开模拟器、支付宝沙箱,扫码进行字符
  5. 观察接受订单数据等是否正常
  • 这里成功进入到了通知接口

生成支付二维码

需求分析

######## 执行流程

  • 打开课程支付引导界面,当我们点击支付宝支付时,需要生成一个二维码,然后用户扫码支付
  • 所以首先需要生成支付二维码,用户扫描二维码开始请求支付宝下单,在向支付宝下单前需要添加选课记录、创建商品订单、生成支付交易记录
  • 生成二维码执行流程如下
    1. 前端调用学习中心服务的添加选课接口
    2. 添加选课成功,请求订单服务生成支付二维码接口
    3. 生成二维码接口:创建商品订单、生成支付交易记录、生成二维码
    4. 将二维码返回到前端,用户扫码
  • 用户扫码支付流程如下
    1. 用户输入支付密码,支付成功
    2. 接收第三方平台通知的支付结果
    3. 根据支付结果,更新支付交易记录的支付状态为支付成功

######## 数据模型

  • 订单支付模式的核心由三张表组成

    1. 订单表:记录订单信息
    2. 订单明细表:记录订单的详细信息
    3. 支付交易记录表:记录每次支付的交易明细
  • 订单号注意唯一性、安全性、尽量短等特点,生成方案常用的如下

    1. 时间戳+随机数
      • 年月日时分秒毫秒+随机数
    2. 高并发场景
      • 年月日时分秒毫秒+随机数+redis自增序列
    3. 订单号中加上业务标识
      • 订单号加上业务标识方便客服提供服务,例如:第10位是业务类型、第11位是用户类型等
    4. 雪花算法
      • 雪花算法是推特内部使用的分布式环境下的唯一ID生成算法,它基于时间戳生成,保证有序递增,加入计算机硬件等元素,可以看组高并发场景下ID不重复
  • 本项目订单号生成策略采用雪花算法,导入黑马提供的IdWorkerUtils.java到base工程的utils包下

接口定义

  • 在订单服务中定义生成支付二维码接口
    • 请求参数:订单信息
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Data
    @ToString
    public class AddOrderDto {

    /**
    * 总价
    */
    private Float totalPrice;

    /**
    * 订单类型
    */
    private String orderType;

    /**
    * 订单名称
    */
    private String orderName;
    /**
    * 订单描述
    */
    private String orderDescrip;

    /**
    * 订单明细json,不可为空
    * [{"goodsId":"","goodsType":"","goodsName":"","goodsPrice":"","goodsDetail":""},{...}]
    */
    private String orderDetail;

    /**
    * 外部系统业务id
    */
    private String outBusinessId;

    }
    • 响应:支付交易记录及二维码信息
    1
    2
    3
    4
    5
    6
    7
    8
    @Data
    @ToString
    public class PayRecordDto extends XcPayRecord {

    //二维码
    private String qrcode;

    }
    • 接口定义如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    @Api(value = "订单支付接口", tags = "订单支付接口")
    @RestController
    @Slf4j
    public class OrderController {
    @ApiOperation("生成支付二维码")
    @PostMapping("/generatepaycode")
    public PayRecordDto generatePayCode(@RequestBody AddOrderDto addOrderDto) {

    // 插入订单信息、 插入支付记录、生成二维码返回

    return null;
    }
    }

接口实现

  • 在前面的分析中,生成支付二维码操作,其中包含了三个小操作
    1. 插入订单信息
    2. 插入支付记录
    3. 生成二维码返回
  • 那我们这里要做的就是实现这三个接口

######## 保存商品订单

  • 定义保存订单信息接口
1
2
3
4
5
6
7
8
9
10
11
public interface OrderService {

/**
* 创建商品订单
* @param userId 用户id
* @param addOrderDto 订单信息
* @return 支付交易记录
*/
PayRecordDto createOrder(String userId, AddOrderDto addOrderDto);

}
  • 在保存订单接口中需要完成
    1. 创建商品订单
    2. 创建交易支付记录
    3. 生成二维码
  • 接口实现如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
XcOrdersMapper xcOrdersMapper;

@Autowired
XcPayRecordMapper xcPayRecordMapper;

@Autowired
XcOrdersGoodsMapper xcOrdersGoodsMapper;


@Override
public PayRecordDto createOrder(String userId, AddOrderDto addOrderDto) {
// 1. 添加商品订单

// 2. 添加支付交易记录

// 3. 生成二维码

return null;
}
}
  • 编写创建订单方法,商品订单的数据来源于选课记录,在订单表需要存入选课记录的ID,这里需要做好幂等性处理,防止用户重复支付
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/**
* 保存订单信息,保存订单表和订单明细表,需要做幂等性判断
*
* @param userId 用户id
* @param addOrderDto 选课信息
* @return
*/
@Transactional
public XcOrders saveOrders(String userId, AddOrderDto addOrderDto) {
// 1. 幂等性判断
XcOrders order = getOrderByBusinessId(addOrderDto.getOutBusinessId());
if (order != null) {
return order;
}
// 2. 插入订单表
order = new XcOrders();
BeanUtils.copyProperties(addOrderDto, order);
order.setId(IdWorkerUtils.getInstance().nextId());
order.setCreateDate(LocalDateTime.now());
order.setUserId(userId);
order.setStatus("600001");
int insert = xcOrdersMapper.insert(order);
if (insert <= 0) {
XueChengPlusException.cast("插入订单记录失败");
}
// 3. 插入订单明细表
Long orderId = order.getId();
String orderDetail = addOrderDto.getOrderDetail();
List<XcOrdersGoods> xcOrdersGoodsList = JSON.parseArray(orderDetail, XcOrdersGoods.class);
xcOrdersGoodsList.forEach(goods -> {
goods.setOrderId(orderId);
int insert1 = xcOrdersGoodsMapper.insert(goods);
if (insert1 <= 0) {
XueChengPlusException.cast("插入订单明细失败");
}
});
return order;
}

######## 创建支付交易记录

  • 为什么创建支付交易记录?
  • 在请求微信或支付宝下单接口时,需要传入商品订单号,在与第三方交付平台对接时发现,当用户支付失败或因为其他原因导致该订单没有支付成功,此时再次调用第三方支付平台的下单接口就会报错订单号已存在
  • 但如果我们此时传入一个新的订单号就可以解决问题,但是商品订单已经创建,因此没有支付成功重新创建一个新订单是不合理的
  • 解决以上问题的方案是
    1. 用户每次发起都创建一个新的支付交易记录,此交易记录与商品订单关联
    2. 将支付交易记录的流水号传给第三方支付系统的下单接口,这样即使没有支付成功,也不会出现上面的问题
    3. 判断订单支付状态,提醒用户不要重复支付
  • 编写创建支付交易记录的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public XcPayRecord createPayRecord(XcOrders orders) {
if (orders == null) {
XueChengPlusException.cast("订单不存在");
}
if ("600002".equals(orders.getStatus()) {
XueChengPlusException.cast("订单已支付");
}
XcPayRecord payRecord = new XcPayRecord();
payRecord.setPayNo(IdWorkerUtils.getInstance().nextId());
payRecord.setOrderId(orders.getId());
payRecord.setOrderName(orders.getOrderName());
payRecord.setTotalPrice(orders.getTotalPrice());
payRecord.setCurrency("CNY");
payRecord.setCreateDate(LocalDateTime.now());
payRecord.setStatus("601001"); // 未支付
payRecord.setUserId(orders.getUserId());
int insert = xcPayRecordMapper.insert(payRecord);
if (insert <= 0) {
XueChengPlusException.cast("插入支付交易记录失败");
}
return payRecord;
}

######## 生成支付二维码

  1. 在nacos中的orders-service-dev.yaml中配置二维码的URL
    • 之前我们用的是测试接口/alipaytest,现在我们需要用实际应用的接口,同时支付的时候也需要带上订单号,待会儿我们会重新写一个Controller方法
1
2
pay:
qrcodeurl: http://192.168.43.204:53030/orders/requestpay?payNo=%s
  1. 完善创建订单Service方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Value("${pay.qrcodeurl}")
String qrcodeurl;

@Override
public PayRecordDto createOrder(String userId, AddOrderDto addOrderDto) {
// 1. 添加商品订单
XcOrders orders = saveOrders(userId, addOrderDto);
// 2. 添加支付交易记录
XcPayRecord payRecord = createPayRecord(orders);
// 3. 生成二维码
String qrCode = null;
try {
// 3.1 用订单号填充占位符
qrcodeurl = String.format(qrcodeurl, payRecord.getPayNo());
// 3.2 生成二维码
qrCode = new QRCodeUtil().createQRCode(qrcodeurl, 200, 200);
} catch (IOException e) {
XueChengPlusException.cast("生成二维码出错");
}
PayRecordDto payRecordDto = new PayRecordDto();
BeanUtils.copyProperties(payRecord, payRecordDto);
payRecordDto.setQrcode(qrCode);
return payRecordDto;
}

######## 生成二维码接口完善

  • 完善生成支付二维码Controller接口
1
2
3
4
5
6
7
8
9
10
11
12
@Autowired
OrderService orderService;

@ApiOperation("生成支付二维码")
@PostMapping("/generatepaycode")
public PayRecordDto generatePayCode(@RequestBody AddOrderDto addOrderDto) {
SecurityUtil.XcUser user = SecurityUtil.getUser();
if (user == null) {
XueChengPlusException.cast("请登录后继续选课");
}
return orderService.createOrder(user.getId(), addOrderDto);
}
  • 重启所有服务,测试二维码是否可以正常生成
  • 查看数据库,订单表、订单明细表、支付交易记录表中均有数据

######### 扫码下单接口

  • 现在已经成功生成了支付二维码,用户可以扫码请求第三方支付平台下单、支付
    1. 定义Controller接口
    1
    2
    3
    4
    5
    @ApiOperation("扫码下单接口")
    @GetMapping("/requestpay")
    public void requestpay(String payNo, HttpServletResponse response) {

    }
    1. 定义查询支付交易记录的Service接口
      1
      2
      3
      4
      5
      6
      /**
      * 查询支付交易记录
      * @param payNo 交易记录号
      * @return
      */
      XcPayRecord getPayRecordByPayNo(String payNo);
      • 对应的接口实现如下
      1
      2
      3
      4
      @Override
      public XcPayRecord getPayRecordByPayNo(String payNo) {
      return xcPayRecordMapper.selectOne(new LambdaQueryWrapper<XcPayRecord>().eq(XcPayRecord::getPayNo, payNo));
      }
    2. 完善下单接口
      • 下单接口我们刚刚已经测试过了,只不过还没有携带我们的数据,所以这里把代码拷贝过来稍加修改即可
      • 交易记录号我们之前是写死的,现在可以从PayRecord中获取payNo
      • 同时也需要修改商品名和商品价格,也是从PayRecord中获取
      • 这里还要把通知地址注释掉
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      @Value("${pay.alipay.APP_ID}")
      String APP_ID;

      @Value("${pay.alipay.APP_PRIVATE_KEY}")
      String APP_PRIVATE_KEY;

      @Value("${pay.alipay.ALIPAY_PUBLIC_KEY}")
      String ALIPAY_PUBLIC_KEY;

      @ApiOperation("扫码下单接口")
      @GetMapping("/requestpay")
      public void requestpay(String payNo, HttpServletResponse response) throws AlipayApiException, IOException {
      XcPayRecord payRecord = orderService.getPayRecordByPayNo(payNo);
      if (payRecord == null) {
      XueChengPlusException.cast("请重新点击支付获取二维码");
      }
      String status = payRecord.getStatus();
      if ("601002".equals(status)) {
      XueChengPlusException.cast("订单已支付,请勿重复支付");
      }
      AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, AlipayConfig.FORMAT, AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY, AlipayConfig.SIGNTYPE);
      //获得初始化的AlipayClient
      AlipayTradeWapPayRequest alipayRequest = new AlipayTradeWapPayRequest();//创建API对应的request
      //alipayRequest.setReturnUrl("http://domain.com/CallBack/return_url.jsp");
      //alipayRequest.setNotifyUrl("http://7veh8s.natappfree.cc/orders/paynotify");//在公共参数中设置回跳和通知地址
      alipayRequest.setBizContent("{" +
      " \"out_trade_no\":\"" + payRecord.getPayNo() + "\"," +
      " \"total_amount\":" + payRecord.getTotalPrice() + "," +
      " \"subject\":\"" + payRecord.getOrderName() + "\"," +
      " \"product_code\":\"QUICK_WAP_WAY\"" +
      " }");//填充业务参数
      String form = alipayClient.pageExecute(alipayRequest).getBody(); //调用SDK生成表单
      response.setContentType("text/html;charset=" + AlipayConfig.CHARSET);
      response.getWriter().write(form);//直接将完整的表单html输出到页面
      response.getWriter().flush();
      }

支付测试

  1. 重启所有服务
  2. 发布一门收费课程
  3. 进入收费课程详情页面,点击马上学习,点击支付宝支付,生成二维码
  4. 将二维码保存,使用沙盒环境支付宝扫码支付,测试是否正常
  5. 打断点观察订单是否创建成功,支付交易记录是否创建成功,在数据库中验证
  • 可以看出,商品价格、名称均显示正常

查询支付结果

  • 在此之前,我们需要替换前端文件和模板文件,加了一个支持完成的按钮
  • 新增一门收费课程和一门免费课程,方便我们后续测试

接口定义

  • 我们前面已经测试过了支付结果的接口,包括:主动查询支付结果、被动接收支付结果
  • 这里我们先来实现主动查询支付结果,当支付完成后,用户点击支付结果,将请求第三方支付平台查询支付结果
  • 在OrderController类定义接口如下
1
2
3
4
5
6
@ApiOperation("查询支付结果")
@GetMapping("/payresult")
public PayRecordDto payresult(String payNo) {

return null;
}

接口实现

  1. 定义查询支付结果的Service接口
1
2
3
4
5
6
/**
* 请求支付宝查询支付结果
* @param payNo 支付记录id
* @return 支付记录信息
*/
PayRecordDto queryPayResult(String payNo);
  1. 对应的接口实现思路
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Override
public PayRecordDto queryPayResult(String payNo) {

// 1. 调用支付宝接口查询支付结果
PayStatusDto payStatusDto = queryPayResultFromAlipay(payNo);

// 2. 拿到支付结果,更新支付记录表和订单表的状态为 已支付
saveAlipayStatus(payStatusDto);

return null;
}

/**
* 调用支付宝接口查询支付结果
*
* @param payNo 支付记录id
* @return 支付记录信息
*/
public PayStatusDto queryPayResultFromAlipay(String payNo) {

return null;
}

/**
* 保存支付结果信息
*
* @param payStatusDto 支付结果信息
*/
public void saveAlipayStatus(PayStatusDto payStatusDto) {

}

######## 查询支付结果

  • 编写查询支付结果的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/**
* 调用支付宝接口查询支付结果
*
* @param payNo 支付记录id
* @return 支付记录信息
*/
public PayStatusDto queryPayResultFromAlipay(String payNo) {
// 1. 获得初始化的AlipayClient
AlipayClient alipayClient = new DefaultAlipayClient(AlipayConfig.URL, APP_ID, APP_PRIVATE_KEY, "json", AlipayConfig.CHARSET, ALIPAY_PUBLIC_KEY, AlipayConfig.SIGNTYPE);
AlipayTradeQueryRequest request = new AlipayTradeQueryRequest();
JSONObject bizContent = new JSONObject();
bizContent.put("out_trade_no", payNo);
AlipayTradeQueryResponse response = null;
// 2. 请求查询
try {
response = alipayClient.execute(request);
} catch (AlipayApiException e) {
XueChengPlusException.cast("请求支付宝查询支付结果异常");
}
// 3. 查询失败
if (!response.isSuccess()) {
XueChengPlusException.cast("请求支付宝查询支付结果异常");
}
// 4. 查询成功,获取结果集
String resultJson = response.getBody();
// 4.1 转map
Map resultMap = JSON.parseObject(resultJson, Map.class);
// 4.2 获取我们需要的信息
Map<String, String> alipay_trade_query_response = (Map) resultMap.get("alipay_trade_query_response");
// 5. 创建返回对象
PayStatusDto payStatusDto = new PayStatusDto();
// 6. 封装返回
String tradeStatus = alipay_trade_query_response.get("trade_status");
String outTradeNo = alipay_trade_query_response.get("out_trade_no");
String tradeNo = alipay_trade_query_response.get("trade_no");
String totalAmount = alipay_trade_query_response.get("total_amount");
payStatusDto.setTrade_status(tradeStatus);
payStatusDto.setOut_trade_no(outTradeNo);
payStatusDto.setTrade_no(tradeNo);
payStatusDto.setTotal_amount(totalAmount);
payStatusDto.setApp_id(APP_ID);
return payStatusDto;
}
  • 重启服务,打断点,使用HttpClient进行测试
1
2
3
#### 查询支付结果
GET localhost:53030/orders/payresult?payNo=1637038916949913600
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsieHVlY2hlbmctcGx1cyJdLCJ1c2VyX25hbWUiOiJ7XCJjb21wYW55SWRcIjpcIjEyMzIxNDE0MjVcIixcImNyZWF0ZVRpbWVcIjpcIjIwMjItMDktMjhUMDg6MzI6MDNcIixcImVtYWlsXCI6XCI2Mzg1Mjk2QHFxLmNvbVwiLFwiaWRcIjpcIjUyXCIsXCJuYW1lXCI6XCJLaWtpXCIsXCJwZXJtaXNzaW9uc1wiOltcInhjX3RlYWNobWFuYWdlclwiLFwieGNfdGVhY2htYW5hZ2VyX2NvdXJzZVwiLFwieGNfdGVhY2htYW5hZ2VyX2NvdXJzZV9hZGRcIixcInhjX3RlYWNobWFuYWdlcl9jb3Vyc2VfZGVsXCIsXCJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX21hcmtldFwiLFwieGNfdGVhY2htYW5hZ2VyX2NvdXJzZV9iYXNlXCIsXCJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX3BsYW5cIixcInhjX3RlYWNobWFuYWdlcl9jb3Vyc2VfcHVibGlzaFwiLFwieGNfdGVhY2htYW5hZ2VyX2NvdXJzZV9saXN0XCIsXCJjb3Vyc2VfZmluZF9saXN0XCJdLFwic2V4XCI6XCIxXCIsXCJzdGF0dXNcIjpcIlwiLFwidXNlcm5hbWVcIjpcIkt5bGVcIixcInV0eXBlXCI6XCIxMDEwMDJcIn0iLCJzY29wZSI6WyJhbGwiXSwiZXhwIjoxNjc5MjAxMzE1LCJhdXRob3JpdGllcyI6WyJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX2Jhc2UiLCJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX2RlbCIsInhjX3RlYWNobWFuYWdlcl9jb3Vyc2VfbGlzdCIsInhjX3RlYWNobWFuYWdlcl9jb3Vyc2VfcGxhbiIsInhjX3RlYWNobWFuYWdlcl9jb3Vyc2UiLCJjb3Vyc2VfZmluZF9saXN0IiwieGNfdGVhY2htYW5hZ2VyIiwieGNfdGVhY2htYW5hZ2VyX2NvdXJzZV9tYXJrZXQiLCJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX3B1Ymxpc2giLCJ4Y190ZWFjaG1hbmFnZXJfY291cnNlX2FkZCJdLCJqdGkiOiJlOTBiY2NkNi1jMzA1LTQ5ZjctYThiZi00N2E1NTliMThiOWMiLCJjbGllbnRfaWQiOiJYY1dlYkFwcCJ9.WUL8mDse9zpwXYnpEcOvgr_MsL_1XKdm2Gm6zCSDv-c
  • 成功进入断点,并且可以查询到正确的订单信息

######## 保存支付结果

  • 编写保存支付结果接口的实现方法
    • 订单表和支付记录表都需要保存,更新支付状态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public void saveAlipayStatus(PayStatusDto payStatusDto) {
// 1. 获取支付流水号
String payNo = payStatusDto.getOut_trade_no();
// 2. 查询数据库订单状态
XcPayRecord payRecord = getPayRecordByPayNo(payNo);
if (payRecord == null) {
XueChengPlusException.cast("未找到支付记录");
}
XcOrders order = xcOrdersMapper.selectById(payRecord.getOrderId());
if (order == null) {
XueChengPlusException.cast("找不到相关联的订单");
}
String statusFromDB = payRecord.getStatus();
// 2.1 已支付,直接返回
if ("600002".equals(statusFromDB)) {
return;
}
// 3. 查询支付宝交易状态
String tradeStatus = payStatusDto.getTrade_status();
// 3.1 支付宝交易已成功,保存订单表和交易记录表,更新交易状态
if ("TRADE_SUCCESS".equals(tradeStatus)) {
// 更新支付交易表
payRecord.setStatus("601002");
payRecord.setOutPayNo(payStatusDto.getTrade_no());
payRecord.setOutPayChannel("Alipay");
payRecord.setPaySuccessTime(LocalDateTime.now());
int updateRecord = xcPayRecordMapper.updateById(payRecord);
if (updateRecord <= 0) {
XueChengPlusException.cast("更新支付交易表失败");
}
// 更新订单表
order.setStatus("600002");
int updateOrder = xcOrdersMapper.updateById(order);
if (updateOrder <= 0) {
log.debug("更新订单表失败");
XueChengPlusException.cast("更新订单表失败");
}
}
}
  • 使用HttpClient进行测试,测试完毕后查看数据库中的订单数据是否为已支付

接口测试

  1. 完善Controller方法
1
2
3
4
5
@ApiOperation("查询支付结果")
@GetMapping("/payresult")
public PayRecordDto payresult(String payNo) {
return orderService.queryPayResult(payNo);
}
  1. 导入黑马提供的LocalDateTimeConfig.java到base工程的config包下,用于处理前端Long精度丢失的问题,然后使用前后端联调(我这里前端暂时有点问题,就先不测了)

接收支付通知

接口定义

  • 支付完成后,第三方支付系统会主动通知支付结果,要实现主动通知,需要在请求支付系统下单时传入NotifyUrl,前面我们已经做过测试了
    1. 首先在下单时指定NotifyUrl(这里还是需要内网穿透工具)
    1
    alipayRequest.setNotifyUrl("http://66vyk5.natappfree.cc/orders/paynotify");
    1. 接收支付结果通知接口如下
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    @PostMapping("/paynotify")
    public void paynotify(HttpServletRequest request, HttpServletResponse response) throws IOException, AlipayApiException {
    Map<String, String> params = new HashMap<>();
    Map requestParams = request.getParameterMap();
    for (Iterator iter = requestParams.keySet().iterator(); iter.hasNext(); ) {
    String name = (String) iter.next();
    String[] values = (String[]) requestParams.get(name);
    String valueStr = "";
    for (int i = 0; i < values.length; i++) {
    valueStr = (i == values.length - 1) ? valueStr + values[i]
    : valueStr + values[i] + ",";
    }
    params.put(name, valueStr);
    }
    boolean verify_result = AlipaySignature.rsaCheckV1(params, ALIPAY_PUBLIC_KEY, AlipayConfig.CHARSET, "RSA2");

    if (verify_result) {
    //商户订单号
    String out_trade_no = new String(request.getParameter("out_trade_no").getBytes("ISO-8859-1"), "UTF-8");
    //支付宝交易号
    String trade_no = new String(request.getParameter("trade_no").getBytes("ISO-8859-1"), "UTF-8");
    //交易状态
    String trade_status = new String(request.getParameter("trade_status").getBytes("ISO-8859-1"), "UTF-8");
    //付款金额
    String total_amount = new String(request.getParameter("total_amount").getBytes("ISO-8859-1"), "UTF-8");
    if (trade_status.equals("TRADE_FINISHED")) {//交易结束

    } else if (trade_status.equals("TRADE_SUCCESS")) {
    // 交易成功,保存订单信息
    PayStatusDto payStatusDto = new PayStatusDto();
    payStatusDto.setOut_trade_no(out_trade_no);
    payStatusDto.setTrade_no(trade_no);
    payStatusDto.setApp_id(APP_ID);
    payStatusDto.setTrade_status(trade_status);
    payStatusDto.setTotal_amount(total_amount);
    orderService.saveAlipayStatus(payStatusDto);
    log.debug("交易成功");
    }
    response.getWriter().write("success");
    } else {
    response.getWriter().write("fail");
    }
    }
  • 重启服务,打断点测试
  • 当我们支付完成后,数据库中的订单状态为已完成,并且再次点击支付宝支付时,会提示订单已支付

支付通知

需求分析

  • 订单服务作为通用服务,在订单支付成功后,需要将支付结果异步通知给其他微服务
    • 学习中心服务:对于收费课程,选课需要支付,与订单服务对接完成支付
    • 学习资源服务:对于收费的学习资料,需要购买后才能下载,与订单服务对接完成支付
  • 订单服务完成支付后,将支付结果发给每一个与订单服务对接的微服务,订单服务将消息发送给交换机,由交换机广播消息,每个订阅消息的微服务都可以接收到支付结果。微服务收到支付结果根据订单的类型去更新自己的业务数据

技术方案

  • 使用消息队列进行异步通知,需要保证消息的可靠性,即生产端将消息成功通知到服务端

  • 消息从生产端发送到消费端经历了如下过程

    1. 消息发送到交换机
    2. 消息由交换机发送到队列
    3. 消费者收到消息进行处理
  • 保证消息的可靠性即保证以上三步的可靠性,本项目使用RabbitMQ,可以通过以下几个方面保证消息的可靠性

    1. 生产者确认机制
      • 发送消息前,使用数据库事务将消息保证到数据库表中
      • 成功发送到交换机,将消息从数据库中删除
    2. MQ持久化
      • MQ收到消息会持久化,当MQ重启,即使消息没有消费完,也不会丢失
      • 需要配置交换机持久化、队列持久化、发送消息时设置持久化
    3. 消费者确认机制
      • 消费者消费成功,自动发送ACK,负责重试消费
  • 项目使用RabbitMQ作为消息队列,我们在虚拟机上安装RabbitMQ的Docker镜像

    1. 拉取镜像
    1
    docker pull rabbitmq:3-management
    1. 启动一个RabbitMQ容器
      1
      2
      3
      4
      5
      6
      7
      8
      9
      docker run \
      -e RABBITMQ_DEFAULT_USER=root \
      -e RABBITMQ_DEFAULT_PASS=root \
      --name mq \
      --hostname mq1 \
      -p 15672:15672 \
      -p 5672:5672 \
      -d \
      rabbitmq:3-management
      • 其中:两个环境变量分别配置登录用户和密码,15672是rabbitMQ的管理平台的端口,5672是将来做消息通信的端口
    2. 我们输入虚拟机ip:15672访问RabbitMQ的管理平台,我这里设置的账号密码均为root
  • 本站也有关于MQ的入门讲解

发送支付结果

订单服务集成MQ

  • 订单服务通过消息队列将支付结果发给学习中心服务,消息队列采用发布订阅模式
    1. 订单服务创建支付结果,通知交换机
    2. 学习中心服务绑定队列到交换机
  • 交换机为Fanout广播模式
  • 首先需要在学习中心服务和订单服务工程中配置连接消息队列
    1. 在订单服务中添加消息队列依赖
    1
    2
    3
    4
    5
    <!--AMQP依赖,包含RabbitMQ-->
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    1. 在nacos中添加RabbitMQ的配置信息rabbitmq-dev.yaml,设置group为xuecheng-plus-common
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    spring:
    rabbitmq:
    host: 192.168.101.128
    port: 5672
    username: root
    password: root
    virtual-host: /
    publisher-confirm-type: correlated #correlated 异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
    publisher-returns: false #开启publish-return功能,同样是基于callback机制,需要定义ReturnCallback
    template:
    mandatory: false #定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
    listener:
    simple:
    prefetch: 1 #每次只能获取一条消息,处理完成才能获取下一个消息
    acknowledge-mode: none #auto:出现异常时返回unack,消息回滚到mq;没有异常,返回ack ,manual:手动控制,none:丢弃消息,不回滚到mq
    retry:
    enabled: true #开启消费者失败重试
    initial-interval: 1000ms #初识的失败等待时长为1秒
    multiplier: 1 #失败的等待时长倍数,下次等待时长 = multiplier * last-interval
    max-attempts: 3 #最大重试次数
    stateless: true #true无状态;false有状态。如果业务中包含事务,这里改为false
    1. 在订单服务接口工程中引入rabbitmq-dev.yaml配置文件
    1
    2
    3
    - data-id: rabbitmq-${spring.profiles.active}.yaml
    group: xuecheng-plus-common
    refresh: true
    1. 在订单服务的service工程编写MQ配置类,配置交换机
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    @Slf4j
    @Configuration
    public class PayNotifyConfig implements ApplicationContextAware {

    //交换机
    public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
    //支付结果通知消息类型
    public static final String MESSAGE_TYPE = "payresult_notify";
    //支付通知队列
    public static final String PAYNOTIFY_QUEUE = "paynotify_queue";

    //声明交换机,且持久化
    @Bean(PAYNOTIFY_EXCHANGE_FANOUT)
    public FanoutExchange paynotify_exchange_fanout() {
    // 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
    return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
    }

    //支付通知队列,且持久化
    @Bean(PAYNOTIFY_QUEUE)
    public Queue course_publish_queue() {
    return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
    }

    //交换机和支付通知队列绑定
    @Bean
    public Binding binding_course_publish_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
    return BindingBuilder.bind(queue).to(exchange);
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
    // 获取RabbitTemplate
    RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
    //消息处理service
    MqMessageService mqMessageService = applicationContext.getBean(MqMessageService.class);
    // 设置ReturnCallback
    rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
    // 投递失败,记录日志
    log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
    replyCode, replyText, exchange, routingKey, message.toString());
    MqMessage mqMessage = JSON.parseObject(message.toString(), MqMessage.class);
    //将消息再添加到消息表
    mqMessageService.addMessage(mqMessage.getMessageType(), mqMessage.getBusinessKey1(), mqMessage.getBusinessKey2(), mqMessage.getBusinessKey3());

    });
    }
    }
    • 重启服务,可以看到创建的交换机和队列

发送支付结果

  • 在OrderService中定义接口
1
2
3
4
5
/**
* 发送通知结果
* @param mqMessage 消息
*/
void notifyPayResult(MqMessage mqMessage);
  • 对应的接口实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Override
public void notifyPayResult(MqMessage mqMessage) {
// 1. 将消息体转为Json
String jsonMsg = JSON.toJSONString(mqMessage);
// 2. 设消息的持久化方式为PERSISTENT,即消息会被持久化到磁盘上,确保即使在RabbitMQ服务器重启后也能够恢复消息。
Message msgObj = MessageBuilder.withBody(jsonMsg.getBytes()).setDeliveryMode(MessageDeliveryMode.PERSISTENT).build();
// 3. 封装CorrelationData,用于跟踪消息的相关信息
CorrelationData correlationData = new CorrelationData(mqMessage.getId().toString());
// 3.1 添加一个Callback对象,该对象用于在消息确认时处理消息的结果
correlationData.getFuture().addCallback(result -> {
if (result.isAck()) {
// 3.2 消息发送成功,删除消息表中的记录
log.debug("消息发送成功:{}", jsonMsg);
mqMessageService.completed(mqMessage.getId());
} else {
// 3.3 消息发送失败
log.error("消息发送失败,id:{},原因:{}", mqMessage.getId(), result.getReason());
}
}, ex -> {
// 3.4 消息异常
log.error("消息发送异常,id:{},原因:{}", mqMessage.getId(), ex.getMessage());
});
// 4. 发送消息
rabbitTemplate.convertAndSend(PayNotifyConfig.PAYNOTIFY_EXCHANGE_FANOUT, "", msgObj, correlationData);
}
  • 订单服务收到第三方支付平台的结果时,在saveAliPayStatus()方法中添加代码,向数据库消息表添加消息并发送消息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
    /**
* 保存支付结果信息
*
* @param payStatusDto 支付结果信息
*/
public void saveAlipayStatus(PayStatusDto payStatusDto) {
// 1. 获取支付流水号
String payNo = payStatusDto.getOut_trade_no();
// 2. 查询数据库订单状态
XcPayRecord payRecord = getPayRecordByPayNo(payNo);
if (payRecord == null) {
XueChengPlusException.cast("未找到支付记录");
}
XcOrders order = xcOrdersMapper.selectById(payRecord.getOrderId());
if (order == null) {
XueChengPlusException.cast("找不到相关联的订单");
}
String statusFromDB = payRecord.getStatus();
// 2.1 已支付,直接返回
if ("600002".equals(statusFromDB)) {
return;
}
// 3. 查询支付宝交易状态
String tradeStatus = payStatusDto.getTrade_status();
// 3.1 支付宝交易已成功,保存订单表和交易记录表,更新交易状态
if ("TRADE_SUCCESS".equals(tradeStatus)) {
// 更新支付交易表
payRecord.setStatus("601002");
payRecord.setOutPayNo(payStatusDto.getTrade_no());
payRecord.setOutPayChannel("Alipay");
payRecord.setPaySuccessTime(LocalDateTime.now());
int updateRecord = xcPayRecordMapper.updateById(payRecord);
if (updateRecord <= 0) {
XueChengPlusException.cast("更新支付交易表失败");
}
// 更新订单表
order.setStatus("600002");
int updateOrder = xcOrdersMapper.updateById(order);
if (updateOrder <= 0) {
log.debug("更新订单表失败");
XueChengPlusException.cast("更新订单表失败");
}
}
+ // 4. 保存消息记录,参数1:支付结果类型通知;参数2:业务id;参数3:业务类型
+ MqMessage mqMessage = mqMessageService.addMessage("payresult_notify", order.getOutBusinessId(), order.getOrderType(), null);
+ // 5. 通知消息
+ notifyPayResult(mqMessage);
}

接收支付结果

学习中心服务集成MQ

  1. 在learning-api工程中添加消息队列依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  1. 在learning-api中引入rabbitmq-dev.yaml配置
1
2
3
- data-id: rabbitmq-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true
  1. 在learning-service中添加配置类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Slf4j
@Configuration
public class PayNotifyConfig {

//交换机
public static final String PAYNOTIFY_EXCHANGE_FANOUT = "paynotify_exchange_fanout";
//支付结果通知消息类型
public static final String MESSAGE_TYPE = "payresult_notify";
//支付通知队列
public static final String PAYNOTIFY_QUEUE = "paynotify_queue";

//声明交换机,且持久化
@Bean(PAYNOTIFY_EXCHANGE_FANOUT)
public FanoutExchange paynotify_exchange_fanout() {
// 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
return new FanoutExchange(PAYNOTIFY_EXCHANGE_FANOUT, true, false);
}

//支付通知队列,且持久化
@Bean(PAYNOTIFY_QUEUE)
public Queue course_publish_queue() {
return QueueBuilder.durable(PAYNOTIFY_QUEUE).build();
}

//交换机和支付通知队列绑定
@Bean
public Binding binding_course_publish_queue(@Qualifier(PAYNOTIFY_QUEUE) Queue queue, @Qualifier(PAYNOTIFY_EXCHANGE_FANOUT) FanoutExchange exchange) {
return BindingBuilder.bind(queue).to(exchange);
}

}

接收支付结果

  • 监听MQ,接收支付结果,定义ReceivePayNotifyServiceImpl类如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Slf4j
@Service
public class ReceivePayNotifyServiceImpl {

@Autowired
MyCourseTablesService tablesService;

@RabbitListener(queues = PayNotifyConfig.PAYNOTIFY_QUEUE)
public void receive(Message message) {
// 1. 获取消息
MqMessage mqMessage = JSON.parseObject(message.getBody(), MqMessage.class);
// 2. 根据我们存入的消息,进行解析
// 2.1 消息类型,学习中心只处理支付结果的通知
String messageType = mqMessage.getMessageType();
// 2.2 选课id
String chooseCourseId = mqMessage.getBusinessKey1();
// 2.3 订单类型,60201表示购买课程,学习中心只负责处理这类订单请求
String orderType = mqMessage.getBusinessKey2();
// 3. 学习中心只负责处理支付结果的通知
if (PayNotifyConfig.MESSAGE_TYPE.equals(messageType)){
// 3.1 学习中心只负责购买课程类订单的结果
if ("60201".equals(orderType)){
// 3.2 保存选课记录
boolean flag = tablesService.saveChooseCourseStatus(chooseCourseId);
if (!flag){
XueChengPlusException.cast("保存选课记录失败");
}
}
}
}
}
  • 对应的保存选课记录方法如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
@Transactional
public boolean saveChooseCourseStatus(String chooseCourseId) {
// 1. 根据选课id,查询选课表
XcChooseCourse chooseCourse = chooseCourseMapper.selectById(chooseCourseId);
if (chooseCourse == null) {
log.error("接收到购买课程的消息,根据选课id未查询到课程,选课id:{}", chooseCourseId);
return false;
}
// 2. 选课状态为未支付时,更新选课状态为选课成功
if ("701002".equals(chooseCourse.getStatus())) {
chooseCourse.setStatus("701001");
int update = chooseCourseMapper.updateById(chooseCourse);
if (update <= 0) {
log.error("更新选课记录失败:{}", chooseCourse);
}
}
// 3. 向我的课程表添加记录
addCourseTables(chooseCourse);
return true;
}

通知支付结果测试

  • 测试准备
    1. 找一门已经发布的免费课程
    2. 如果该课程已经存在于我的课程表,则删除
    3. 删除此课程的支付交易记录和订单记录
  • 测试流程
    1. 进入收费课程页面,点击马上学习,生成二维码进行支付
    2. 点击支付完成(或者被动接收支付通知,内网穿透工具别关)
    3. 观察数据库中的消息表以及消息记录表和我的选课表中是否有对应的记录

在线学习

需求分析

  • 用户通过课程详情界面点击马上学习,进入视频播放页面进行视频点播
  • 获取视频资源时,进行学习资格校验
  • 拥有学习资格则继续播放视频,不具有学习资格,则引导其去购买、续期等操作
  • 如何判断是否拥有学习资格?
    • 首先判断是否为试学视频,如果为试学视频,则可以正常学习
    • 如果为非试学视频,则先判断用户是否登录,如果已经登录则判断是否选课,如果已经选课,且没有过期则可以正常学习

查询课程信息

  • 在视频点播页面需要查询课程信息,课程上线后也需要访问:/api/content/course/whole/{courseId}
  • 课程预览时,请求获取课程接口为:/open/content/course/whole/{courseId}
  • 注意要在nginx中添加如下配置
1
2
3
4
5
6
7
8
9
10
11
#api
location /api/ {
proxy_pass http://gatewayserver/;
}
#openapi
location /open/content/ {
proxy_pass http://gatewayserver/content/open/;
}
location /open/media/ {
proxy_pass http://gatewayserver/media/open/;
}
  • 下面实现获取课程发布信息接口,在content-api中的CoursePublishController类,定义查询课程发布信息接口
1
2
3
4
5
@ApiOperation("获取课程发布信息")
@GetMapping("/course/whole/{courseId}")
public CoursePreviewDto getCoursePreviewDto(@PathVariable("courseId") Long courseId) {
return coursePublishService.getCoursePreviewInfo(courseId);
}
  • 这里调用的方法在很久之前我们就写过了
1
2
3
4
5
6
7
8
9
10
11
12
@Override
public CoursePreviewDto getCoursePreviewInfo(Long courseId) {
CoursePreviewDto coursePreviewDto = new CoursePreviewDto();
// 根据课程id查询 课程基本信息、营销信息
CourseBaseInfoDto courseBaseInfo = courseBaseInfoService.getCourseBaseInfo(courseId);
// 根据课程id,查询课程计划
List<TeachplanDto> teachplanDtoList = teachplanService.findTeachplanTree(courseId);
// 封装返回
coursePreviewDto.setCourseBase(courseBaseInfo);
coursePreviewDto.setTeachplans(teachplanDtoList);
return coursePreviewDto;
}
  • 重启内容管理服务,进入学习界面查看课程计划、课程名称等信息是否正常

获取视频

需求分析

接口定义

  • 先来思考一下我们需要传入什么参数
    • 学生现在的需求是在线观看学习视频
      • 观看的课程也有课程id,即courseId
      • 课程中的每个小节是教学计划,即teachplanId
      • 每个教学计划都绑定了对应的媒资信息(视频),即mediaId
    • 关于学生观看视频的权限,我们可以用之前的SecurityUtil,来获取当前登录用户的userId
  • 在MyLearningController中定义接口如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Api(value = "学习课程管理接口", tags = "学习课程管理接口")
@Slf4j
@RestController
public class MyLearningController {

@GetMapping("/open/learn/getvideo/{courseId}/{teachplanId}/{mediaId}")
public RestResponse<String> getVideo(@PathVariable("courseId") Long courseId, @PathVariable("teachplanId") Long teachplanId, @PathVariable("mediaId") String mediaId) {
// 1. 获取登录用户
SecurityUtil.XcUser user = SecurityUtil.getUser();
String userId = null;
if (user != null) {
userId = user.getId();
}

return null;
}

}
  • 定义Service接口
1
2
3
public interface LearningService {
RestResponse<String> getVideo(String userId, Long courseId, Long teachplanId, String mediaId);
}
  • 获取课程信息远程接口
1
2
3
4
5
6
7
8
9
10
11
@FeignClient(value = "content-api", fallbackFactory = ContentServiceClientFallbackFactory.class)
public interface ContentServiceClient {

@ResponseBody
@GetMapping("/content/r/coursepublish/{courseId}")
CoursePublish getCoursePublish(@PathVariable("courseId") Long courseId);

@PostMapping("/content/teachplan/{teachplanId}")
Teachplan getTeachplan(@PathVariable Long teachplanId);

}
  • 这里要在TeachPlanController中定义对应的接口
1
2
3
4
5
@ApiOperation("课程计划查询")
@PostMapping("/teachplan/{teachplanId}")
public Teachplan getTeachplan(@PathVariable Long teachplanId) {
return teachplanService.getTeachplan(teachplanId);
}
  • 对应的实现方式也很简单
1
2
3
4
@Override
public Teachplan getTeachplan(Long teachplanId) {
return teachplanMapper.selectById(teachplanId);
}
  • 对应的降级方法如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Slf4j
@Component
public class ContentServiceClientFallbackFactory implements FallbackFactory<ContentServiceClient> {
@Override
public ContentServiceClient create(Throwable throwable) {
return new ContentServiceClient() {

@Override
public CoursePublish getCoursePublish(Long courseId) {
log.error("调用内容管理服务查询课程信息发生熔断:{}", throwable.toString(),throwable);
return null;
}

@Override
public Teachplan getTeachplan(Long teachplanId) {
log.error("调用内容管理服务查询教学计划发生熔断:{}", throwable.toString(),throwable);
return null;
}
};
}
}
  • 获取视频远程接口
    • 由于最终我们需要将课程视频的URL返回给前端,所以我们这里需要远程调用媒资管理服务,获取视频的URL
      • 所以我们需要在learning-service中定义媒资管理FeignClient
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 远程调用媒资管理服务
*/
@FeignClient(value = "media-api", fallbackFactory = MediaServiceClientFallbackFactory.class)
@RequestMapping("/media")
public interface MediaServiceClient {

/**
* 获取媒资url
* @param mediaId 媒资id
* @return
*/
@GetMapping("/preview/{mediaId}")
RestResponse<String> getPlayUrlByMediaId(@PathVariable String mediaId);
}
  • 对应的降级方法如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Slf4j
@Component
public class MediaServiceClientFallbackFactory implements FallbackFactory<MediaServiceClient> {
@Override
public MediaServiceClient create(Throwable throwable) {
return new MediaServiceClient() {
@Override
public RestResponse<String> getPlayUrlByMediaId(String mediaId) {
log.error("远程调用媒资管理服务熔断异常:{}", throwable.getMessage());
return null;
}
};
}
}

学习资格校验

  • 编写获取视频的接口实现方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Slf4j
@Service
public class LearningServiceImpl implements LearningService {
@Autowired
MyCourseTablesService courseTablesService;

@Autowired
ContentServiceClient contentServiceClient;

@Autowired
MediaServiceClient mediaServiceClient;

@Override
public RestResponse<String> getVideo(String userId, Long courseId, Long teachplanId, String mediaId) {
// 1. 远程调用内容管理服务,查询课程信息
CoursePublish coursePublish = contentServiceClient.getCoursePublish(courseId);
if (coursePublish == null) {
return RestResponse.validfail("课程信息不存在");
}
// 2. 判断试学规则,远程调用内容管理服务,查询教学计划teachplan
Teachplan teachplan = contentServiceClient.getTeachplan(teachplanId);
// 2.1 isPreview字段为1表示支持试学,返回课程url
if ("1".equals(teachplan.getIsPreview())) {
return mediaServiceClient.getPlayUrlByMediaId(mediaId);
}
// 3. 非试学,登录状态
if (StringUtil.isNotEmpty(userId)) {
// 3.1 判断是否选课
XcCourseTablesDto learningStatus = courseTablesService.getLearningStatus(userId, courseId);
String learnStatus = learningStatus.getLearnStatus();
if ("702002".equals(learnStatus)) {
RestResponse.validfail("没有选课或选课后没有支付");
} else if ("702003".equals(learnStatus)) {
RestResponse.validfail("已过期需要申请续期或重新支付");
} else {
return mediaServiceClient.getPlayUrlByMediaId(mediaId);
}
}
// 4. 非试学,未登录状态
String charge = coursePublish.getCharge();
// 4.1 免费课程,返回课程url
if ("201000".equals(charge)) {
return mediaServiceClient.getPlayUrlByMediaId(mediaId);
}
return RestResponse.validfail("请购买课程后学习");
}
}

测试

  1. 完善接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Api(value = "学习课程管理接口", tags = "学习课程管理接口")
@Slf4j
@RestController
public class MyLearningController {
@Autowired
LearningService learningService;

@GetMapping("/open/learn/getvideo/{courseId}/{teachplanId}/{mediaId}")
public RestResponse<String> getVideo(@PathVariable("courseId") Long courseId, @PathVariable("teachplanId") Long teachplanId, @PathVariable("mediaId") String mediaId) {
// 1. 获取登录用户
SecurityUtil.XcUser user = SecurityUtil.getUser();
String userId = null;
if (user != null) {
userId = user.getId();
}
return learningService.getVideo(userId, courseId, teachplanId, mediaId);
}
}
  1. 准备测试
    • 准备一门收费课程,其中一个章节可以试学,打断点进行测试
    • 上图中,成功在判断试学成功后返回了视频地址,放行后可以正常播放视频

我的课程表

需求分析

  • 登录网站,点击“我的学习”进入个人中心,会显示选课成功的免费课程、收费课程。

配置Nginx

  • 在nginx中配置用户中心server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
server {
listen 80;
server_name ucenter.localhost;
#charset koi8-r;
ssi on;
ssi_silent_errors on;
#access_log logs/host.access.log main;
location / {
alias D:\BaiduNetdiskDownload\xc-ui-pc-static-portal\ucenter/;
index index.html index.htm;
}
location /include {
proxy_pass http://127.0.0.1;
}
location /img/ {
proxy_pass http://127.0.0.1/static/img/;
}
location /api/ {
proxy_pass http://gatewayserver/;
}
}
  • 在hosts文件中配置如下内容
1
127.0.0.1 ucenter.localhost

接口定义

  • 在MyCourseTablesController中定义我的课程表接口
1
2
3
4
5
6
@ApiOperation("我的课程表")
@GetMapping("/mycoursetable")
public PageResult<XcCourseTables> myCourseTables(MyCourseTableParams params){

return null;
}

接口开发

  • 在Service中定义我的课程表接口
1
PageResult<XcCourseTables> myCourseTables(MyCourseTableParams params);
  • 对应的接口实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Override
public PageResult<XcCourseTables> myCourseTables(MyCourseTableParams params) {
// 1. 获取页码
int pageNo = params.getPage();
// 2. 设置每页记录数,固定为4
long pageSize = 4;
// 3. 分页条件
Page<XcCourseTables> page = new Page<>(pageNo, pageSize);
// 4. 根据用户id查询课程
String userId = params.getUserId();
LambdaQueryWrapper<XcCourseTables> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(XcCourseTables::getUserId, userId);
// 5. 分页查询
Page<XcCourseTables> pageResult = courseTablesMapper.selectPage(page, queryWrapper);
// 6. 获取记录总数
long total = pageResult.getTotal();
// 7. 获取记录
List<XcCourseTables> records = pageResult.getRecords();
// 8. 封装返回
return new PageResult<>(records, total, pageNo, pageSize);
}
  • 完善Controller
1
2
3
4
5
6
7
8
9
10
11
@ApiOperation("我的课程表")
@GetMapping("/mycoursetable")
public PageResult<XcCourseTables> myCourseTables(MyCourseTableParams params) {
SecurityUtil.XcUser user = SecurityUtil.getUser();
if (user == null) {
XueChengPlusException.cast("请登录后查看课程表");
}
String userId = user.getId();
params.setUserId(userId);
return myCourseTablesService.myCourseTables(params);
}

接口测试

  • 登录网站,点击我的学习进入学习中心,查询我的课程表中是否有当前用户所选课程
  • 可能遇到的bug,跳转到学习中心后,登录失效
    • 原因:cookie作用域的问题
    • 修改前端文件中util.js里的setCookie方法,将domain设置为.localhost
  • 成功查询到我的课程表