feat(open-platform): 新增开放平台独立链路及订单接口
- 新增开放平台配置块 application.yml,配置 JWKS、公钥地址、issuer、audience 及接口前缀 - 新增 OpenPlatformSecurityConfig,独立安全链路匹配 /api/open/** 请求 - JwtAuthenticationFilter 增加 shouldNotFilter 跳过 /api/open/**,避免 RS256 验签异常 - 新增 OpenTenantContext 与拦截器,令牌中 tenant_id 作为唯一租户来源,防止伪造请求头 - MybatisPlusConfig 优先使用 OpenTenantContext 里的租户 ID,确保数据隔离正确 - sys_order 表显式在 OrderMapper.xml 中使用 tenant_id 条件过滤,支持开放链路多租户隔离 - 新增开放平台订单分页查询接口 /api/open/v1/order/page,使用独立参数与返回值结构 - 出参 OpenOrderVO 支持手机号脱敏,保障数据安全,且只暴露必要字段 - 调整 SecurityConfig 加 @Order(2),让位给开放链路的 SecurityConfig @Order(1) - TenantController.page 增加安全修复,未登录状态拒绝访问,防止匿名读取全部租户数据 - 新增开放平台专用异常处理及认证、权限拒绝响应,避免泄露内部异常信息 - 新增 pom 依赖 spring-boot-starter-oauth2-resource-server,用于 RS256 JWT 验签 - 完整开放平台对接文档 docs/OPEN_PLATFORM_INTEGRATION.md,涵盖设计理念与接口契约 - 增加 OpenOrderVO 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性
This commit is contained in:
@@ -0,0 +1,209 @@
|
|||||||
|
# 开放平台对接说明(server-api / gxwebsoft_core)
|
||||||
|
|
||||||
|
> 记录日期:2026-09-22 | 分支:`codex/open-platform-integration`
|
||||||
|
> 上游控制面:`base-api`(开放平台,负责应用、凭证、权限与令牌签发)
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
开放平台(`base-api`)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
|
||||||
|
`base-api` 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
|
||||||
|
|
||||||
|
1. 用 JWKS 公钥验签,校验 `iss` / `aud` / `exp`;
|
||||||
|
2. 校验令牌的 `scope` 是否包含接口所需权限;
|
||||||
|
3. 从令牌的 `tenant_id` 取租户,作为数据隔离条件。
|
||||||
|
|
||||||
|
本服务(`server.websoft.top`)在此之上新增了一条独立的**开放接口链路**,
|
||||||
|
对内控制台链路零影响。
|
||||||
|
|
||||||
|
## 2. 关键设计
|
||||||
|
|
||||||
|
### 2.1 两条安全链互不干扰
|
||||||
|
|
||||||
|
本服务原有 `SecurityConfig` 有两个特点,都不能被开放接口继承:
|
||||||
|
|
||||||
|
| 原有行为 | 位置 | 对开放接口的风险 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `antMatchers(GET, "/**").permitAll()`,即所有 GET 在框架层放行 | `SecurityConfig` | 开放接口挂上去默认就是公开的 |
|
||||||
|
| 内部令牌是 HS256 对称密钥,由全局 `JwtAuthenticationFilter` 解析 | `JwtAuthenticationFilter` | 用内部密钥解析 RS256 令牌会抛 `UnsupportedJwtException` |
|
||||||
|
|
||||||
|
因此新增 `OpenPlatformSecurityConfig`(`@Order(1)`),只匹配 `/api/open/**`,
|
||||||
|
`anyRequest().authenticated()` 且无任何 `permitAll`;原 `SecurityConfig` 降为 `@Order(2)`。
|
||||||
|
`JwtAuthenticationFilter` 增加 `shouldNotFilter` 跳过 `/api/open/**`
|
||||||
|
(该过滤器是 `@Component`,会被 Spring Boot 额外注册为普通 Servlet Filter,
|
||||||
|
仅靠安全链隔离并不够)。
|
||||||
|
|
||||||
|
### 2.2 租户只来自令牌
|
||||||
|
|
||||||
|
原有租户取值顺序是「请求头 `tenantId` → 请求头 `Domain` → 登录用户」
|
||||||
|
(见 `MybatisPlusConfig` 与 `BaseController.getTenantId`),
|
||||||
|
意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:
|
||||||
|
|
||||||
|
`OpenTenantInterceptor` 在请求进入 controller 前,从已验签的 JWT 取 `tenant_id`
|
||||||
|
写入 `OpenTenantContext`;`MybatisPlusConfig.getTenantId()` 以它作为**最高优先级**,
|
||||||
|
不再读取请求头。令牌没有 `tenant_id` 时直接返回 403,不回退。
|
||||||
|
|
||||||
|
### 2.3 订单表需要显式租户条件
|
||||||
|
|
||||||
|
`sys_order` 在 `MybatisPlusConfig` 的 `ignoreTable` 白名单里,**不受多租户插件管辖**,
|
||||||
|
内部接口是靠「非平台租户强制 `userId = 登录用户`」实现隔离的(按人而非按租户)。
|
||||||
|
因此 `OrderMapper.xml` 补了一条显式条件:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
|
```
|
||||||
|
|
||||||
|
`PageParam` 会跳过名为 `tenantId` 的字段,该参数此前无人设置,所以对既有调用无影响。
|
||||||
|
|
||||||
|
## 3. 代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
com.gxwebsoft.openplatform
|
||||||
|
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
|
||||||
|
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
|
||||||
|
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
|
||||||
|
├─ config/OpenPlatformWebMvcConfig.java 给 /api/open/** 挂租户拦截器
|
||||||
|
├─ context/OpenCaller.java 调用方身份(全部来自令牌)
|
||||||
|
├─ context/OpenTenantContext.java 开放链路租户 ThreadLocal
|
||||||
|
├─ web/OpenTenantInterceptor.java 绑定与清理租户上下文
|
||||||
|
├─ web/OpenPlatformAuthenticationEntryPoint.java 未认证响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
|
||||||
|
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
|
||||||
|
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
|
||||||
|
├─ constant/OpenScopes.java scope 常量
|
||||||
|
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
|
||||||
|
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
|
||||||
|
└─ controller/OpenOrderController.java /api/open/v1/order/page
|
||||||
|
```
|
||||||
|
|
||||||
|
既有文件改动:
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
| --- | --- |
|
||||||
|
| `pom.xml` | 新增 `spring-boot-starter-oauth2-resource-server` |
|
||||||
|
| `SecurityConfig` | 加 `@Order(2)` 让位给开放链 |
|
||||||
|
| `JwtAuthenticationFilter` | 加 `shouldNotFilter`,跳过 `/api/open/**` |
|
||||||
|
| `MybatisPlusConfig` | 租户取值优先读 `OpenTenantContext` |
|
||||||
|
| `OrderMapper.xml` | 补 `tenant_id` 查询条件 |
|
||||||
|
| `TenantController.page` | **安全修复**:未登录直接拒绝,见第 6 节 |
|
||||||
|
| `application.yml` | 新增 `open-platform.*` 配置块 |
|
||||||
|
|
||||||
|
## 4. 配置项
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
open-platform:
|
||||||
|
enabled: true
|
||||||
|
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||||||
|
issuer: https://base-api.websoft.top/api
|
||||||
|
audience: websoft-open-platform
|
||||||
|
path-prefix: /api/open
|
||||||
|
mask-sensitive: true
|
||||||
|
```
|
||||||
|
|
||||||
|
> **不要改用 `spring.security.oauth2.resourceserver.jwt.issuer-uri`**。
|
||||||
|
> 配置该项后 Spring Security 会在启动时请求 `{issuer}/.well-known/openid-configuration`
|
||||||
|
> 做 OIDC 发现,而 `base-api` 未提供该文档(实测返回 `{"code":401,"message":"请先登录"}`),
|
||||||
|
> 会导致启动直接失败。这里只配 JWKS 地址,`iss` / `aud` 用显式校验器声明。
|
||||||
|
|
||||||
|
本地联调时可临时指向自建 JWKS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -Dspring-boot.run.arguments="\
|
||||||
|
--open-platform.jwk-set-uri=http://127.0.0.1:18999/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 接口契约
|
||||||
|
|
||||||
|
`GET /api/open/v1/order/page`
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 认证 | `Authorization: Bearer <base-api accessToken>` |
|
||||||
|
| 权限 | scope 含 `order:read`,否则 403 |
|
||||||
|
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
|
||||||
|
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100) |
|
||||||
|
| 过滤 | `orderNo`(模糊)、`type`、`orderStatus`、`payStatus`、`payType`、`createTimeStart`、`createTimeEnd` |
|
||||||
|
|
||||||
|
响应沿用平台约定(**HTTP 状态码固定 200**,业务结果看 `code`;不返回 `error` 字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "操作成功",
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"orderId": 1,
|
||||||
|
"orderNo": "1856321928276439040",
|
||||||
|
"orderStatus": 1,
|
||||||
|
"payStatus": true,
|
||||||
|
"payPrice": 100.00,
|
||||||
|
"phone": "138****0316",
|
||||||
|
"createTime": "2026-09-01 10:00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 6,
|
||||||
|
"page": 1,
|
||||||
|
"limit": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
典型错误:
|
||||||
|
|
||||||
|
| code | message | 触发条件 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 401 | 令牌缺失或无效 | 未带令牌、签名错误、已过期、`iss`/`aud` 不匹配 |
|
||||||
|
| 403 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `order:read` |
|
||||||
|
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
|
||||||
|
|
||||||
|
## 6. 随本次改动修复的既有问题
|
||||||
|
|
||||||
|
`TenantController.page`(`GET /api/system/tenant/page`)原先没有 `@PreAuthorize`,
|
||||||
|
而 `SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
|
||||||
|
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
|
||||||
|
未登录返回 `{"code":401,"message":"请先登录"}`。
|
||||||
|
|
||||||
|
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
|
||||||
|
不依赖该接口,因此不影响登录。
|
||||||
|
|
||||||
|
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
|
||||||
|
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
|
||||||
|
> 影响面较大,建议单独排期。
|
||||||
|
|
||||||
|
## 7. 如何新增一个开放接口
|
||||||
|
|
||||||
|
1. 在 `OpenScopes` 增加 scope 常量,并确保 `base-api` 的权限字典里有同名 scope;
|
||||||
|
2. 在 `controller` 下新建对外 controller,路径以 `/api/open/v1/` 开头;
|
||||||
|
3. 方法上加 `@PreAuthorize("hasAuthority('SCOPE_xxx')")`;
|
||||||
|
4. 入参使用**独立的白名单 DTO**,不要直接暴露内部 `XxxParam`;
|
||||||
|
5. 需要租户时用 `OpenTenantContext.getTenantId()`,不要从参数或请求头取;
|
||||||
|
6. 出参使用独立的 VO,不要直接返回实体;
|
||||||
|
7. 若目标表在 `MybatisPlusConfig` 的 `ignoreTable` 名单里(如 `sys_order`),
|
||||||
|
必须在 Mapper 里显式加租户条件。
|
||||||
|
|
||||||
|
## 8. 验证方式
|
||||||
|
|
||||||
|
单元测试(不依赖 base-api 在线):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
|
||||||
|
```
|
||||||
|
|
||||||
|
覆盖:`iss`/`aud`/`exp` 三条校验规则、租户只来自令牌(伪造 `tenantId` 请求头无效)、
|
||||||
|
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
|
||||||
|
|
||||||
|
端到端(本地自建 JWKS,无需 base-api 凭证):
|
||||||
|
|
||||||
|
签发 `tenant_id` 分别为 A / B / 不存在的租户三枚令牌调用接口,
|
||||||
|
预期返回条数与数据库 `SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?`
|
||||||
|
完全一致,且伪造 `tenantId` 请求头不改变结果。
|
||||||
|
|
||||||
|
## 9. 待办
|
||||||
|
|
||||||
|
- [ ] `base-api` 的权限字典补充 `order:read` 等业务 scope,并在应用管理页可勾选;
|
||||||
|
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
|
||||||
|
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
|
||||||
|
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
|
||||||
|
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。
|
||||||
@@ -203,6 +203,12 @@
|
|||||||
<artifactId>spring-boot-starter-security</artifactId>
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- 开放平台资源服务:校验 base-api 签发的 RS256 令牌(JWKS 取公钥,不共享密钥) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- jjwt - 升级到安全版本 -->
|
<!-- jjwt - 升级到安全版本 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.jsonwebtoken</groupId>
|
<groupId>io.jsonwebtoken</groupId>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
|||||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||||
import com.gxwebsoft.common.core.utils.RedisUtil;
|
import com.gxwebsoft.common.core.utils.RedisUtil;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenTenantContext;
|
||||||
import com.gxwebsoft.common.system.entity.User;
|
import com.gxwebsoft.common.system.entity.User;
|
||||||
import net.sf.jsqlparser.expression.Expression;
|
import net.sf.jsqlparser.expression.Expression;
|
||||||
import net.sf.jsqlparser.expression.LongValue;
|
import net.sf.jsqlparser.expression.LongValue;
|
||||||
@@ -40,6 +41,12 @@ public class MybatisPlusConfig {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Expression getTenantId() {
|
public Expression getTenantId() {
|
||||||
|
// 0 开放平台链路:租户只来自令牌,优先级最高
|
||||||
|
// 必须放在最前面,否则第三方可以伪造 tenantId 请求头越过数据隔离
|
||||||
|
Integer openTenantId = OpenTenantContext.getTenantId();
|
||||||
|
if (openTenantId != null) {
|
||||||
|
return new LongValue(openTenantId);
|
||||||
|
}
|
||||||
String tenantId;
|
String tenantId;
|
||||||
// 从请求头拿ID
|
// 从请求头拿ID
|
||||||
tenantId = request.getHeader("tenantId");
|
tenantId = request.getHeader("tenantId");
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
@Resource
|
@Resource
|
||||||
private LoginRecordService loginRecordService;
|
private LoginRecordService loginRecordService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台接口走独立的安全链与 RS256 验签,不能被本过滤器用内部 HS256 密钥解析。
|
||||||
|
*
|
||||||
|
* <p>否则请求会在这里抛出
|
||||||
|
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures}
|
||||||
|
* 并被包装成「请退出重新登录」。</p>
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
String uri = request.getRequestURI();
|
||||||
|
return uri != null && uri.startsWith("/api/open/");
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
|
||||||
throws ServletException, IOException {
|
throws ServletException, IOException {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.core.security;
|
|||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
import org.springframework.http.HttpMethod;
|
import org.springframework.http.HttpMethod;
|
||||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
@@ -20,6 +21,7 @@ import javax.annotation.Resource;
|
|||||||
* @since 2020-03-23 18:04:52
|
* @since 2020-03-23 18:04:52
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@Order(2)
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||||
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
public class SecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.gxwebsoft.common.system.controller;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
import com.gxwebsoft.common.core.config.ConfigProperties;
|
import com.gxwebsoft.common.core.config.ConfigProperties;
|
||||||
import com.gxwebsoft.common.core.exception.BusinessException;
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
import com.gxwebsoft.common.core.utils.CommonUtil;
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
@@ -58,13 +59,17 @@ public class TenantController extends BaseController {
|
|||||||
@Operation(summary = "分页查询租户")
|
@Operation(summary = "分页查询租户")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
public ApiResult<PageResult<Tenant>> page(TenantParam param) {
|
||||||
|
// 安全修复:SecurityConfig 对所有 GET 放行,本方法原先在未登录且未指定 userId 时
|
||||||
|
// 会走「无过滤」分支返回全部租户(含租户名称、编码、手机号)。这里补上登录校验。
|
||||||
|
// 登录页的多租户选择走登录响应里的 tenants 列表,不依赖本接口,因此不影响登录流程。
|
||||||
|
final User loginUser = getLoginUser();
|
||||||
|
if (loginUser == null) {
|
||||||
|
return new ApiResult<>(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
|
||||||
|
}
|
||||||
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
|
// 如果传了 all=true,查询全部租户;否则自动用当前登录用户的 userId
|
||||||
if (param.getAll() == null || !param.getAll()) {
|
if (param.getAll() == null || !param.getAll()) {
|
||||||
if (param.getUserId() == null) {
|
if (param.getUserId() == null && loginUser.getUserId() != null) {
|
||||||
final User loginUser = getLoginUser();
|
param.setUserId(loginUser.getUserId());
|
||||||
if (loginUser != null && loginUser.getUserId() != null) {
|
|
||||||
param.setUserId(loginUser.getUserId());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PageResult<Tenant> result = tenantService.pageRel(param);
|
PageResult<Tenant> result = tenantService.pageRel(param);
|
||||||
|
|||||||
@@ -8,6 +8,12 @@
|
|||||||
FROM sys_order a
|
FROM sys_order a
|
||||||
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
LEFT JOIN sys_company b ON a.tenant_id = b.tenant_id
|
||||||
<where>
|
<where>
|
||||||
|
<!-- 租户条件:sys_order 在 MybatisPlusConfig 的 ignoreTable 白名单里,
|
||||||
|
不受多租户插件管辖,必须在这里显式过滤。目前只有开放平台链路会设置该参数,
|
||||||
|
内部调用不传即保持原有行为。 -->
|
||||||
|
<if test="param.tenantId != null">
|
||||||
|
AND a.tenant_id = #{param.tenantId}
|
||||||
|
</if>
|
||||||
<if test="param.orderId != null">
|
<if test="param.orderId != null">
|
||||||
AND a.order_id = #{param.orderId}
|
AND a.order_id = #{param.orderId}
|
||||||
</if>
|
</if>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.gxwebsoft.openplatform.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2Error;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
|
||||||
|
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtValidators;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台令牌校验配置。
|
||||||
|
*
|
||||||
|
* <p><b>不要使用 {@code spring.security.oauth2.resourceserver.jwt.issuer-uri}</b>:配置该项后
|
||||||
|
* Spring Security 会在启动时请求 {@code {issuer}/.well-known/openid-configuration} 做 OIDC 发现,
|
||||||
|
* 而 base-api 并未提供该文档(会返回「请先登录」),启动会直接失败。这里只配置 JWKS 地址,
|
||||||
|
* issuer 与 audience 用显式校验器声明。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class OpenPlatformJwtConfig {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtDecoder openPlatformJwtDecoder() {
|
||||||
|
// 只配置 JWKS:公钥懒加载,启动时不依赖 base-api 可达
|
||||||
|
NimbusJwtDecoder decoder = NimbusJwtDecoder
|
||||||
|
.withJwkSetUri(properties.getJwkSetUri())
|
||||||
|
.build();
|
||||||
|
decoder.setJwtValidator(buildTokenValidator());
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造令牌校验器:iss / exp / nbf 由 Spring Security 负责,aud 需要显式声明。
|
||||||
|
*
|
||||||
|
* <p>公开出来是为了让单元测试可以直接用本地生成的 RSA 密钥验证校验规则,
|
||||||
|
* 不必依赖 base-api 在线。</p>
|
||||||
|
*/
|
||||||
|
public OAuth2TokenValidator<Jwt> buildTokenValidator() {
|
||||||
|
return new DelegatingOAuth2TokenValidator<>(
|
||||||
|
JwtValidators.createDefaultWithIssuer(properties.getIssuer()),
|
||||||
|
audienceValidator());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* aud 必须命中配置的受众,避免拿到其它系统的令牌来调业务接口。
|
||||||
|
*/
|
||||||
|
private OAuth2TokenValidator<Jwt> audienceValidator() {
|
||||||
|
return jwt -> {
|
||||||
|
if (jwt.getAudience() != null && jwt.getAudience().contains(properties.getAudience())) {
|
||||||
|
return OAuth2TokenValidatorResult.success();
|
||||||
|
}
|
||||||
|
return OAuth2TokenValidatorResult.failure(
|
||||||
|
new OAuth2Error("invalid_token", "令牌受众不正确", null));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.gxwebsoft.openplatform.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台(base-api)对接配置。
|
||||||
|
*
|
||||||
|
* <p>业务服务不共享密钥,只通过 JWKS 拉取公钥本地验签,因此这里只有地址与校验参数。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "open-platform")
|
||||||
|
public class OpenPlatformProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否启用开放接口链路。关闭后 /api/open/** 不再放行(会退回 internal 鉴权链路)
|
||||||
|
*/
|
||||||
|
private boolean enabled = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base-api 的 JWKS 地址,资源服务启动时拉取并缓存公钥
|
||||||
|
*/
|
||||||
|
private String jwkSetUri = "https://base-api.websoft.top/api/v1/oauth/jwks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌签发方,必须与 base-api 的 iss 声明完全一致
|
||||||
|
*/
|
||||||
|
private String issuer = "https://base-api.websoft.top/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌受众,必须与 base-api 的 aud 声明一致
|
||||||
|
*/
|
||||||
|
private String audience = "websoft-open-platform";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对外接口路径前缀,默认与 base-api 的约定保持一致
|
||||||
|
*/
|
||||||
|
private String pathPrefix = "/api/open";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否对手机号等敏感字段脱敏后再返回给第三方,默认开启
|
||||||
|
*/
|
||||||
|
private boolean maskSensitive = true;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package com.gxwebsoft.openplatform.config;
|
||||||
|
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenPlatformAccessDeniedHandler;
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenPlatformAuthenticationEntryPoint;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台接口的独立安全链。
|
||||||
|
*
|
||||||
|
* <p>必须与内部控制台的安全链分开,原因有两点:</p>
|
||||||
|
* <ol>
|
||||||
|
* <li>内部控制台用 HS256 的对称密钥,开放平台用 RS256 的非对称密钥,
|
||||||
|
* 同一条链上会互相干扰(表现为
|
||||||
|
* {@code UnsupportedJwtException: ... may not be used to verify RS256 signatures});</li>
|
||||||
|
* <li>控制台的 {@code SecurityConfig} 为了兼容前台站点,把「所有 GET」放行了,
|
||||||
|
* 对外开放接口绝不能继承这个默认放行。</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>本链排在前面(@Order(1)),只匹配 {@code /api/open/**},其余请求仍由原有链路处理。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Order(1)
|
||||||
|
@EnableWebSecurity
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class OpenPlatformSecurityConfig extends WebSecurityConfigurerAdapter {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private JwtDecoder openPlatformJwtDecoder;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformAccessDeniedHandler openPlatformAccessDeniedHandler;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformAuthenticationEntryPoint openPlatformAuthenticationEntryPoint;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void configure(HttpSecurity http) throws Exception {
|
||||||
|
http.requestMatcher(new AntPathRequestMatcher(properties.getPathPrefix() + "/**"))
|
||||||
|
.authorizeRequests()
|
||||||
|
// 开放接口一律需要令牌,没有任何免登录路径
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
.and()
|
||||||
|
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
|
.and()
|
||||||
|
.csrf().disable()
|
||||||
|
.cors()
|
||||||
|
.and()
|
||||||
|
.logout().disable()
|
||||||
|
.headers().frameOptions().disable()
|
||||||
|
.and()
|
||||||
|
.exceptionHandling()
|
||||||
|
.accessDeniedHandler(openPlatformAccessDeniedHandler)
|
||||||
|
.authenticationEntryPoint(openPlatformAuthenticationEntryPoint)
|
||||||
|
.and()
|
||||||
|
.oauth2ResourceServer()
|
||||||
|
// 令牌解析失败(签名错误、已过期、格式不对)由 BearerTokenAuthenticationFilter
|
||||||
|
// 直接短路,不会经过上面的 exceptionHandling,必须在资源服务器上再配一次,
|
||||||
|
// 否则调用方收到的是「HTTP 401 + 空 body」,与平台约定的 200 + code 不一致。
|
||||||
|
.authenticationEntryPoint(openPlatformAuthenticationEntryPoint)
|
||||||
|
.accessDeniedHandler(openPlatformAccessDeniedHandler)
|
||||||
|
.jwt().decoder(openPlatformJwtDecoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.openplatform.config;
|
||||||
|
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenTenantInterceptor;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的 WebMvc 配置:只给 /api/open/** 挂租户绑定拦截器。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
public class OpenPlatformWebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
@Resource
|
||||||
|
private OpenTenantInterceptor openTenantInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(openTenantInterceptor)
|
||||||
|
.addPathPatterns(properties.getPathPrefix() + "/**");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.gxwebsoft.openplatform.constant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台权限标识(scope)。
|
||||||
|
*
|
||||||
|
* <p>与 base-api 的权限字典、以及在管理后台勾选的权限一一对应。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
public final class OpenScopes {
|
||||||
|
|
||||||
|
/** 查询订单 */
|
||||||
|
public static final String ORDER_READ = "order:read";
|
||||||
|
|
||||||
|
private OpenScopes() {
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.gxwebsoft.openplatform.context;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的调用方身份,全部来自 base-api 签发的 access token。
|
||||||
|
*
|
||||||
|
* <p>租户、用户、权限一律以令牌为准,不接受任何请求参数或请求头覆盖。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class OpenCaller implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 应用标识(client_id) */
|
||||||
|
private String clientId;
|
||||||
|
|
||||||
|
/** 应用所属租户,数据隔离的唯一依据 */
|
||||||
|
private Integer tenantId;
|
||||||
|
|
||||||
|
/** 终端用户 id,仅授权码模式携带 */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 终端用户账号,仅授权码模式携带 */
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/** 授权模式:client_credentials / authorization_code */
|
||||||
|
private String grantType;
|
||||||
|
|
||||||
|
/** 已授予的权限 */
|
||||||
|
private List<String> scopes = Collections.emptyList();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Spring Security 的认证对象解析调用方;非开放平台令牌返回 null。
|
||||||
|
*/
|
||||||
|
public static OpenCaller from(Authentication authentication) {
|
||||||
|
if (authentication == null || !(authentication.getPrincipal() instanceof Jwt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Jwt jwt = (Jwt) authentication.getPrincipal();
|
||||||
|
OpenCaller caller = new OpenCaller();
|
||||||
|
caller.setClientId(jwt.getClaimAsString("client_id"));
|
||||||
|
caller.setTenantId(toInteger(jwt.getClaim("tenant_id")));
|
||||||
|
caller.setUserId(toLong(jwt.getClaim("user_id")));
|
||||||
|
caller.setUsername(jwt.getClaimAsString("username"));
|
||||||
|
caller.setGrantType(jwt.getClaimAsString("grant_type"));
|
||||||
|
caller.setScopes(parseScopes(jwt.getClaimAsString("scope")));
|
||||||
|
return caller;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 令牌的 scope 是空格分隔的字符串
|
||||||
|
*/
|
||||||
|
private static List<String> parseScopes(String scope) {
|
||||||
|
if (scope == null || scope.trim().isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<String> scopes = new ArrayList<>();
|
||||||
|
for (String item : scope.trim().split("\\s+")) {
|
||||||
|
if (!item.isEmpty()) {
|
||||||
|
scopes.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Integer toInteger(Object value) {
|
||||||
|
return value instanceof Number ? ((Number) value).intValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Long toLong(Object value) {
|
||||||
|
return value instanceof Number ? ((Number) value).longValue() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.gxwebsoft.openplatform.context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口链路的租户上下文。
|
||||||
|
*
|
||||||
|
* <p>与既有的「请求头 tenantId → Domain → 登录用户」取值顺序不同:开放链路只认令牌里的租户,
|
||||||
|
* 由 {@code OpenTenantInterceptor} 在请求进入 controller 之前写入、请求结束后清理。
|
||||||
|
* {@code MybatisPlusConfig} 会优先读取这里,从而彻底屏蔽第三方通过请求头指定租户的可能。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
public final class OpenTenantContext {
|
||||||
|
|
||||||
|
private static final ThreadLocal<OpenCaller> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private OpenTenantContext() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void set(OpenCaller caller) {
|
||||||
|
HOLDER.set(caller);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static OpenCaller get() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前开放调用的租户;不在开放链路中时为 null
|
||||||
|
*/
|
||||||
|
public static Integer getTenantId() {
|
||||||
|
OpenCaller caller = HOLDER.get();
|
||||||
|
return caller == null ? null : caller.getTenantId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.gxwebsoft.openplatform.controller;
|
||||||
|
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import com.gxwebsoft.common.core.web.PageResult;
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.common.system.param.OrderParam;
|
||||||
|
import com.gxwebsoft.common.system.service.OrderService;
|
||||||
|
import com.gxwebsoft.openplatform.config.OpenPlatformProperties;
|
||||||
|
import com.gxwebsoft.openplatform.constant.OpenScopes;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.param.OpenOrderPageParam;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenPageResult;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.annotation.Resource;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台 · 订单接口。
|
||||||
|
*
|
||||||
|
* <p>路径带版本号 {@code /api/open/v1/}:对外接口是长期契约,发布后只增不改。</p>
|
||||||
|
*
|
||||||
|
* <p>鉴权分两层:框架层由 {@code OpenPlatformSecurityConfig} 校验 RS256 令牌的签名与 iss/aud/exp;
|
||||||
|
* 权限层由 {@code @PreAuthorize} 校验 scope。租户由 {@code OpenTenantInterceptor} 从令牌落到上下文。</p>
|
||||||
|
*
|
||||||
|
* <p>随 {@code open-platform.enabled} 一起装配:开关关闭时接口整体下线,
|
||||||
|
* 而不是退化到内部控制台的安全链(那条链对所有 GET 放行)。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Tag(name = "开放接口-订单")
|
||||||
|
@RestController
|
||||||
|
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
|
||||||
|
@RequestMapping("/api/open/v1/order")
|
||||||
|
public class OpenOrderController {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private OrderService orderService;
|
||||||
|
@Resource
|
||||||
|
private OpenPlatformProperties properties;
|
||||||
|
|
||||||
|
@PreAuthorize("hasAuthority('SCOPE_" + OpenScopes.ORDER_READ + "')")
|
||||||
|
@Operation(summary = "分页查询本租户订单")
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ApiResult<OpenPageResult<OpenOrderVO>> page(@AuthenticationPrincipal Jwt jwt,
|
||||||
|
OpenOrderPageParam query) {
|
||||||
|
// 租户只认令牌:拦截器已校验过 tenant_id 存在,这里不会拿到请求头里的值
|
||||||
|
Integer tenantId = OpenTenantContext.getTenantId();
|
||||||
|
|
||||||
|
OrderParam param = new OrderParam();
|
||||||
|
param.setPage(normalizePage(query.getPage()));
|
||||||
|
param.setLimit(normalizeLimit(query.getLimit()));
|
||||||
|
param.setTenantId(tenantId);
|
||||||
|
param.setOrderNo(StrUtil.trimToNull(query.getOrderNo()));
|
||||||
|
param.setType(query.getType());
|
||||||
|
param.setOrderStatus(query.getOrderStatus());
|
||||||
|
param.setPayStatus(query.getPayStatus());
|
||||||
|
param.setPayType(query.getPayType());
|
||||||
|
param.setCreateTimeStart(StrUtil.trimToNull(query.getCreateTimeStart()));
|
||||||
|
param.setCreateTimeEnd(StrUtil.trimToNull(query.getCreateTimeEnd()));
|
||||||
|
|
||||||
|
PageResult<Order> result = orderService.pageRel(param);
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(result.getList(), properties.isMaskSensitive());
|
||||||
|
return new ApiResult<>(Constants.RESULT_OK_CODE, Constants.RESULT_OK_MSG,
|
||||||
|
new OpenPageResult<>(list, result.getCount(), param.getPage(), param.getLimit()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizePage(Long page) {
|
||||||
|
return (page == null || page < 1L) ? 1L : page;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Long normalizeLimit(Long limit) {
|
||||||
|
if (limit == null || limit < 1L) {
|
||||||
|
return 20L;
|
||||||
|
}
|
||||||
|
return Math.min(limit, OpenOrderPageParam.MAX_LIMIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.gxwebsoft.openplatform.param;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口订单分页查询入参。
|
||||||
|
*
|
||||||
|
* <p>刻意不复用内部的 {@code OrderParam}:后者带着 {@code userId}、{@code keywords}、{@code deleted}
|
||||||
|
* 等可被构造的字段,直接暴露给第三方等于把内部查询能力整体开放。这里只保留白名单字段,
|
||||||
|
* 且<b>不提供 tenantId</b>——租户只来自令牌。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrderPageParam", description = "开放接口订单分页查询参数")
|
||||||
|
public class OpenOrderPageParam implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/** 每页最大条数,防止第三方一次拉全量 */
|
||||||
|
public static final long MAX_LIMIT = 100L;
|
||||||
|
|
||||||
|
@Schema(description = "页码,从 1 开始", example = "1")
|
||||||
|
private Long page = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "每页数量,最大 100", example = "20")
|
||||||
|
private Long limit = 20L;
|
||||||
|
|
||||||
|
@Schema(description = "订单编号,模糊匹配")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态,0未完成 1已完成 2已取消 ...")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间起始,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeStart;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间结束,闭区间,格式 yyyy-MM-dd HH:mm:ss")
|
||||||
|
private String createTimeEnd;
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.gxwebsoft.openplatform.vo;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的订单出参。
|
||||||
|
*
|
||||||
|
* <p>只暴露第三方确实需要的字段,避免把内部实体(含 adminUrl、menuParam、deleted 等)整体吐出去。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenOrder", description = "开放接口订单")
|
||||||
|
public class OpenOrderVO implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "订单id")
|
||||||
|
private Integer orderId;
|
||||||
|
|
||||||
|
@Schema(description = "订单编号")
|
||||||
|
private String orderNo;
|
||||||
|
|
||||||
|
@Schema(description = "订单类型,0产品 1插件")
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
@Schema(description = "下单渠道,0网站 1小程序 2其他")
|
||||||
|
private Integer channel;
|
||||||
|
|
||||||
|
@Schema(description = "订单总额")
|
||||||
|
private BigDecimal totalPrice;
|
||||||
|
|
||||||
|
@Schema(description = "优惠金额")
|
||||||
|
private BigDecimal reducePrice;
|
||||||
|
|
||||||
|
@Schema(description = "实际付款金额")
|
||||||
|
private BigDecimal payPrice;
|
||||||
|
|
||||||
|
@Schema(description = "退款金额")
|
||||||
|
private BigDecimal refundMoney;
|
||||||
|
|
||||||
|
@Schema(description = "购买数量")
|
||||||
|
private Integer totalNum;
|
||||||
|
|
||||||
|
@Schema(description = "支付方式")
|
||||||
|
private Integer payType;
|
||||||
|
|
||||||
|
@Schema(description = "是否已付款")
|
||||||
|
private Boolean payStatus;
|
||||||
|
|
||||||
|
@Schema(description = "订单状态")
|
||||||
|
private Integer orderStatus;
|
||||||
|
|
||||||
|
@Schema(description = "第三方支付订单号")
|
||||||
|
private String transactionId;
|
||||||
|
|
||||||
|
@Schema(description = "下单人姓名")
|
||||||
|
private String realName;
|
||||||
|
|
||||||
|
@Schema(description = "下单人手机号")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "备注")
|
||||||
|
private String comments;
|
||||||
|
|
||||||
|
@Schema(description = "支付时间")
|
||||||
|
private Date payTime;
|
||||||
|
|
||||||
|
@Schema(description = "退款时间")
|
||||||
|
private Date refundTime;
|
||||||
|
|
||||||
|
@Schema(description = "下单时间")
|
||||||
|
private Date createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实体转出参。
|
||||||
|
*
|
||||||
|
* @param orders 订单列表
|
||||||
|
* @param maskSensitive 是否对手机号脱敏(默认开启,避免把终端用户信息直接交给第三方)
|
||||||
|
*/
|
||||||
|
public static List<OpenOrderVO> from(List<Order> orders, boolean maskSensitive) {
|
||||||
|
if (orders == null || orders.isEmpty()) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
List<OpenOrderVO> list = new ArrayList<>(orders.size());
|
||||||
|
for (Order order : orders) {
|
||||||
|
OpenOrderVO vo = new OpenOrderVO();
|
||||||
|
vo.setOrderId(order.getOrderId());
|
||||||
|
vo.setOrderNo(order.getOrderNo());
|
||||||
|
vo.setType(order.getType());
|
||||||
|
vo.setChannel(order.getChannel());
|
||||||
|
vo.setTotalPrice(order.getTotalPrice());
|
||||||
|
vo.setReducePrice(order.getReducePrice());
|
||||||
|
vo.setPayPrice(order.getPayPrice());
|
||||||
|
vo.setRefundMoney(order.getRefundMoney());
|
||||||
|
vo.setTotalNum(order.getTotalNum());
|
||||||
|
vo.setPayType(order.getPayType());
|
||||||
|
vo.setPayStatus(order.getPayStatus());
|
||||||
|
vo.setOrderStatus(order.getOrderStatus());
|
||||||
|
vo.setTransactionId(order.getTransactionId());
|
||||||
|
vo.setRealName(order.getRealName());
|
||||||
|
vo.setPhone(maskSensitive ? maskPhone(order.getPhone()) : order.getPhone());
|
||||||
|
vo.setComments(order.getComments());
|
||||||
|
vo.setPayTime(order.getPayTime());
|
||||||
|
vo.setRefundTime(order.getRefundTime());
|
||||||
|
vo.setCreateTime(order.getCreateTime());
|
||||||
|
list.add(vo);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskPhone(String phone) {
|
||||||
|
if (phone == null || phone.length() < 7) {
|
||||||
|
return phone;
|
||||||
|
}
|
||||||
|
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.gxwebsoft.openplatform.web;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的分页返回结构。
|
||||||
|
*
|
||||||
|
* <p>刻意不复用内部的 {@code PageResult}(字段是 {@code count}):对外接口是长期契约,
|
||||||
|
* 用独立结构可以避免以后内部字段调整波及第三方。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(name = "OpenPageResult", description = "开放接口分页结果")
|
||||||
|
public class OpenPageResult<T> implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@Schema(description = "当前页数据")
|
||||||
|
private List<T> list = Collections.emptyList();
|
||||||
|
|
||||||
|
@Schema(description = "总记录数")
|
||||||
|
private Long total;
|
||||||
|
|
||||||
|
@Schema(description = "当前页码,从 1 开始")
|
||||||
|
private Long page;
|
||||||
|
|
||||||
|
@Schema(description = "每页数量")
|
||||||
|
private Long limit;
|
||||||
|
|
||||||
|
public OpenPageResult() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public OpenPageResult(List<T> list, Long total, Long page, Long limit) {
|
||||||
|
this.list = list == null ? Collections.emptyList() : list;
|
||||||
|
this.total = total;
|
||||||
|
this.page = page;
|
||||||
|
this.limit = limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.gxwebsoft.openplatform.web;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.web.access.AccessDeniedHandler;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口权限不足响应:不返回 {@code error} 字段,避免泄露内部信息。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OpenPlatformAccessDeniedHandler implements AccessDeniedHandler {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e)
|
||||||
|
throws IOException {
|
||||||
|
CommonUtil.responseError(response, Constants.UNAUTHORIZED_CODE, "权限不足,请确认应用已获得该接口权限", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.gxwebsoft.openplatform.web;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.utils.CommonUtil;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.security.web.AuthenticationEntryPoint;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口未认证响应。
|
||||||
|
*
|
||||||
|
* <p>与内部链路共用 {@code JwtAuthenticationEntryPoint} 的差别只有一个:不返回 {@code error} 字段。
|
||||||
|
* 该字段承载的是异常 {@code toString()},会把内部类名与解析细节暴露给第三方。HTTP 状态码保持 200,
|
||||||
|
* 与 base-api 一致,调用方以 body 里的 {@code code} 判断成败。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OpenPlatformAuthenticationEntryPoint implements AuthenticationEntryPoint {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
|
||||||
|
throws IOException {
|
||||||
|
CommonUtil.responseError(response, Constants.UNAUTHENTICATED_CODE, "令牌缺失或无效", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.gxwebsoft.openplatform.web;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.common.core.web.ApiResult;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口专用异常处理。
|
||||||
|
*
|
||||||
|
* <p>只作用于 {@code com.gxwebsoft.openplatform} 下的 controller,且优先级高于全局的
|
||||||
|
* {@code GlobalExceptionHandler},目的是<b>不把 {@code error} 字段(异常 toString)返回给第三方</b>。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@RestControllerAdvice(basePackages = "com.gxwebsoft.openplatform")
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||||
|
public class OpenPlatformExceptionAdvice {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(OpenPlatformExceptionAdvice.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ApiResult<?> businessExceptionHandler(BusinessException e) {
|
||||||
|
return new ApiResult<>(e.getCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
public ApiResult<?> accessDeniedExceptionHandler(AccessDeniedException e) {
|
||||||
|
return new ApiResult<>(Constants.UNAUTHORIZED_CODE, "权限不足,请确认应用已获得该接口权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Throwable.class)
|
||||||
|
public ApiResult<?> exceptionHandler(Throwable e) {
|
||||||
|
logger.error("开放接口处理失败: {}", e.getMessage(), e);
|
||||||
|
return new ApiResult<>(Constants.RESULT_ERROR_CODE, Constants.RESULT_ERROR_MSG);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.gxwebsoft.openplatform.web;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.Constants;
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenCaller;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenTenantContext;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的租户绑定拦截器。
|
||||||
|
*
|
||||||
|
* <p>令牌验签由 Spring Security 的 resource server 完成,这里只负责把 {@code tenant_id}
|
||||||
|
* 落到线程上下文,供 ORM 租户插件与业务代码使用。令牌没有租户时直接拒绝,
|
||||||
|
* 不回退到请求头,避免第三方通过 {@code tenantId} 头越权访问其它租户数据。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OpenTenantInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||||
|
OpenCaller caller = OpenCaller.from(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
if (caller == null) {
|
||||||
|
throw new BusinessException(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
|
||||||
|
}
|
||||||
|
if (caller.getTenantId() == null) {
|
||||||
|
throw new BusinessException(Constants.UNAUTHORIZED_CODE, "令牌缺少租户信息,无法确定数据范围");
|
||||||
|
}
|
||||||
|
OpenTenantContext.set(caller);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
|
||||||
|
Exception ex) {
|
||||||
|
// 线程复用,必须清理,否则租户会串到下一个请求
|
||||||
|
OpenTenantContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -168,3 +168,19 @@ certificate:
|
|||||||
alipay-cert-public-key-file: "alipayCertPublicKey.crt"
|
alipay-cert-public-key-file: "alipayCertPublicKey.crt"
|
||||||
alipay-root-cert-file: "alipayRootCert.crt"
|
alipay-root-cert-file: "alipayRootCert.crt"
|
||||||
|
|
||||||
|
# 开放平台(base-api)对接配置
|
||||||
|
# 业务服务只做本地验签,不共享密钥;/api/open/** 走独立安全链。
|
||||||
|
open-platform:
|
||||||
|
enabled: true
|
||||||
|
# base-api 的 JWKS 公钥地址
|
||||||
|
# 注意:不要改用 spring.security.oauth2.resourceserver.jwt.issuer-uri,
|
||||||
|
# Spring 会去做 OIDC 发现(base-api 没有该文档)导致启动失败。
|
||||||
|
jwk-set-uri: https://base-api.websoft.top/api/v1/oauth/jwks
|
||||||
|
# 必须与 base-api 令牌里的 iss 完全一致
|
||||||
|
issuer: https://base-api.websoft.top/api
|
||||||
|
# 必须与 base-api 令牌里的 aud 一致
|
||||||
|
audience: websoft-open-platform
|
||||||
|
# 对外接口前缀,版本号由 controller 的 @RequestMapping 决定
|
||||||
|
path-prefix: /api/open
|
||||||
|
# 返回给第三方前是否对手机号脱敏
|
||||||
|
mask-sensitive: true
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.gxwebsoft.openplatform;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.system.entity.Order;
|
||||||
|
import com.gxwebsoft.openplatform.vo.OpenOrderVO;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口订单出参的字段裁剪与脱敏测试。
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class OpenOrderVOTest {
|
||||||
|
|
||||||
|
private Order order() {
|
||||||
|
Order order = new Order();
|
||||||
|
order.setOrderId(1);
|
||||||
|
order.setOrderNo("1234567890");
|
||||||
|
order.setPhone("13800000009");
|
||||||
|
order.setRealName("张三");
|
||||||
|
order.setComments("内部备注");
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("默认对手机号脱敏")
|
||||||
|
void masksPhoneByDefault() {
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), true);
|
||||||
|
|
||||||
|
assertEquals(1, list.size());
|
||||||
|
assertEquals("138****0009", list.get(0).getPhone());
|
||||||
|
assertEquals("张三", list.get(0).getRealName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("关闭脱敏后返回原号码")
|
||||||
|
void keepsPhoneWhenMaskingDisabled() {
|
||||||
|
List<OpenOrderVO> list = OpenOrderVO.from(List.of(order()), false);
|
||||||
|
|
||||||
|
assertEquals("13800000009", list.get(0).getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("手机号异常长度时原样返回,不抛异常")
|
||||||
|
void handlesShortPhone() {
|
||||||
|
Order order = order();
|
||||||
|
order.setPhone("123");
|
||||||
|
assertEquals("123", OpenOrderVO.from(List.of(order), true).get(0).getPhone());
|
||||||
|
|
||||||
|
order.setPhone(null);
|
||||||
|
assertNull(OpenOrderVO.from(List.of(order), true).get(0).getPhone());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("空列表返回空集合而不是 null")
|
||||||
|
void handlesEmptyList() {
|
||||||
|
assertTrue(OpenOrderVO.from(null, true).isEmpty());
|
||||||
|
assertTrue(OpenOrderVO.from(List.of(), true).isEmpty());
|
||||||
|
assertNotNull(OpenOrderVO.from(List.of(order()), true).get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.gxwebsoft.openplatform;
|
||||||
|
|
||||||
|
import com.gxwebsoft.openplatform.config.OpenPlatformJwtConfig;
|
||||||
|
import com.gxwebsoft.openplatform.config.OpenPlatformProperties;
|
||||||
|
import com.nimbusds.jose.JWSAlgorithm;
|
||||||
|
import com.nimbusds.jose.JWSHeader;
|
||||||
|
import com.nimbusds.jose.crypto.RSASSASigner;
|
||||||
|
import com.nimbusds.jwt.JWTClaimsSet;
|
||||||
|
import com.nimbusds.jwt.SignedJWT;
|
||||||
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.interfaces.RSAPrivateKey;
|
||||||
|
import java.security.interfaces.RSAPublicKey;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放平台令牌校验规则测试。
|
||||||
|
*
|
||||||
|
* <p>用本地生成的 RSA 密钥自行签发令牌,验证 iss / aud / exp 三条规则是否真的生效,
|
||||||
|
* 不依赖 base-api 在线(JWKS 地址在这里不会被访问)。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class OpenPlatformJwtValidationTest {
|
||||||
|
|
||||||
|
private static final String ISSUER = "https://base-api.websoft.top/api";
|
||||||
|
private static final String AUDIENCE = "websoft-open-platform";
|
||||||
|
|
||||||
|
private static KeyPair keyPair;
|
||||||
|
|
||||||
|
@BeforeAll
|
||||||
|
static void generateKeyPair() throws Exception {
|
||||||
|
KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
|
||||||
|
generator.initialize(2048);
|
||||||
|
keyPair = generator.generateKeyPair();
|
||||||
|
}
|
||||||
|
|
||||||
|
private JwtDecoder decoder() {
|
||||||
|
OpenPlatformProperties properties = new OpenPlatformProperties();
|
||||||
|
properties.setIssuer(ISSUER);
|
||||||
|
properties.setAudience(AUDIENCE);
|
||||||
|
OpenPlatformJwtConfig config = new OpenPlatformJwtConfig();
|
||||||
|
ReflectionTestUtils.setField(config, "properties", properties);
|
||||||
|
|
||||||
|
NimbusJwtDecoder decoder = NimbusJwtDecoder
|
||||||
|
.withPublicKey((RSAPublicKey) keyPair.getPublic())
|
||||||
|
.build();
|
||||||
|
decoder.setJwtValidator(config.buildTokenValidator());
|
||||||
|
return decoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sign(String issuer, String audience, Instant expiresAt) throws Exception {
|
||||||
|
JWTClaimsSet claims = new JWTClaimsSet.Builder()
|
||||||
|
.issuer(issuer)
|
||||||
|
.audience(audience)
|
||||||
|
.subject("demo-app")
|
||||||
|
.claim("client_id", "demo-app")
|
||||||
|
.claim("tenant_id", 1001)
|
||||||
|
.claim("scope", "order:read")
|
||||||
|
.issueTime(Date.from(Instant.now().minusSeconds(30)))
|
||||||
|
.expirationTime(Date.from(expiresAt))
|
||||||
|
.build();
|
||||||
|
SignedJWT jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.RS256), claims);
|
||||||
|
jwt.sign(new RSASSASigner((RSAPrivateKey) keyPair.getPrivate()));
|
||||||
|
return jwt.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("iss/aud/exp 都正确时验签通过,并保留业务声明")
|
||||||
|
void acceptsValidToken() throws Exception {
|
||||||
|
String token = sign(ISSUER, AUDIENCE, Instant.now().plusSeconds(300));
|
||||||
|
|
||||||
|
Jwt jwt = decoder().decode(token);
|
||||||
|
|
||||||
|
assertNotNull(jwt);
|
||||||
|
assertEquals("demo-app", jwt.getClaimAsString("client_id"));
|
||||||
|
assertEquals(1001, ((Number) jwt.getClaim("tenant_id")).intValue());
|
||||||
|
assertEquals("order:read", jwt.getClaimAsString("scope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("签发方不一致时拒绝,防止拿其它系统的令牌调业务接口")
|
||||||
|
void rejectsWrongIssuer() throws Exception {
|
||||||
|
String token = sign("https://someone-else.example.com", AUDIENCE, Instant.now().plusSeconds(300));
|
||||||
|
|
||||||
|
assertThrows(JwtException.class, () -> decoder().decode(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("受众不一致时拒绝")
|
||||||
|
void rejectsWrongAudience() throws Exception {
|
||||||
|
String token = sign(ISSUER, "another-platform", Instant.now().plusSeconds(300));
|
||||||
|
|
||||||
|
assertThrows(JwtException.class, () -> decoder().decode(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("令牌过期后拒绝")
|
||||||
|
void rejectsExpiredToken() throws Exception {
|
||||||
|
String token = sign(ISSUER, AUDIENCE, Instant.now().minusSeconds(60));
|
||||||
|
|
||||||
|
assertThrows(JwtException.class, () -> decoder().decode(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package com.gxwebsoft.openplatform;
|
||||||
|
|
||||||
|
import com.gxwebsoft.common.core.exception.BusinessException;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenCaller;
|
||||||
|
import com.gxwebsoft.openplatform.context.OpenTenantContext;
|
||||||
|
import com.gxwebsoft.openplatform.web.OpenTenantInterceptor;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开放接口的租户绑定与上下文清理测试。
|
||||||
|
*
|
||||||
|
* <p>租户隔离是这套改造的核心,这里覆盖三件事:从令牌取租户、令牌缺租户时拒绝、
|
||||||
|
* 请求结束后必须清理 ThreadLocal(否则线程复用会把租户串到下一个请求)。</p>
|
||||||
|
*
|
||||||
|
* @author WebSoft
|
||||||
|
*/
|
||||||
|
class OpenTenantBindingTest {
|
||||||
|
|
||||||
|
private final OpenTenantInterceptor interceptor = new OpenTenantInterceptor();
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void clearContext() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
OpenTenantContext.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jwt jwtWithTenant(Object tenantId) {
|
||||||
|
Jwt.Builder builder = Jwt.withTokenValue("test-token")
|
||||||
|
.header("alg", "RS256")
|
||||||
|
.claim("client_id", "demo-app")
|
||||||
|
.claim("scope", "order:read order:write");
|
||||||
|
if (tenantId != null) {
|
||||||
|
builder.claim("tenant_id", tenantId);
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("绑定的租户来自令牌,而不是请求头")
|
||||||
|
void tenantComesFromTokenNotHeader() {
|
||||||
|
SecurityContextHolder.getContext()
|
||||||
|
.setAuthentication(new JwtAuthenticationToken(jwtWithTenant(1001)));
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
// 第三方伪造的租户头
|
||||||
|
request.addHeader("tenantId", "9");
|
||||||
|
|
||||||
|
interceptor.preHandle(request, new MockHttpServletResponse(), new Object());
|
||||||
|
|
||||||
|
assertEquals(1001, OpenTenantContext.getTenantId());
|
||||||
|
assertEquals("demo-app", OpenTenantContext.get().getClientId());
|
||||||
|
assertEquals(List.of("order:read", "order:write"), OpenTenantContext.get().getScopes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("令牌没有租户信息时直接拒绝,不回退到请求头")
|
||||||
|
void rejectsTokenWithoutTenant() {
|
||||||
|
SecurityContextHolder.getContext()
|
||||||
|
.setAuthentication(new JwtAuthenticationToken(jwtWithTenant(null)));
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("tenantId", "9");
|
||||||
|
|
||||||
|
BusinessException e = assertThrows(BusinessException.class,
|
||||||
|
() -> interceptor.preHandle(request, new MockHttpServletResponse(), new Object()));
|
||||||
|
|
||||||
|
assertEquals(403, e.getCode());
|
||||||
|
assertNull(OpenTenantContext.getTenantId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("请求结束后清理上下文,避免租户串到下一个请求")
|
||||||
|
void clearsContextAfterCompletion() {
|
||||||
|
SecurityContextHolder.getContext()
|
||||||
|
.setAuthentication(new JwtAuthenticationToken(jwtWithTenant(1001)));
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
interceptor.preHandle(request, response, new Object());
|
||||||
|
assertEquals(1001, OpenTenantContext.getTenantId());
|
||||||
|
|
||||||
|
interceptor.afterCompletion(request, response, new Object(), null);
|
||||||
|
|
||||||
|
assertNull(OpenTenantContext.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("无认证对象时不构成调用方")
|
||||||
|
void noAuthenticationMeansNoCaller() {
|
||||||
|
assertNull(OpenCaller.from(null));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("scope 为空时不产生权限")
|
||||||
|
void parsesEmptyScopes() {
|
||||||
|
Jwt jwt = Jwt.withTokenValue("t").header("alg", "RS256").claim("scope", "").build();
|
||||||
|
assertTrue(OpenCaller.from(new JwtAuthenticationToken(jwt)).getScopes().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user