Files
core/docs/OPEN_PLATFORM_INTEGRATION.md
T
gxwebsoft ea5021c3f0 feat(open-platform): 新增用户分页查询接口并更新订单权限及时间格式
- 添加 OpenUserController 支持分页查询本租户用户,权限为 sys:user:list
- 引入 OpenUserPageParam 作为用户查询白名单参数类,过滤敏感字段
- 定义 OpenUserVO,确保响应脱敏手机号、邮箱且不包含密码字段
- 修正订单接口权限 scope 由 order:read 改为 shop:shopOrder:list
- 订单及退款时间字段增加 JsonFormat 注解,统一时间格式为 yyyy-MM-dd HH:mm:ss
- 更新权限常量 OpenScopes,新增 SHOP_ORDER_LIST 和 SYS_USER_LIST
- 增加 OpenUserVO 单元测试,确保敏感字段不被泄露且脱敏逻辑正确
- 新增开放平台调用验证脚本 scripts/verify-open-api.sh,覆盖令牌获取及接口调用验证
- 修改接口文档与示例,明确权限 scope 和时间格式要求
- 修复 TenantController 权限校验问题,确保租户字段来源于令牌且不可伪造
- 禁用伪造 tenantId 请求头修改数据范围,保证租户数据隔离安全
2026-09-22 03:17:18 +08:00

12 KiB
Raw Blame History

开放平台对接说明(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 → 登录用户」 (见 MybatisPlusConfigBaseController.getTenantId), 意味着客户端可以用一个请求头指定租户。开放链路必须堵死这一点:

OpenTenantInterceptor 在请求进入 controller 前,从已验签的 JWT 取 tenant_id 写入 OpenTenantContextMybatisPlusConfig.getTenantId() 以它作为最高优先级 不再读取请求头。令牌没有 tenant_id 时直接返回 403,不回退。

2.3 订单表需要显式租户条件

sys_orderMybatisPlusConfigignoreTable 白名单里,不受多租户插件管辖 内部接口是靠「非平台租户强制 userId = 登录用户」实现隔离的(按人而非按租户)。 因此 OrderMapper.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
├─ 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

既有文件改动:

文件 改动
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(模糊)、typeorderStatuspayStatuspayTypecreateTimeStartcreateTimeEnd
时间格式 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(模糊)、typestatuscreateTimeStartcreateTimeEnd

出参只包含 userId / userCode / username / nickname / realName / type / sex / sexName / phone / email / emailVerified / organizationId / organizationName / status / auditStatus / createTime

为什么必须用独立 VOUser 实体对 passwordpayPassword 都没有 @JsonIgnoreUserMapper 用的是 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.pageGET /api/system/tenant/page)原先没有 @PreAuthorizeSecurityConfig 对所有 GET 放行;未登录且未指定 userId 时会走「无过滤」分支, 匿名即可分页读取全部租户(含租户名称、编码、手机号)。已补登录校验: 未登录返回 {"code":401,"message":"请先登录"}

登录页的多租户选择来自登录响应里的 tenants 列表(loginBySelectTenant 流程), 不依赖该接口,因此不影响登录。

同类风险可能还有其它「无 @PreAuthorize 的 GET 接口」。彻底收口需要先把 SecurityConfig 里 GET 的 /** 白名单摘掉,再按真实流量补显式白名单, 影响面较大,建议单独排期。

已知同一族的两个问题(本次未动,因为属于内部接口行为变更): GET /api/system/user/withoutAuth 免登录且可返回用户实体(含密码哈希), GET /api/system/user/page 等接口返回的实体也带密码字段——两者都应该改为按需 VO。

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. 若目标表在 MybatisPlusConfigignoreTable 名单里(如 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

判定标准:

  1. 令牌 scopeshop:shopOrder:list、含 tenant_id
  2. 订单列表 code=0totalSELECT 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。