refactor(platform): 移除旧版开放平台模块并迁移至独立仓库

- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码
- 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现
- 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例
- 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用
- 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi
- 开放接口controller添加 @OpenApi 注解用于异常处理匹配
- 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护
- 保持包名兼容,确保遗留代码引用不变,无需做import改动
This commit is contained in:
2026-09-22 08:50:29 +08:00
parent 86e73b68b7
commit c3e46f4bc8
22 changed files with 49 additions and 1067 deletions
+24 -7
View File
@@ -59,7 +59,19 @@
## 3. 代码结构 ## 3. 代码结构
``` ```
本服务的业务侧(留在本仓库)
com.gxwebsoft.openplatform com.gxwebsoft.openplatform
├─ constant/OpenScopes.java scope 常量
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
├─ param/OpenUserPageParam.java 用户查询入参白名单
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
├─ controller/OpenOrderController.java /api/open/v1/order/page
└─ controller/OpenUserController.java /api/open/v1/user/page
平台层(已迁出到独立仓库 websoft-platform,作为构件依赖引入)
com.gxwebsoft.platform.openapi
├─ OpenApi 标记注解,异常处理器按它匹配
├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏 ├─ config/OpenPlatformProperties.java 配置:JWKS / iss / aud / 前缀 / 脱敏
├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器 ├─ config/OpenPlatformJwtConfig.java JwtDecoder + iss/aud 校验器
├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/** ├─ config/OpenPlatformSecurityConfig.java @Order(1),只匹配 /api/open/**
@@ -71,15 +83,20 @@ com.gxwebsoft.openplatform
├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段) ├─ web/OpenPlatformAccessDeniedHandler.java 无权限响应(不含 error 字段)
├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息 ├─ web/OpenPlatformExceptionAdvice.java 开放接口专用异常处理,不外泄内部信息
├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit} ├─ web/OpenPageResult.java 对外分页结构 {list,total,page,limit}
constant/OpenScopes.java scope 常量 web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil)
├─ param/OpenOrderPageParam.java 入参白名单(刻意不含 tenantId)
├─ param/OpenUserPageParam.java 用户查询入参白名单
├─ vo/OpenOrderVO.java 出参裁剪 + 手机号脱敏
├─ vo/OpenUserVO.java 出参裁剪 + 手机号/邮箱脱敏,不含密码字段
├─ controller/OpenOrderController.java /api/open/v1/order/page
└─ controller/OpenUserController.java /api/open/v1/user/page
``` ```
> **迁移说明(2026-09-22)**:上述平台层文件原先以源码形式放在本仓库的
> `com.gxwebsoft.openplatform.{config,context,web}` 下,现已抽到独立仓库
> `websoft-platform` 并作为 `websoft-platform-openapi` 构件依赖引入。
> 内核 `Constants` / `ApiResult` / `PageResult` / `BusinessException` 同样已迁移,
> 但**包名保持不变**,所以本服务其余代码一行 import 都不用改。
>
> 对外开放的 controller 必须加 `@OpenApi` 注解——平台层的异常处理器按注解匹配,
> 漏加会导致第三方收到带 `error` 字段(内部异常信息)的响应。
>
> 详见 `websoft-platform/docs/PLATFORM_LAYER.md`。
既有文件改动: 既有文件改动:
| 文件 | 改动 | | 文件 | 改动 |
+14 -3
View File
@@ -24,6 +24,8 @@
<maven.compiler.target>17</maven.compiler.target> <maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<!-- 平台层版本:升级平台能力只改这一处 -->
<websoft-platform.version>1.0.0</websoft-platform.version>
</properties> </properties>
<dependencyManagement> <dependencyManagement>
@@ -203,10 +205,19 @@
<artifactId>spring-boot-starter-security</artifactId> <artifactId>spring-boot-starter-security</artifactId>
</dependency> </dependency>
<!-- 开放平台资源服务:校验 base-api 签发的 RS256 令牌(JWKS 取公钥,不共享密钥) --> <!--
平台层:内核(统一返回/状态码/业务异常)+ 开放平台资源服务能力。
这些类原先以源码形式散在本仓库里,每个业务仓库各复制一份,改一处要改 40 处。
现在改为依赖构件,升级只要改下面的版本号。
配套约定:BOM 只管理 com.gxwebsoft 的构件,不接管 Spring Boot 版本,
所以本服务仍可自行决定 Boot 版本。
-->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>com.gxwebsoft</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId> <artifactId>websoft-platform-openapi</artifactId>
<version>${websoft-platform.version}</version>
</dependency> </dependency>
<!-- jjwt - 升级到安全版本 --> <!-- jjwt - 升级到安全版本 -->
@@ -1,93 +0,0 @@
package com.gxwebsoft.common.core;
/**
* 系统常量
* Created by WebSoft on 2019-10-29 15:55
*/
public class Constants {
/**
* 默认成功码
*/
public static final int RESULT_OK_CODE = 0;
/**
* 默认失败码
*/
public static final int RESULT_ERROR_CODE = 1;
/**
* 默认成功信息
*/
public static final String RESULT_OK_MSG = "操作成功";
/**
* 默认失败信息
*/
public static final String RESULT_ERROR_MSG = "操作失败";
/**
* 无权限错误码
*/
public static final int UNAUTHORIZED_CODE = 403;
/**
* 无权限提示信息
*/
public static final String UNAUTHORIZED_MSG = "没有访问权限";
/**
* 未认证错误码
*/
public static final int UNAUTHENTICATED_CODE = 401;
/**
* 未认证提示信息
*/
public static final String UNAUTHENTICATED_MSG = "请先登录";
/**
* 登录过期错误码
*/
public static final int TOKEN_EXPIRED_CODE = 401;
/**
* 登录过期提示信息
*/
public static final String TOKEN_EXPIRED_MSG = "登录已过期";
/**
* 非法token错误码
*/
public static final int BAD_CREDENTIALS_CODE = 401;
/**
* 非法token提示信息
*/
public static final String BAD_CREDENTIALS_MSG = "请退出重新登录";
/**
* 表示升序的值
*/
public static final String ORDER_ASC_VALUE = "asc";
/**
* 表示降序的值
*/
public static final String ORDER_DESC_VALUE = "desc";
/**
* token通过header传递的名称
*/
public static final String TOKEN_HEADER_NAME = "Authorization";
/**
* token通过参数传递的名称
*/
public static final String TOKEN_PARAM_NAME = "access_token";
/**
* token认证类型
*/
public static final String TOKEN_TYPE = "Bearer";
}
@@ -7,7 +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.platform.openapi.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;
@@ -1,48 +0,0 @@
package com.gxwebsoft.common.core.exception;
import com.gxwebsoft.common.core.Constants;
/**
* 自定义业务异常
*
* @author WebSoft
* @since 2018-02-22 11:29:28
*/
public class BusinessException extends RuntimeException {
private static final long serialVersionUID = 1L;
private Integer code;
public BusinessException() {
this(Constants.RESULT_ERROR_MSG);
}
public BusinessException(String message) {
this(Constants.RESULT_ERROR_CODE, message);
}
public BusinessException(Integer code, String message) {
super(message);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public BusinessException(Integer code, String message, Throwable cause,
boolean enableSuppression, boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
this.code = code;
}
public Integer getCode() {
return code;
}
public void setCode(Integer code) {
this.code = code;
}
}
@@ -1,87 +0,0 @@
package com.gxwebsoft.common.core.web;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
/**
* 返回结果
*
* @author WebSoft
* @since 2017-06-10 10:10:50
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public class ApiResult<T> implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "状态码")
private Integer code;
@Schema(description = "状态信息")
private String message;
@Schema(description = "返回数据")
private T data;
@Schema(description = "错误信息")
private String error;
public ApiResult() {}
public ApiResult(Integer code) {
this(code, null);
}
public ApiResult(Integer code, String message) {
this(code, message, null);
}
public ApiResult(Integer code, String message, T data) {
this(code, message, data, null);
}
public ApiResult(Integer code, String message, T data, String error) {
setCode(code);
setMessage(message);
setData(data);
setError(error);
}
public Integer getCode() {
return this.code;
}
public ApiResult<T> setCode(Integer code) {
this.code = code;
return this;
}
public String getMessage() {
return this.message;
}
public ApiResult<T> setMessage(String message) {
this.message = message;
return this;
}
public T getData() {
return this.data;
}
public ApiResult<T> setData(T data) {
this.data = data;
return this;
}
public String getError() {
return this.error;
}
public ApiResult<T> setError(String error) {
this.error = error;
return this;
}
}
@@ -1,51 +0,0 @@
package com.gxwebsoft.common.core.web;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.List;
/**
* 分页查询返回结果
*
* @author WebSoft
* @since 2017-06-10 10:10:02
*/
public class PageResult<T> implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "当前页数据")
private List<T> list;
@Schema(description = "总数量")
private Long count;
public PageResult() {
}
public PageResult(List<T> list) {
this(list, null);
}
public PageResult(List<T> list, Long count) {
setList(list);
setCount(count);
}
public List<T> getList() {
return this.list;
}
public void setList(List<T> list) {
this.list = list;
}
public Long getCount() {
return this.count;
}
public void setCount(Long count) {
this.count = count;
}
}
@@ -1,69 +0,0 @@
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));
};
}
}
@@ -1,49 +0,0 @@
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;
}
@@ -1,76 +0,0 @@
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);
}
}
@@ -1,31 +0,0 @@
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() + "/**");
}
}
@@ -1,83 +0,0 @@
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;
}
}
@@ -1,39 +0,0 @@
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();
}
}
@@ -7,12 +7,13 @@ import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Order; import com.gxwebsoft.common.system.entity.Order;
import com.gxwebsoft.common.system.param.OrderParam; import com.gxwebsoft.common.system.param.OrderParam;
import com.gxwebsoft.common.system.service.OrderService; import com.gxwebsoft.common.system.service.OrderService;
import com.gxwebsoft.openplatform.config.OpenPlatformProperties; import com.gxwebsoft.platform.openapi.OpenApi;
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
import com.gxwebsoft.openplatform.constant.OpenScopes; import com.gxwebsoft.openplatform.constant.OpenScopes;
import com.gxwebsoft.openplatform.context.OpenTenantContext; import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.openplatform.param.OpenOrderPageParam; import com.gxwebsoft.openplatform.param.OpenOrderPageParam;
import com.gxwebsoft.openplatform.vo.OpenOrderVO; import com.gxwebsoft.openplatform.vo.OpenOrderVO;
import com.gxwebsoft.openplatform.web.OpenPageResult; import com.gxwebsoft.platform.openapi.web.OpenPageResult;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -40,6 +41,7 @@ import java.util.List;
* @author WebSoft * @author WebSoft
*/ */
@Tag(name = "开放接口-订单") @Tag(name = "开放接口-订单")
@OpenApi
@RestController @RestController
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
@RequestMapping("/api/open/v1/order") @RequestMapping("/api/open/v1/order")
@@ -7,12 +7,13 @@ import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User; import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.common.system.param.UserParam; import com.gxwebsoft.common.system.param.UserParam;
import com.gxwebsoft.common.system.service.UserService; import com.gxwebsoft.common.system.service.UserService;
import com.gxwebsoft.openplatform.config.OpenPlatformProperties; import com.gxwebsoft.platform.openapi.OpenApi;
import com.gxwebsoft.platform.openapi.config.OpenPlatformProperties;
import com.gxwebsoft.openplatform.constant.OpenScopes; import com.gxwebsoft.openplatform.constant.OpenScopes;
import com.gxwebsoft.openplatform.context.OpenTenantContext; import com.gxwebsoft.platform.openapi.context.OpenTenantContext;
import com.gxwebsoft.openplatform.param.OpenUserPageParam; import com.gxwebsoft.openplatform.param.OpenUserPageParam;
import com.gxwebsoft.openplatform.vo.OpenUserVO; import com.gxwebsoft.openplatform.vo.OpenUserVO;
import com.gxwebsoft.openplatform.web.OpenPageResult; import com.gxwebsoft.platform.openapi.web.OpenPageResult;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -34,6 +35,7 @@ import java.util.List;
* @author WebSoft * @author WebSoft
*/ */
@Tag(name = "开放接口-用户") @Tag(name = "开放接口-用户")
@OpenApi
@RestController @RestController
@ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(prefix = "open-platform", name = "enabled", havingValue = "true", matchIfMissing = true)
@RequestMapping("/api/open/v1/user") @RequestMapping("/api/open/v1/user")
@@ -1,45 +0,0 @@
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;
}
}
@@ -1,27 +0,0 @@
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);
}
}
@@ -1,31 +0,0 @@
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);
}
}
@@ -1,44 +0,0 @@
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);
}
}
@@ -1,46 +0,0 @@
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();
}
}
@@ -1,119 +0,0 @@
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", "shop:shopOrder:list")
.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("shop:shopOrder:list", 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));
}
}
@@ -1,112 +0,0 @@
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", "shop:shopOrder:list shop:shopOrder:save");
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("shop:shopOrder:list", "shop:shopOrder:save"),
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());
}
}