Files
core/docs/OPEN_PLATFORM_INTEGRATION.md
T
gxwebsoft efccb786fe 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 单元测试,覆盖手机号脱敏、空列表处理等场景,提高可靠性
2026-09-22 03:08:31 +08:00

210 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 开放平台对接说明(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。