- 删除api返回结果封装(ApiResult)、业务异常(BusinessException)、系统常量(Constants)等基础模块代码 - 移除开放平台上下文(OpenCaller)、租户上下文(OpenTenantContext)及相关拦截器等实现 - 删除开放平台安全配置、安全异常处理、JWT校验配置和相关测试用例 - 调整 MybatisPlusConfig 中对 OpenTenantContext 包路径的引用 - 将开放平台controller、参数、视图、配置、拦截器等迁移至新的独立仓库包名 com.gxwebsoft.platform.openapi - 开放接口controller添加 @OpenApi 注解用于异常处理匹配 - 更新文档,补充开放平台模块移除和迁移说明,指导后续开发维护 - 保持包名兼容,确保遗留代码引用不变,无需做import改动
16 KiB
开放平台对接说明(server-api / gxwebsoft_core)
记录日期:2026-09-22 | 分支:
codex/open-platform-integration上游控制面:base-api(开放平台,负责应用、凭证、权限与令牌签发)
1. 背景
开放平台(base-api)只负责签发令牌,业务数据仍在业务服务里。第三方应用拿着
base-api 签发的 RS256 access token 调用业务接口时,业务服务需要自行完成:
- 用 JWKS 公钥验签,校验
iss/aud/exp; - 校验令牌的
scope是否包含接口所需权限; - 从令牌的
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 补了一条显式条件:
<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. 配置项
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:
./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 字段):
{
"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 哈希字段:
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 属于同一族(免登录 + 返回实体)。
修复方式是在实体上把这两个字段设为只写:
@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. 如何新增一个开放接口
- 在
OpenScopes增加 scope 常量,并确保base-api的权限字典里有同名 scope; - 在
controller下新建对外 controller,路径以/api/open/v1/开头; - 方法上加
@PreAuthorize("hasAuthority('SCOPE_xxx')"); - 入参使用独立的白名单 DTO,不要直接暴露内部
XxxParam; - 需要租户时用
OpenTenantContext.getTenantId(),不要从参数或请求头取; - 出参使用独立的 VO,不要直接返回实体;
- 若目标表在
MybatisPlusConfig的ignoreTable名单里(如sys_order), 必须在 Mapper 里显式加租户条件。
8. 验证方式
单元测试(不依赖 base-api 在线):
./mvnw -o test -Dtest='OpenPlatformJwtValidationTest,OpenTenantBindingTest,OpenOrderVOTest'
覆盖:iss/aud/exp 三条校验规则、租户只来自令牌(伪造 tenantId 请求头无效)、
令牌缺租户时拒绝、请求结束清理上下文、出参脱敏与字段裁剪。
端到端脚本 scripts/verify-open-api.sh,一条命令跑完「换令牌 → 解令牌 → 调接口 → 负向用例」:
# 真实链路(需要应用凭证)
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
判定标准:
- 令牌
scope含shop:shopOrder:list、含tenant_id; - 订单列表
code=0,total与SELECT COUNT(*) FROM sys_order WHERE deleted=0 AND tenant_id=?一致; - 不带令牌返回 401;
- 伪造
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。