Files
core/docs/OPEN_PLATFORM_INTEGRATION.md
T
gxwebsoft c3e46f4bc8 refactor(platform): 移除旧版开放平台模块并迁移至独立仓库
- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码
- 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现
- 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例
- 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用
- 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi
- 开放接口controller添加 @OpenApi 注解用于异常处理匹配
- 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护
- 保持包名兼容,确保遗留代码引用不变,无需做import改动
2026-09-22 08:50:29 +08:00

326 lines
16 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
├─ 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/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}
└─ web/JsonResponseWriter.java 响应写出(不依赖业务侧 CommonUtil
```
> **迁移说明(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`。
既有文件改动:
| 文件 | 改动 |
| --- | --- |
| `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. 接口契约
### 5.1 订单列表
`GET /api/open/v1/order/page`
| 项 | 说明 |
| --- | --- |
| 认证 | `Authorization: Bearer <base-api accessToken>` |
| 权限 | scope 含 `shop:shopOrder:list`,否则 403 |
| 数据范围 | 令牌 `tenant_id` 对应租户的订单,**不接受也不识别 `tenantId` 参数或请求头** |
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100 |
| 过滤 | `orderNo`(模糊)、`type``orderStatus``payStatus``payType``createTimeStart``createTimeEnd` |
| 时间格式 | `yyyy-MM-dd HH:mm:ss`,输出库中存储的挂钟时间,不做时区换算 |
响应沿用平台约定(**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 | 权限不足,请确认应用已获得该接口权限 | 令牌没有 `shop:shopOrder:list` |
| 403 | 令牌缺少租户信息,无法确定数据范围 | 令牌没有 `tenant_id` |
### 5.2 用户列表
`GET /api/open/v1/user/page`
| 项 | 说明 |
| --- | --- |
| 认证 | `Authorization: Bearer <base-api accessToken>` |
| 权限 | scope 含 `sys:user:list`,否则 403 |
| 数据范围 | 令牌 `tenant_id` 对应租户的用户 |
| 分页 | `page`(默认 1)、`limit`(默认 20,上限 100 |
| 过滤 | `username`(模糊)、`nickname`(模糊)、`type``status``createTimeStart``createTimeEnd` |
出参只包含 `userId` / `userCode` / `username` / `nickname` / `realName` / `type` / `sex` /
`sexName` / `phone` / `email` / `emailVerified` / `organizationId` / `organizationName` /
`status` / `auditStatus` / `createTime`
> **为什么必须用独立 VO**`User` 实体对 `password`、`payPassword` 都没有 `@JsonIgnore`
> 而 `UserMapper` 用的是 `SELECT a.*`。内部接口 `/api/system/user/page` 直接把实体返回,
> 响应里带着密码哈希;开放接口如果照抄这个写法就会把凭证交给第三方。
> `OpenUserVOTest` 里有一条断言专门防止这种回归。
> **scope 命名说明**`shop:shopOrder:list` 取自 `sys_menu.authority` 里已有的权限点
> (菜单 157795「查询」、182274「项目订单」),与内部权限体系保持一致。
> 但注意 `gxwebsoft_core` 库里**没有 `shop_*` 表**(只有 `sys_order` / `sys_order_goods`),
> 当前该 scope 守卫的是 `sys_order` 的数据。若后续要对外开放的是商城订单,
> 需要把接口指向商城所在的服务与库,而不是复用本 controller。
## 6. 随本次改动修复的既有问题
`TenantController.page``GET /api/system/tenant/page`)原先没有 `@PreAuthorize`
`SecurityConfig` 对所有 GET 放行;未登录且未指定 `userId` 时会走「无过滤」分支,
**匿名即可分页读取全部租户**(含租户名称、编码、手机号)。已补登录校验:
未登录返回 `{"code":401,"message":"请先登录"}`
登录页的多租户选择来自登录响应里的 `tenants` 列表(`loginBySelectTenant` 流程),
不依赖该接口,因此不影响登录。
> 同类风险可能还有其它「无 `@PreAuthorize` 的 GET 接口」。彻底收口需要先把
> `SecurityConfig` 里 GET 的 `/**` 白名单摘掉,再按真实流量补显式白名单,
> 影响面较大,建议单独排期。
### 6.1 用户密码哈希可通过免登录接口读取(已修复)
`GET /api/system/user/getByUserId/{userId}``SecurityConfig` 的 GET 白名单里,
实测**无需任何凭证**,只带一个 `tenantId` 请求头即可取到用户实体,
而响应里包含 `password``payPassword` 两个 BCrypt 哈希字段:
```bash
curl -H 'tenantId: <任意租户ID>' 'https://server.websoft.top/api/system/user/getByUserId/<用户ID>'
# => {"code":0,"data":{"userId":..., "password":"$2a$10$...", "payPassword":"..."}}
```
根因有两点叠加:`User` 实体对 `password` / `payPassword` 没有做序列化限制,
`UserMapper` 使用 `SELECT a.*`;同时该接口无 `@PreAuthorize` 又落在 GET 白名单里。
`GET /api/system/user/withoutAuth` 属于同一族(免登录 + 返回实体)。
修复方式是在实体上把这两个字段设为只写:
```java
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
```
只影响序列化(不再输出),不影响任何依赖反序列化写入密码的流程。
`password``payPassword` 都已加上该注解,因此所有返回 `User` 实体的接口一并收敛,
包括 `getByUserId``withoutAuth``getByPhone``getByUnionid``list``page` 等。
> **同步报文已显式保留**`RabbitMQSyncProducer.sendUserSyncMessage` 把 User 转成 Map 时
> 用的是同一个 ObjectMapper,实体改成只写后密码会从报文里消失。为避免悄悄改变
> websopy 侧收到的内容,生产者在转换后显式补回 `password` / `payPassword`。
> 是否继续在 MQ 报文里带密码哈希,需要 websopy 侧确认后另行决定。
> `UserCredentialSerializationTest` 用三个用例锁住了「HTTP 不输出、反序列化可写入、MQ 报文不变」。
### 6.2 同一批免登录接口仍存在的问题(未修复,需决策)
修掉密码后,这一族接口仍然免登录返回完整用户实体,且租户由 `tenantId` 请求头指定:
| 接口 | 问题 |
| --- | --- |
| `GET /api/system/user/getByUserId/{userId}` | 免登录返回用户详情,含 `idCard`(完整 18 位身份证号)、手机号、邮箱、余额 |
| `GET /api/system/user/withoutAuth` | 免登录,一次返回该租户**全部**用户(不受 page/limit 限制,实测租户 5 返回 163 条 × 107 字段) |
| `GET /api/system/user/getByPhone/{phone}` | 免登录按手机号查用户,可被枚举(`mp-react-nextjs` 在用) |
| `GET /api/system/user/getByUnionid/{unionid}` | 免登录 |
| `PUT /api/system/user/updateWithoutLogin` | **完全没有校验**,任何人都能改用户资料 |
| `POST /api/system/user/batchBackUserId` | **完全没有校验**,可批量建用户(`mp-react-nextjs` 在用) |
另有 `updateUserBalanceWithoutLogin``addUserBalanceWithoutLogin``getUserWithoutLogin`
`updateUserOfficeOpenidWithoutLogin` 使用硬编码常量 `authCode == "1700083"` 作为唯一凭证,
该常量对所有租户相同且写在源码里,等同于弱口令。
`id_card` 列存的是**完整 18 位身份证号**(库里 3173 条有值),与实体上的
「身份证号(脱敏)」注释不符,需要单独决定是脱敏还是仅对管理员可见——会直接影响
租户后台的实名审核页面,因此未擅自改动。
## 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` 请求头无效)、
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
端到端脚本 `scripts/verify-open-api.sh`,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
```bash
# 真实链路(需要应用凭证)
OPEN_CLIENT_ID=xxx OPEN_CLIENT_SECRET=yyy ./scripts/verify-open-api.sh
# 已有令牌时跳过换令牌
OPEN_ACCESS_TOKEN=eyJ... BUSINESS_BASE_URL=http://127.0.0.1:8080 TENANT_EXPECT=6 ./scripts/verify-open-api.sh
```
判定标准:
1. 令牌 `scope``shop:shopOrder:list`、含 `tenant_id`
2. 订单列表 `code=0``total``SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?` 一致;
3. 不带令牌返回 401
4. 伪造 `tenantId` 请求头不改变返回结果。
本地自建 JWKS(不依赖 base-api)的联调方式见第 4 节。
## 9. 待办
- [ ] `base-api` 的权限字典补充 `shop:shopOrder:list` 等业务 scope,并在应用管理页可勾选;
- [ ] 开放流量的限流与配额(当前 `open_app.rate_limit` 只作用于令牌签发);
- [ ] 调用审计埋点(`client_id` / `tenant_id` / `scope` 全链路);
- [ ] `SecurityConfig` 的 GET `/**` 白名单收口;
- [ ] 第二个业务服务出现时,把 `openplatform` 配置与拦截器抽成共享 starter。