# 开放平台对接说明(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 AND a.tenant_id = #{param.tenantId} ``` `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. 配置项 ```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 ` | | 权限 | 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 ` | | 权限 | 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。