签名认证

为什么要做签名认证?

如果你直接把接口暴露出去,黑客可以通过抓包拿到请求地址,然后用脚本疯狂刷你的接口,导致你的服务器瘫痪,甚至把你的数据库拖垮

防守策略(AK/SK 机制):

  1. AK (AccessKey):是公开的,代表“你是谁”。每次请求都要带在请求头(Header)里。
  2. SK (SecretKey):是绝密的,代表“你的密码”。绝对不能放在请求头里在网络上传输!
  3. Sign (签名):客户端在发请求前,把请求参数和绝密的 SK 拼接在一起,用 MD5 等算法算出一串“乱码”(这就是签名)。
  4. 验证:服务端收到请求后,拿到明文的 AK,去数据库查出对应的 SK。然后服务端用同样的参数和 SK 再算一次签名。如果两次签名一致,说明请求确实是这个用户发出的,且参数没有被篡改。

实现签名生成算法

第一步:打造签名生成算法

我们先回到 api-platform-common 模块,把这个签名算法写成一个通用的工具类,这样以后不管是客户端发请求,还是服务端做校验,都可以复用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* API签名工具类
*/
public class SignUtils {
/**
* 生成API调用签名
*
* @param body 请求体内容(或者请求参数)
* @param secreKey 用户的私钥
* @return 经过MD5加密的签名字符串
*/
public static String genSign(String body,String secreKey){

// 防止明文拼接被破解,可以在body和secretKey之间加入一个固定的分隔符,增加破解难度
String content = body+ "." + secreKey;

return DigestUtils.md5DigestAsHex(content.getBytes(StandardCharsets.UTF_8));
// TODO:一般还会把随机数(Nonce)和时间戳(Timestamp)加入签名拼接中,以此防范“重放攻击”
// 目前先用最精简的 body + SK 跑通主流程,后面做网关拦截时可以再加固
}
}

第二步:定义标准的请求头契约

为了让签名机制生效,客户端每次调用我们的接口,都必须在 HTTP 请求头(Header)里携带以下四个关键信息:

  1. accessKey:标识调用者身份。
  2. nonce:随机数(防止重放攻击)。
  3. timestamp:时间戳(防止请求过期)。
  4. sign:利用我们刚才的工具类算出来的签名。

到这里签名算法工具就准备好了,接下来就是在服务端写一个拦截器,专门去扒取请求头里的签名,并去数据库里查信息对比

实现服务端拦截器

接下来,需要在 api-platform-interface 模块里建立这道“安检门”,标准步骤如下:

  1. 拦截请求:在 HTTP 请求到达 NameController 之前,强制把它拦下。
  2. 扒取请求头:从 Header 中提取出调用方传来的 accessKeynoncetimestampsign
  3. 风控基础校验
    • 防过期:判断 timestamp 是不是超过了 5 分钟?(防止黑客拿着几天前的请求一直刷)。
    • 防重放:判断 nonce(随机数)是不是在短时间内已经被用过了?
  4. 核心签名校验:根据 accessKey 去数据库查出这个用户的 secretKey。服务端用同样的参数和查到的私钥再算一遍签名,如果算出来的结果和传过来的 sign 严丝合缝,安检放行;否则,直接报“无权限”踢出。

在实现拦截器时,一般有两个注意点:

  1. 这个拦截动作通常会放在**统一网关(Gateway)**里做,而不是每个具体的微服务自己做。
  2. 校验签名时,需要去数据库查 SecretKey。但我们的 api-platform-interface 是一个模拟第三方接口的独立模块,它没有连接主数据库。

所以为了跑通核心的签名算法和防刷逻辑,这个阶段先在这个 interface 模块里写一个本地拦截器,并且在代码里Mock一个正确的 AK 和 SK。等到了项目第四阶段(引入 Gateway 和 RPC),我们会把这段逻辑无缝迁移到网关,并连上真实的数据库。

第一步:引入 Common 模块依赖

我们需要用到刚才在 common 模块写的 SignUtils 工具类,添加下面的依赖:

1
2
3
4
5
<dependency>
<groupId>com.accycx</groupId>
<artifactId>api-platform-common</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

第二步:编写核心安全拦截器 (Interceptor)

api-platform-interfaceapiinterface 下新建包 interceptor,然后创建 ApiAuthInterceptor.java

这段代码包含了防伪造、防重放、防过期的风控逻辑:

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
/**
* API 调用全局权限拦截器
*/
@Component
public class ApiAuthInterceptor implements HandlerInterceptor {

// 模拟数据库中查出来的分配给这个用户的真实AK和SK
private static final String MOCK_AK = "accycx_test_ak";
private static final String MOCK_SK = "accycx_test_sk";

@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception{
// 1.从请求头中扒取调用方带过来的凭证
String accessKey = request.getHeader("accessKey");
String nonce = request.getHeader("nonce");
String timestamp = request.getHeader("timestamp");
String sign = request.getHeader("sign");
String body = request.getHeader("body");

// 2.校验AK是否存在及合法
if(accessKey == null || !accessKey.equals(MOCK_AK)){
throw new RuntimeException("无权限:AccessKey 错误或不存在");
}

// 3.防重放:校验随机数(简单版)
// 一般做法:把nonce存进Redis,如果发现这个nonce已经存在,说明是黑客在重放攻击,直接拒绝
// 这里先简单校验长度,大于4位即可
if(nonce == null || nonce.length() <4){
throw new RuntimeException("无权限:非法请求(Nonce 不合法)");
}

// 4.防过期:校验时间戳
if(timestamp == null){
throw new RuntimeException("无权限:缺少时间戳");
}

// 计算当前时间与请求时间的差值(设定请求有效期为5分钟)
long currentTime = System.currentTimeMillis() / 1000;
final long FIVE_MINUTES = 5 * 60;
if((currentTime - Long.parseLong(timestamp)) >= FIVE_MINUTES){
throw new RuntimeException("无权限:请求过期");
}

// 5.校验签名
// 服务端使用同样的body和查出来的真实SK再算一遍签名
String serverSign = SignUtils.genSign(body,MOCK_SK);

// 比对调用方传来的签名和算出来的签名是否一致
if(sign == null || !sign.equals(serverSign)){
throw new RuntimeException("无权限:签名校验失败,数据可能被篡改!");
}

// 所有安检通过,放行请求,进入对应的Controller
return true;
}
}

这段代码采用了 Timestamp + Nonce 的双重防御。首先判断 Timestamp 是否过期(通常是 5 分钟),拦截掉旧请求。然后将 Nonce(随机数)存入 Redis 并设置 5 分钟过期时间。每次请求来时去 Redis 查,如果 Nonce 已经存在,说明是重放攻击,直接拒绝。这两者结合,保证了安全又不会撑爆 Redis 内存。

当然现阶段还没有引入Redis,所以到阶段四的时候会完善。

第三步:注册拦截器并挂载到接口上

拦截器写好了,但 Spring Boot 还不知道要把这扇门安在哪里。我们需要写一个配置类,告诉系统:“所有访问 /** 的请求,都必须走这个安检门。”

apiinterface 包下新建包 config,创建 MvcConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* Web MVC 配置类
*/
@Configuration
public class MvcConfig implements WebMvcConfigurer {

@Autowired
private ApiAuthInterceptor apiAuthInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry){
// 将拦截器注册到Spring MVC中
registry.addInterceptor(apiAuthInterceptor)
.addPathPatterns("/**");//拦截所有进入此服务的请求
}
}

现在重新启动接口服务并访问之前测试过的简单的地址:

http://localhost:8102/name/get?name=accycx

会抛出500异常,控制台会打印出写的报错信息:“无权限:AccessKey错误或不存在”,这就说明拦截器生效了

SDK

模拟用户使用接口服务

由于手写底层的 HttpURLConnection太过繁琐,所以我们在这里引入Hutool工具包

第一步:引入 Hutool 工具包

api-platform-interface 模块引入依赖:

1
2
3
4
5
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>

第二步:编写客户端调用类 (ApiClient.java)

api-platform-interface 模块的apiinterface 下新建一个包 client,然后创建 ApiClient.java

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
/**
* 调用第三方接口的客户端类
*/
public class ApiClient {

private final String accessKey;
private final String secretKey;

// 构造方法,强制要求调用者传入AK和SK
public ApiClient(String accessKey,String secretKey){
this.accessKey = accessKey;
this.secretKey = secretKey;
}

/**
* 核心逻辑:组装请求头
* 把凭证全部放在Header里
*/
private Map<String,String> getHeaderMap(String body){
Map<String,String> hashMap = new HashMap<>();
hashMap.put("accessKey",accessKey);

// 生成随机数(防重放)
hashMap.put("nonce", RandomUtil.randomNumbers(4));

// 生成当前时间戳(防过期)
hashMap.put("timestamp",String.valueOf(System.currentTimeMillis() / 1000));

// 将请求体参与签名计算
hashMap.put("body",body);

// 生成签名
hashMap.put("sign", SignUtils.genSign(body,secretKey));

return hashMap;
}

// 1.调用GET接口
public String getNameByGet(String name){
// Hutool的HttpUtil可以简化HTTP请求的发送
HashMap<String,Object> paramMap = new HashMap<>();
paramMap.put("name",name);
String result = HttpUtil.get("http://localhost:8102/name/get",paramMap);
System.out.println(result);
return result;
}

// 2.调用POST URL传参接口
public String getNameByPost(String name){
HashMap<String,Object> paramMap = new HashMap<>();
paramMap.put("name",name);
String result = HttpUtil.post("http://localhost:8102/name/post",paramMap);
System.out.println(result);
return result;
}

// 3.调用POST JSON接口(携带签名)
public String getUserNameByPost(User user){
// 将User对象转为JSON字符串
String json = JSONUtil.toJsonStr(user);

// 发送带请求头的HTTP请求
HttpResponse httpResponse = HttpRequest.post("http://localhost:8102/name/user")
.addHeaders(getHeaderMap(json)) //关键:把算好的签名头放进去
.body(json) //塞入请求体
.execute();

System.out.println(httpResponse.body());
String result = httpResponse.body();
System.out.println(result);
return result;

}
}

第三步:测试安检门

api-platform-interfacesrc/test/java 目录下建一个 Main.java 测试类,编写测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Main {
public static void main(String[] args) {
// 1. 填入在拦截器里 Mock 好的真实 AK 和 SK
String accessKey = "accycx_test_ak";
String secretKey = "accycx_test_sk";
// 2. 实例化客户端
ApiClient apiClient = new ApiClient(accessKey, secretKey);
// 3. 准备参数
User user = new User();
user.setUsername("accycx");
// 4. 发起带有签名验证的请求
System.out.println("----- 测试开始 -----");
String result = apiClient.getUserNameByPost(user);
System.out.println("服务端返回结果"+ result);
}
}

启动接口服务,并运行测试类,得到结果:

测试结果

故意把 String secretKey = "accycx_test_sk";改错后再测试,后台会返回错误信息:

测试结果

开发SDK

为什么要开发 SDK?

刚才我们为了测试接口,在 Main 方法里手动组装了 Header,手动算了 Sign。 试想一下,如果你的平台有 1000 个开发者接入,难道你要让这 1000 个人每个人都去研究你的签名算法,然后各自写一遍 SignUtilsApiClient 吗? 如果他们算错了哪怕一个字符,就会一直报 500 错误,然后疯狂找你对线。

解决方法:官方提供一个 SDK(比如一个定制的 Spring Boot Starter)。开发者只需要引入这个依赖,在 application.yml 里填上你发给他的 AK/SK,剩下的签名计算、请求头拼接,SDK 全部在底层自动搞定。开发者只需要写一行代码 apiClient.getUserNameByPost(user) 就能拿到数据。

第一步:创建独立的 SDK 模块

由于 SDK 是要打成 jar 包发给别人用的,它必须是一个干净、纯粹的模块,所以在根工程下,新建一个模块,命名为 api-platform-client-sdk

  1. 配置 pom.xml

这是 SDK 的核心依赖,注意,我们要引入 spring-boot-configuration-processor,这是做自定义 Starter 的灵魂,它能让使用 SDK 的人在 application.yml 里敲代码时拥有自动提示功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  <dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.16</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>
  1. 搬运核心资产 (代码大迁移)

为了让 SDK 独立运行,我们需要把刚才在其他模块写的几个类原封不动地复制到 api-platform-client-sdksrc/main/java/com/jingxuan/apiclientsdk 包下:

  1. User.java (专门接收参数的小模型)
  2. SignUtils.java (签名工具)
  3. ApiClient.java (客户端)

然后把api-platform-interface包下的ApiClient.java删了

第二步:编写自动装配类 (AutoConfiguration)

我们要写一段配置代码,让 Spring Boot 在启动时,自动读取配置文件里的 AK/SK,然后自动帮我们创建一个 ApiClient 实例扔进 Spring 容器里(@Bean)。

1. 创建属性配置类

apiclientsdk 包下新建 client 包,创建 ApiClientConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* ApiClient 自动配置类
*/
@Configuration
//这个注解的意思是:去 application.yml 里读取前缀为 "api.client" 的配置,映射到这个类的属性上
@ConfigurationProperties("api.client")
@Data
@ComponentScan
public class ApiClientConfig {

private String accessKey;
private String secretKey;

/**
* 将ApiClient 注入到Spring容器中国
*/
public ApiClient apiClient(){
// 使用配置文件中读取到的ak和sk实例化客户端
return new ApiClient(accessKey, secretKey);
}
}

2. 注册自动配置类

由于我们是要做一个给别人用的 jar 包,别人项目启动时,默认只会扫描他们自己包下的类,根本扫不到我们写的 ApiClientConfig

src/main/resources 目录下,依次新建三个嵌套文件夹:META-INF -> spring。 最终路径为:src/main/resources/META-INF/spring

在这个文件夹下,新建一个文本文件,名字必须叫:org.springframework.boot.autoconfigure.AutoConfiguration.imports *

在这个文件中,写入刚才写的配置类的全限定名:

1
com.accycx.apiclientsdk.client.ApiClientConfig

完成这两步之后,API开放平台客户端SDK就大功告成了,下面在interface模块里面引入它测试一下

模拟测试

第一步:将 SDK 打包并安装到本地仓库

现在写的SDK只是普通代码,我们需要把它变成一个 .jar 包,并放到电脑本地的 Maven 仓库(.m2 文件夹)里,这样其他模块才能引用它。

在maven面板里面给SDK模块cleaninstall一遍,SDK就发布好了。

为什么要点 install 而不是 package

因为 package 只是把 jar 包打在当前模块的 target 目录下;而 install 会把打好的 jar 包安装到本地的 Maven 仓库中,让整台电脑里的其他项目都能通过 pom.xml 搜到并使用它。

第二步:在测试模块引入 SDK

现在回到之前测试用的 api-platform-interface 模块,我们要把刚刚自己写的 SDK 像引入 Spring Boot 官方组件一样引进来。

打开 api-platform-interface 模块的 pom.xml,在 <dependencies> 中加入:

1
2
3
4
5
<dependency>
<groupId>com.accycx</groupId>
<artifactId>api-platform-client-sdk</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

第三步:在配置文件中填入凭证(极简配置)

打开 api-platform-interface 模块的 src/main/resources/application.yml,在最下面加上我们在 SDK 里定义好的配置前缀:

1
2
3
4
api:
client:
access-key: jingxuan_test_ak
secret-key: jingxuan_test_sk

第四步:使用 Spring Boot Test 测试

既然交给了 Spring Boot 自动装配,我们就不能用普通的 main 方法测试了,因为普通的 main 方法没有启动 Spring 容器。我们需要用 Spring Boot 的单元测试。

api-platform-interfacesrc/test/java/com/accycx/apiinterface 目录下,新建(或修改已有的)测试类 ApiInterfaceApplicationTests.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@SpringBootTest
class ApiPlatformInterfaceApplicationTests {

// 这个没有写任何 new ApiClient() 的代码,直接注入就能用
// 因为我们写的 SDK 里的 AutoConfiguration 已经在后台帮我们把 application.yml 里的密钥塞进去并实例化了。
@Resource
private ApiClient apiClient;

@Test
void contextLoads() {
// 1. 准备参数
User user = new User();
user.setUsername("AccyCx");

// 2. 一行代码,直接调用!SDK 在底层会自动算好签名、拼好请求头并发送。
String result = apiClient.getUserNameByPost(user);

System.out.println("测试结果:" + result);
}
}

点击测试,可以看到控制台成功打印了结果,如图

测试结果

到这里就说明我们的自定义SDK已经大功告成了!

但是,我们刚才只是在interface 模块里“本地模拟”了第三方开发者。这只证明了我们的SDK能发请求,interface 能拦请求,整个平台的闭环还没有打通。

  • 缺失的拼图一:动态分配凭证。 现在的 AK/SK 都是我们为了跑通主流程在代码里写死的(accycx_test_ak)。真实情况是,每个用户注册后,会在主平台的数据库里生成自己独一无二的 AK/SK。
  • 缺失的拼图二:真实的验签逻辑。 我们的 interface 服务现在是在本地拦截器里写死了AK、SK 来对比。真实的业务是:interface 服务收到请求后,必须通过某种方式,拿着用户传过来的 accessKey,去主数据库里查出对应的 secretKey,才能完成验签。

所以,下一步要进行:主后台与SDK联动。

主后台与SDK联动

我们要把刚才写好的、纯净无暇的 SDK 接入到主业务模块 api-platform-backend 中,让主后台真正拥有调用 API 的能力。

未来的业务场景:

管理员可以在主平台的管理界面上,直接点击一个“在线测试调用”按钮,主后台底层就会使用这个 SDK,自动带着管理员的 AK/SK 去调用 interface 模块里的真实接口,并把结果展示在页面上!

第一步:在主业务后台引入 SDK

打开 api-platform-backend 模块的 pom.xml,把我们的轮子装上去:

1
2
3
4
5
<dependency>
<groupId>com.accycx</groupId>
<artifactId>api-platform-client-sdk</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>

第二步:给主后台配置“管理员”凭证

为了让主后台能代表平台方去“在线测试”接口,我们需要给主后台配置一套凭证(这也是我们 SDK 自动装配必须的)。

打开 api-platform-backend 模块的 src/main/resources/application.yml,在底部添加:

1
2
3
4
api:
client:
access-key: accycx_admin_ak
secret-key: accycx_admin_sk

第三步:编写“在线调用”业务逻辑

我们在之前的InterfaceInfoController,增加一个“在线测试调用”的接口。

1. 新建在线测试请求体 (DTO)api-platform-model 模块下的 com.accycx.model.dto.interfaceinfo 包中,新建 InterfaceInfoInvokeRequest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 接口调用请求体
*/
@Data
public class InterfaceInfoInvokeRequest implements Serializable {

// 接口主键id
private Long id;

// 用户传入的测试参数(如果是JSON格式,那就是那一串JSON字符串)
private String userRequestParams;

@Serial
private static final long serialVersionUID = 1L;
}

2. 增加调用 Controller 接口 回到 api-platform-backend 模块的 InterfaceInfoController,在最下面添加这个接口:

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
    @Resource
private ApiClient apiClient;

/**
* 在线调用(测试)接口
*/
@PostMapping("/invoke")
@Operation(summary = "在线调用测试接口")
public BaseResponse<Object> invokeInterfaceInfo(@RequestBody InterfaceInfoInvokeRequest invokeRequest){
// 1.校验参数
if(invokeRequest == null || invokeRequest.getId() <=0){
return ResultUtils.success(ErrorCode.PARAMS_ERROR);
}

// 2.判断接口是否存在
long id = invokeRequest.getId();
InterfaceInfo oldInterfaceInfo = interfaceInfoService.getById(id);
if(oldInterfaceInfo == null){
return ResultUtils.error(ErrorCode.NOT_FOUND_ERROR,"接口不存在");
}

// 3.判断接口状态是否开启(1是开启)
if(oldInterfaceInfo.getStatus() != 1){
return ResultUtils.error(ErrorCode.PARAMS_ERROR,"接口已关闭");
}

// 4.发起实际调用
// 这里应该根据oldInterfaceInfo.getUrl()动态去调
// 但是目前为了跑通主流程,先用if-else写死判断,只测试"/name/user"接口
String userRequestParams = invokeRequest.getUserRequestParams();
if(oldInterfaceInfo.getUrl().contains("/name/user")){
// 利用Hutool将前端传来的JSON字符串反序列化为User对象
com.accycx.apiclientsdk.model.User user = cn.hutool.json.JSONUtil.toBean(userRequestParams, com.accycx.apiclientsdk.model.User.class);

// 主后台使用装配好的SDK客户端发起真实网络请求
String result = apiClient.getUserNameByPost(user);
return ResultUtils.success(result);
}
return ResultUtils.error(ErrorCode.PARAMS_ERROR,"目前仅支持测试/name/user接口");
}

写完这段代码后,/invoke 接口就完成了,现在我们可以把interface模块的接口地址信息用之前写过的增加接口功能存入数据库里,然后再用ApiFox测试这个新添的接口。

跨服务鉴权与 RPC 调用

之前我们为了跑通流程,在interface模块的拦截器里写了:private static final String MOCK_SK = "accycx_test_sk1";这样的模拟数据,并没有从数据库里查询真实数据。

目前的问题:

  1. interface 模块收到了调用者的 accessKey
  2. 它需要查出对应的 secretKey 来验签。
  3. 但用户信息存在主库 api_platform 中,interface 作为一个模拟的第三方独立微服务,绝对不能直接连主库(如果每个微服务都能直连主库,一旦数据库崩溃,全盘皆输,这就违背了微服务架构的初衷)。

所以我们需要用到RPC(远程过程调用)

interface 模块在代码里打个“内线电话”,调用主业务 backend 模块里的方法去查数据库。这个“电话线”,我们要用到的技术选型就是 Apache Dubbo

为了打通 Dubbo,我们分为三步走,现在直接开始第一步:建立通信契约

第一步:在 Common 模块定义“内线服务接口”

在微服务中,两个服务要打电话,必须要有一个双方都认识的“电话簿”(公共接口)。这个电话簿理所当然要放在大家都能引用的 api-platform-common 模块里。

打开 api-platform-common 模块,在 src/main/java/com/accycx/common 下新建一个包 service,然后创建两个专门用于内部调用的 RPC 接口(注意:这和我们之前在 backend 里写的对外 Service 是不一样的):

1. 内部用户服务 (InnerUserService.java)

用于 interface 模块向 backend 查询用户的 AK/SK 是否合法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 内部用户服务(仅供微服务内部调用)
*/
public interface InnerUserService {

/**
* 数据库中查是否已分配给用户秘钥(根据accessKey 查找到对应的User,里面包含secretKey)
*
* @param accessKey 用户秘钥
* @return 如果找不到返回null
*/
User getInvokeUser(String accessKey);
}

2. 内部接口信息服务 (InnerInterfaceInfoService.java)

用于 interface 模块查询用户正在调用的这个接口,在数据库里是否存在?是不是开启状态?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 内部接口信息服务
*/
public interface InnerInterfaceInfoService {

/**
* 从数据库中查询接口是否存在(请求路径、请求方法、状态为开启)
*
* @param path 请求路径
* @param method 请求方法
* @return 如果找不到返回null
*/
InterfaceInfo getInterfaceinfo(String path, String method);
}

这两个接口里用到了 UserInterfaceInfo 实体类。因为现在这两个类还在 model 模块里,而 common 并没有引入 model,引用一下就好了。

契约定好了,接下来就需要把**DubboNacos(注册中心)** 引入到项目中,让 backend 把电话接起来,让 interface 把电话拨出去。

第一步:环境整备(依赖与中间件)

在写代码前,我们得先在本地启动 **Nacos**。它就像是微服务世界的“中枢站”,没有它,服务之间找不到彼此。

1.启动 Nacos 服务,默认端口 8848

下载 Nacos 2.x 并在本地启动。启动成功后,访问 http://localhost:8848/nacos,看到控制台界面就说明中间件准备好了。

2.在父工程/Common 引入依赖,统一版本管理

在主 pom.xml 中引入 Spring Cloud Alibaba 依赖。然后在 backendinterfacepom.xml 中分别加入:

  • dubbo-spring-boot-starter
  • spring-cloud-starter-alibaba-nacos-discovery

第二步:服务端(backend)接电话

我们要让 backend 模块把之前在 common 里定义的契约接口实现出来,并通过 Dubbo 广播出去。

  1. 实现内部服务类

api-platform-backendservice.impl 下新建 InnerUserServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@DubboService //核心:告诉Dubbo这是一个服务实现类,提供给其他微服务调用
public class InnerUserServiceImpl implements InnerUserService {

@Resource
private UserMapper userMapper;

@Override
public User getInvokeUser(String accessKey){
QueryWrapper<User> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("access_key", accessKey);
return userMapper.selectOne(queryWrapper);
}
}

  1. 配置 backend 的 application.yml
1
2
3
4
5
6
7
8
dubbo:
application:
name: api-platform-backend # 服务名
protocol:
name: dubbo
port: -1 # 随机可用端口
registry:
address: nacos://localhost:8848 # 注册到 Nacos

第三步:调用端(interface)拨电话

现在我们要改掉那个写死的 MOCK_SK,让拦截器去实时查数据库。

  1. **配置 interface 的 application.yml**
1
2
3
4
5
dubbo:
application:
name: api-platform-interface
registry:
address: nacos://localhost:8848
  1. 重构拦截器 ApiAuthInterceptor.java

在这里,我们用 @DubboReference把远在另一个进程的对象调过来

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
public class ApiAuthInterceptor implements HandlerInterceptor {

@DubboReference // 核心:远程引用 backend 暴露的服务
private InnerUserService innerUserService;

@Override
public boolean preHandle(HttpServletRequest request, ...) {
String accessKey = request.getHeader("accessKey");

// 1. 动态查库:不再是 MOCK,而是真的去 backend 查
User invokeUser = innerUserService.getInvokeUser(accessKey);
if (invokeUser == null) {
throw new RuntimeException("无权限:AccessKey 错误");
}

// 2. 拿到真实的 secretKey 进行验签
String secretKey = invokeUser.getSecretKey();
String serverSign = SignUtils.genSign(body, secretKey);

if (!sign.equals(serverSign)) {
throw new RuntimeException("无权限:签名校验失败");
}
return true;
}
}

最后记得在主类加上@EnableDubbo注解

1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
@EnableDubbo
public class ApiPlatformBackendApplication {

public static void main(String[] args) {
SpringApplication.run(ApiPlatformBackendApplication.class, args);
}

}

然后现在准备联调测试

1.启动 Nacos

2.启动 Backend:查看 Nacos 控制台的“服务列表”,如果出现了 api-platform-backend,说明电话接通了。

3.启动 Interface

4.运行之前的 SDK 测试用例:如果控制台依然返回成功,说明这一通“跨服务内线电话”打通了!

结果如图:

测试结果

到这里本项目的阶段三就完成了,接下来将进入阶段四:网关与中间件(高并发攻坚),然后会先搭建统一API网关(Gateway)

目前架构的痛点: 现在我们的验签拦截器 (ApiAuthInterceptor) 是写在 api-platform-interface 这个具体的接口服务里的。 假设以后平台做大了,又开发了 weather-interface(天气服务)、sms-interface(短信服务)、ai-interface(AI 问答服务),难道要把这坨又臭又长的验签拦截器,在每一个项目里都复制粘贴一遍吗?如果哪天签名算法要升级,那得改多少个项目?

所以我们需要在所有微服务的最前面,建立一个统一的“海关大楼”(网关模块)。

  1. 所有的第三方开发者(SDK)不再直接请求具体的接口,而是把请求全部打到网关
  2. 网关负责统一验签:把拦截器里的代码搬到网关里,验签通过后,网关再负责把请求**路由(转发)**到对应的真实接口服务。
  3. 保护真实服务:真实的接口服务将不再对外暴露端口,只允许网关访问。

另外还能解决接口调用次数统计的问题,因为我们做的是一个API开放平台,平台是要算计配额的。“调用次数”是这个项目的核心业务数据。网关不仅要验签,还要在转发请求成功后,让这个用户的接口剩余调用次数减 1

这就需要我们用到之前建好的 user_interface_info(用户接口关系表),并且再次用到 Dubbo 的 RPC 调用。

接下来的路线:

1.新建 网关模块 (api-platform-gateway)。

2.迁移 鉴权逻辑:把 interface 里的拦截器废弃,在网关里写一个 全局过滤器 (GlobalFilter) 来接管验证。

3.实现 次数统计:利用 Dubbo 发起 RPC 调用,在数据库里对调用次数进行 count + 1,剩余配额 leftNum - 1

4.路由 转发:网关验证无误后,把请求平滑地送到 interface