使用token保证接口幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
设计思想
这种token令牌机制应该是市面上用的比较多的一种保证幂等方式,简单理解,就是每次请求都拿着一张门票,这个门票是一次性的,用过一次就被毁掉了,不能重复利用。这个token令牌就相当于门票的概念,每次接口请求的时候带上token令牌,服务器第一次处理的时候去校验token,并且这个token只能用一次,如果用户使用相同的令牌请求二次,那么第二次就不处理,直接返回。
大体的流程如下:
- 服务端提供了发送Token的接口,在执行业务前,先去获取Token,服务器会把Token保存到redis中;
- 然后调用业务接口请求时,把token携带过去,一般作为请求参数或者请求头中传递;
- 服务器判断token是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务;
- 服务器如果短时间内重复提交这个接口,因为两次请求token是一样的,所以第二次请求的时候,
服务器校验token时,redis中已经没有了刚刚被第一次删掉的token,就表示是重复操作,所以第二次请求会校验失败,不作处理,这样就保证了业务代码,不被重复执行;
代码实现
服务器端代码:
提供产生token的代码:
package com.wxclient.controller;
import com.fasterxml.uuid.Generators;
import com.wxclient.feign.hospital.HospitalFeign;
import com.wxclient.feign.hospital.ResevationFeign;
import com.wxclient.utils.TokenUtil;
import com.yl.entitys.RespEntity;
import com.yl.entitys.YlScheduling;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
*
* @author songmingsong
*/
@CrossOrigin(origins = "*",maxAge = 3600)
@Slf4j
@RestController
@RequestMapping("/system/idempotence")
public class IdempotenceController {
@Autowired
private RedisTemplate redisTemplate;
public static final String USER_TOKEN_PREFIX = "idempotence:token:";
/**
* 参数token , 放入redis set数据结构,防止重复,返回给前端,做接口幂等性
*/
@GetMapping("/{userId}")
public RespEntity idempotence(@PathVariable Integer userId) {
// 基于时间的UUID(全球唯一)
UUID uuid = Generators.timeBasedGenerator().generate();
// 将token 放入redis set中 ,5分钟的过期时间
redisTemplate.opsForValue().set(USER_TOKEN_PREFIX+userId, uuid,5, TimeUnit.MINUTES);
log.debug("【系统日志】产生的TOKEN->{}", uuid);
return RespEntity.okData(uuid);
}
}
比如我要新增一个数据,我点击新增时就会请求这个接口:
// 请求token接口,保证幂等性
idempotence() {
// 解析jwt
// token变量传需要解析的jwt值
var jwt = localStorage.getItem("jwt");
let strings = jwt.split("."); //截取token,获取载体
var userinfo = JSON.parse(decodeURIComponent(escape(window.atob(strings[1].replace(/-/g, "+").replace(/_/g, "/")))));
// // 获取用户ID
let userid = userinfo.id;
setRedisToken(userid).then((res) => {
if (res.states == 500) {
this.$notify({
title: '服务繁忙,请稍后再试',
message: res.msg,
type: 'error',
duration: 5000
})
return;
}
if (res.states == 200) {
this.token = res.object;
}
})
},
我的前端界面:
我点击确定时,向后台发送数据,并把这个token,放在请求头中
export function createArticle(data,idempotence) {
return request({
url: '/yl-system/yl-notice/',
method: 'post',
data,
headers: {
"idempotence": idempotence
}
})
}
// 添加数据
createData() {
var token = this.token;
this.$refs['dataForm'].validate((valid) => {
if (valid) {
createArticle(this.temp, token).then((res) => {
if (res.states == 201) {
this.$notify({
title: '失败',
message: res.msg,
type: 'error',
duration: 5000
})
return
}
this.dialogFormVisible = false
this.getList()
this.$notify({
title: '成功',
message: '创建成功',
type: 'success',
duration: 2000
})
})
}
})
},
后台执行业务代码前进行校验:
/**
* 添加公告信息
*/
@ApiOperation(value = "添加公告信息")
@PostMapping("/")
public RespEntity add(@RequestBody YlNotice notice, HttpServletRequest httpServletRequest) {
log.debug("【系统日志】添加公告信息---》");
// 验证幂等性的标识
String idempotence = httpServletRequest.getHeader("idempotence");
// 用户的JWT信息
String jwt = httpServletRequest.getHeader("jwt");
JWT token = JWTUtil.parseToken(jwt);
Integer userId = (Integer) token.getPayload("id");
// 获取用户ID
log.debug("【系统日志】用户:{}->",userId);
log.debug("【幂等性】idempotence:{}->",idempotence);
//获取redis中的令牌【令牌的对比和删除必须保证原子性】
//LUA脚本 返回0表示校验令牌失败 1表示删除成功,校验令牌成功
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Arrays.asList(USER_TOKEN_PREFIX + userId),
idempotence);
if (result == 1) {
log.debug("【幂等性】OK:{}->",idempotence);
log.debug("【系统日志】redis验证成功:{}->",idempotence);
//令牌验证成功
//去创建、下订单、验令牌、验价格、锁定库存...
if (notice.getNoticeTitlet().isEmpty()){
return new RespEntity(501, "公告标题不能为空", null);
}
if (notice.getNoticeContent().isEmpty()){
return new RespEntity(501, "公告内容不能为空", null);
}
// 默认启用
notice.setNoticeStates("y");
LocalDateTime dateTime = LocalDateTime.now(); // 获取当前时间
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
notice.setNoticeTime(dateTime.format(formatter));
noticeService.save(notice);
return RespEntity.SUCCESS;
} else {
log.debug("【幂等性】ERROR:{}->",idempotence);
log.debug("【系统日志】redis验证失败:{}->",idempotence);
//令牌校验失败,返回失败信息
return RespEntity.FAIL;
}
}
以下是我的前端界面