平台 SDK 契约
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
平台 SDK 是宿主与插件之间唯一的稳定面:插件要宿主的能力,不直接注入宿主的 Mapper / Service, 而是依赖这里的一个接口;宿主提供一个实现。
| 项 | 值 |
|---|---|
| 坐标 | cn.wujies:wujies-common-plugin-sdk(版本随平台,当前 5.6.2) |
| 源码 | wujies-platform/wujies-common/wujies-common-plugin-sdk/src/main/java/cn/wujies/common/plugin/sdk/ |
| 内容 | 18 个类:6 个能力接口、9 个值对象、3 个定位器 |
| 依赖 | 零依赖(纯 JDK)。它被所有插件共同依赖,一旦引入 Spring 或 JSON 库就不再是稳定契约 |
为什么必须是零依赖
这不是洁癖,是几条具体设计后果的来源:
PlatformOssApi.upload收的是PlatformUploadFile而不是 Spring 的MultipartFile—— 插件侧只多一行PlatformUploadFile.of(mf.getOriginalFilename(), mf.getContentType(), mf.getBytes()), 换来契约不绑框架;- 分页自己给一个
PlatformMessagePage(纯 JDK),不引平台分页类 —— 插件要转成自己的分页结构自己转; PlatformOssExt只给类型、不负责解析 —— 解析交给调用方自己的 JSON 工具, SDK 不因为一个parseObject就把 Jackson 拉进来。
一、能力接口(宿主实现,插件使用)
插件侧一律用 ObjectProvider<X> 取,不要强注入:宿主缺少该能力时应当降级,而不是启动失败。
| 接口 | 用途 | 方法数 |
|---|---|---|
PlatformUserApi | 读用户信息(昵称 / 头像 / 状态)与按部门取人 | 8 |
PlatformDeptApi | 把部门展开成"它 + 全部子部门" | 1 |
PlatformMessageApi | 发站内消息、查公开公告 | 2 |
PlatformOssApi | 对象存储:查元信息 / 上传 / 批量删 | 5 |
FollowRelationApi | 查"某用户关注了谁"(图床的关注流要用社交模块的数据) | 1 |
PlatformMemberApi | 读 / 改会员资料(宿主读会员数据用,见定位器) | 2 |
PlatformUserApi
| 方法 | 说明 |
|---|---|
PlatformUserView findById(Long userId) | 取单个用户;不存在返回 null,不抛异常 |
Map<Long, PlatformUserView> findByIds(Collection<Long> userIds) | 批量取;入参为空返回空 Map,查不到的 ID 不出现在结果里 |
List<Long> listEnabledUserIds() | 全部启用中的用户 ID |
List<Long> searchEnabledUserIds(String keyword) | 按关键词模糊搜索启用中的用户(昵称或账号);关键词为空等价于上一个方法 |
long countEnabled() | 启用中的用户总数 |
long countEnabledByIds(Collection<Long> userIds) | 给定 ID 里启用中的有多少(考试用它算有效考生) |
List<Long> listUserIdsByDeptIds(Collection<Long> deptIds, boolean includeChildren) | 按部门范围取启用中的用户;展开子部门的逻辑由宿主负责 |
int updateStatus(Collection<Long> userIds, String status) | 批量改账号状态(会员冻结时要连带停用系统账号)。刻意只改状态,不做通用更新 |
PlatformDeptApi
| 方法 | 说明 |
|---|---|
Set<Long> deptAndChildIds(Long deptId) | 某部门及其全部子部门的 ID(含自己);入参 null 返回空集合 |
只有一个方法,是刻意的:插件对部门表的需求实测就这么一件事。一个 1 方法的接口评审成本接近零, 而一个"按任意条件查部门"的接口则不然。
PlatformMessageApi
| 方法 | 说明 |
|---|---|
Long send(PlatformMessageCommand command) | 发一条站内消息(落库 + 投递)。失败返回 null,不抛异常 —— 通知发不出去不该让业务事务回滚 |
PlatformMessagePage queryPublicNotices(int pageNum, int pageSize) | 分页查公开公告(@SaIgnore 的开放接口用) |
PlatformOssApi
| 方法 | 说明 |
|---|---|
PlatformOssView findById(Long ossId) | 按 ID 取单个对象元信息,不存在返回 null |
Map<Long, PlatformOssView> findByIds(Collection<Long> ossIds) | 批量取(列表页一次补齐 URL,避免 N+1) |
PlatformOssView upload(PlatformUploadFile file) | 上传"请求里带上来的字节" |
PlatformOssView upload(File file) | 上传本地文件(走流式,适合大文件与插件自己产生的文件) |
boolean deleteByIds(Collection<Long> ossIds) | 批量删除(宿主按软删除处理) |
刻意没有"按任意条件查询""透传 SqlWrapper"这类口子 —— 一旦开了,契约就退化成"给插件一个 Mapper"。
FollowRelationApi
| 方法 | 说明 |
|---|---|
Set<Long> followedUserIds(Long userId, Collection<Long> candidateUserIds) | 该用户关注了哪些人;candidateUserIds 为空表示不限制。返回集合可能为空,不会为 null |
它存在的理由:图床的"关注流"要读社交模块的数据,原实现让图床直接依赖 SocialFollowMapper,
图床就无法脱离社交模块单独安装。现在反转成"插件声明需要什么能力,宿主提供实现"。
PlatformMemberApi
| 方法 | 说明 |
|---|---|
MemberProfileView queryProfileByUserId(Long userId) | 按登录用户 id 查会员资料;不是会员返回 null |
boolean updateProfileByUserId(Long userId, MemberProfileCommand command) | 按登录用户 id 更新会员资料;不是会员返回 false |
它与 MemberServiceLocator 的分工见定位器:这个接口是宿主读会员用的,
定位器是插件之间用的。
二、值对象(跨边界的传输形态)
| 类 | 形态 | 用途 | 规模 |
|---|---|---|---|
PlatformUserView | record | 插件能看到的全部用户信息。不含 password / roleIds / postIds | 10 字段 + 2 常量 + displayName() / enabled() |
PlatformMessageView | record | 站内消息只读视图(公开公告列表实际要展示的那些字段) | 8 字段 |
PlatformMessagePage | record | 与分页框架无关的最小分页载体(records / total) | 2 字段 + empty() |
PlatformMessageCommand | record | 发消息的请求。替代插件自己"先 insert、再从回填的 bo 取 ID、再 publish"两步 | 9 字段 + 2 静态工厂 + 3 链式方法 |
PlatformOssView | record | 对象存储元信息只读视图 | 8 字段 + extJson() / local() / toMap() |
PlatformOssExt | 类 | 附件扩展信息(存在 OSS 记录 ext1 字段里的 JSON 结构)。刻意不是 record —— 属性名即 JSON 键名,改 record 会改序列化行为 | 13 字段 |
PlatformUploadFile | record | 与 Web 框架无关的待上传文件。content 是整块字节数组,不适合大文件 | 3 字段 + 2 个 of + size() / isEmpty() |
MemberProfileView | 类(@Data) | 会员资料视图,只带标量字段(badgeList 与嵌套 user 刻意不带) | 19 字段 |
MemberProfileCommand | 类(@Data) | 会员资料更新指令;字段全可为 null,null 表示这次不改这一项。昵称 / 头像 / 手机号属于宿主 sys_user,不走这个契约 | 5 字段 |
三、定位器
三个定位器都标了 @AutoConfiguration,是 PluginServiceLocator(在 wujies-common-core,不在本 SDK)
的薄包装。它们让宿主侧代码拿到插件上下文里的服务实现。
| 类 | 应用编码 | 应用名(仅用于报错文案) |
|---|---|---|
MemberServiceLocator | member | 会员管理 |
PayServiceLocator | pay | 支付管理 |
ImServiceLocator | im | 即时通讯 |
三者结构相同:构造时收 ObjectProvider<PluginServiceProvider> 与宿主 ApplicationContext,
对外暴露 get(Class<T>) / available(Class<?>) / proxy(Class<T>)。
机制、回落与降级规则见定位器。
四、自动装配
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 的内容:
cn.wujies.common.plugin.sdk.MemberServiceLocator
cn.wujies.common.plugin.sdk.PayServiceLocator
cn.wujies.common.plugin.sdk.ImServiceLocator只有这三个定位器需要自动装配 —— 能力接口与值对象都不是 bean。
五、两侧各要做什么
private final ObjectProvider<PlatformUserApi> userApiProvider;
void doSomething(Long userId) {
PlatformUserApi api = userApiProvider.getIfAvailable();
if (api == null) {
// 宿主没提供这个能力 → 降级。**不要**让插件启动失败
return;
}
PlatformUserView user = api.findById(userId);
}实现在 wujies-system 等拿得到 Mapper 的模块里,用 @ConditionalOnMissingBean 声明 ——
这样沙箱或测试可以覆盖成替身。不要把实现放进某个插件:消费方可能是另一个插件,
而插件看不见兄弟插件,对方会拿到 null 并静默降级。