OA 系统对接示例
本文档以 OA 办公系统为例,演示如何完整对接 IM 开放平台,包括应用安装、SSO 免登和消息推送。
场景说明
OA 系统是一个多租户的企业办公应用,需要实现以下功能:
- 应用安装 - OA 管理员在 IM 中安装 OA 应用,建立工作空间与租户的绑定
- SSO 免登 - IM 用户点击 OA 应用直接登录,无需重复输入账号密码
- 消息推送 - OA 系统向 IM 用户推送审批通知、待办提醒等
数据库设计
工作空间绑定表
记录 IM 工作空间与 OA 租户的绑定关系(一对一):
CREATE TABLE workspace_bindings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
oa_tenant_id BIGINT NOT NULL COMMENT 'OA 租户ID',
im_workspace_id BIGINT NOT NULL COMMENT 'IM 工作空间ID',
im_workspace_name VARCHAR(255) COMMENT 'IM 工作空间名称',
bot_token VARCHAR(1024) COMMENT 'Bot Token',
bot_token_expires_at DATETIME COMMENT 'Token 过期时间',
bind_status TINYINT DEFAULT 1 COMMENT '绑定状态: 1-已绑定, 0-已解绑',
install_time DATETIME COMMENT '安装时间',
installer_user_id BIGINT COMMENT '安装者用户ID',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_tenant (oa_tenant_id),
UNIQUE KEY uk_workspace (im_workspace_id)
) COMMENT '工作空间绑定表';
用户绑定表
记录 IM 用户与 OA 用户的绑定关系:
CREATE TABLE im_bindings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL COMMENT 'OA 用户ID',
tenant_id BIGINT NOT NULL COMMENT 'OA 租户ID',
im_user_id BIGINT NOT NULL COMMENT 'IM 用户ID',
im_workspace_id BIGINT NOT NULL COMMENT 'IM 工作空间ID',
bind_status TINYINT DEFAULT 1 COMMENT '绑定状态: 1-已绑定, 0-已解绑',
binding_method VARCHAR(32) COMMENT '绑定方式: installer_auto/manual/oauth',
bind_time DATETIME COMMENT '绑定时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_tenant (user_id, tenant_id),
UNIQUE KEY uk_im_user_workspace (im_user_id, im_workspace_id)
) COMMENT '用户绑定表';
完整对接流程
流程一:应用安装
流程二:SSO 免登
流程三:消息推送
核心代码实现
配置文件
# application.yml
im:
client-id: ${IM_CLIENT_ID:550e8400-e29b-41d4-a716-446655440000}
client-secret: ${IM_CLIENT_SECRET:7c9e6679-7425-40de-944b-e07fc1f90ae7}
api-base-url: ${IM_API_BASE_URL:http://localhost:8088}
app:
frontend:
url: ${APP_FRONTEND_URL:http://localhost:3001}
实体类定义
import lombok.Data;
import java.time.LocalDateTime;
/**
* 工作空间绑定实体
*/
@Data
public class WorkspaceBinding {
private Long id;
private Long oaTenantId;
private Long imWorkspaceId;
private String imWorkspaceName;
private String botToken;
private LocalDateTime botTokenExpiresAt;
private Integer bindStatus;
private LocalDateTime installTime;
private Long installerUserId;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
/**
* 用户绑定实体
*/
@Data
public class ImBinding {
private Long id;
private Long userId;
private Long tenantId;
private Long imUserId;
private Long imWorkspaceId;
private Integer bindStatus;
private String bindingMethod;
private LocalDateTime bindTime;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
绑定服务实现
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class WorkspaceBindingService {
private final WorkspaceBindingMapper bindingMapper;
/**
* 检查租户是否已被绑定
*/
public boolean isTenantBound(Long tenantId) {
return bindingMapper.selectByTenantId(tenantId) != null;
}
/**
* 检查工作空间是否已被绑定
*/
public boolean isWorkspaceBound(Long workspaceId) {
return bindingMapper.selectByWorkspaceId(workspaceId) != null;
}
/**
* 根据工作空间ID查询绑定
*/
public WorkspaceBinding getByWorkspaceId(Long workspaceId) {
return bindingMapper.selectByWorkspaceId(workspaceId);
}
/**
* 根据租户ID查询绑定
*/
public WorkspaceBinding getByTenantId(Long tenantId) {
return bindingMapper.selectByTenantId(tenantId);
}
/**
* 创建绑定关系
*/
@Transactional
public WorkspaceBinding createBinding(
Long tenantId,
Long workspaceId,
String workspaceName,
String botToken,
Long expiresIn,
Long installerUserId
) {
WorkspaceBinding binding = new WorkspaceBinding();
binding.setOaTenantId(tenantId);
binding.setImWorkspaceId(workspaceId);
binding.setImWorkspaceName(workspaceName);
binding.setBotToken(botToken);
binding.setBotTokenExpiresAt(LocalDateTime.now().plusSeconds(expiresIn));
binding.setBindStatus(1);
binding.setInstallTime(LocalDateTime.now());
binding.setInstallerUserId(installerUserId);
bindingMapper.insert(binding);
return binding;
}
}
用户绑定服务
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class ImBindingService {
private final ImBindingMapper bindingMapper;
/**
* 根据 OA 用户ID查询绑定
*/
public ImBinding getByUserId(Long userId, Long tenantId) {
return bindingMapper.selectByUserIdAndTenantId(userId, tenantId);
}
/**
* 根据 IM 用户ID查询绑定
*/
public ImBinding getByImUserId(Long imUserId, Long imWorkspaceId) {
return bindingMapper.selectByImUserIdAndWorkspaceId(imUserId, imWorkspaceId);
}
/**
* 创建绑定关系
*/
@Transactional
public ImBinding createBinding(
Long userId,
Long tenantId,
Long imUserId,
Long imWorkspaceId,
String bindingMethod
) {
ImBinding binding = new ImBinding();
binding.setUserId(userId);
binding.setTenantId(tenantId);
binding.setImUserId(imUserId);
binding.setImWorkspaceId(imWorkspaceId);
binding.setBindStatus(1);
binding.setBindingMethod(bindingMethod);
binding.setBindTime(LocalDateTime.now());
bindingMapper.insert(binding);
return binding;
}
/**
* 安装者自动绑定
*/
@Transactional
public void createAutoBinding(
Long oaUserId,
Long tenantId,
Long imUserId,
Long imWorkspaceId
) {
// 检查是否已存在绑定
ImBinding existing = getByUserId(oaUserId, tenantId);
if (existing != null) {
return; // 不覆盖已有绑定
}
createBinding(oaUserId, tenantId, imUserId, imWorkspaceId, "installer_auto");
}
}
安装回调处理
import jakarta.servlet.http.HttpSession;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
@RestController
@RequestMapping("/api/im/install")
@RequiredArgsConstructor
@Slf4j
public class ImInstallController {
private final ImApiClient imApiClient;
private final WorkspaceBindingService bindingService;
private final ImBindingService imBindingService;
private final AppProperties appProperties;
/**
* 安装回调接口(IM 跳转到此接口)
*/
@GetMapping("/callback")
public String handleCallback(
@RequestParam("workspace_id") Long workspaceId,
@RequestParam("workspace_name") String workspaceName,
@RequestParam("app_id") Long appId,
@RequestParam(value = "initiator_user_id", required = false) Long initiatorUserId,
@RequestParam("timestamp") Long timestamp,
@RequestParam("sign") String sign,
HttpSession session
) {
log.info("收到安装回调: workspaceId={}, appId={}, initiatorUserId={}",
workspaceId, appId, initiatorUserId);
// 保存到 Session
session.setAttribute("install_workspace_id", workspaceId);
session.setAttribute("install_workspace_name", workspaceName);
session.setAttribute("install_initiator_user_id", initiatorUserId);
session.setAttribute("install_timestamp", timestamp);
session.setAttribute("install_sign", sign);
// 生成 Session Token 传递给前端
String sessionToken = UUID.randomUUID().toString();
session.setAttribute("install_session_token", sessionToken);
// 重定向到前端
return "redirect:" + appProperties.getFrontend().getUrl()
+ "/im/install-confirm?session=" + sessionToken;
}
/**
* 确认安装接口
*/
@PostMapping("/confirm")
public Result<InstallResult> confirmInstall(
@RequestBody @Valid InstallConfirmRequest request,
HttpSession session
) {
// 1. 验证 Session Token
String savedToken = (String) session.getAttribute("install_session_token");
if (!request.getSessionToken().equals(savedToken)) {
return Result.error("无效的会话");
}
// 2. 获取 Session 中的安装信息
Long workspaceId = (Long) session.getAttribute("install_workspace_id");
String workspaceName = (String) session.getAttribute("install_workspace_name");
Long initiatorUserId = (Long) session.getAttribute("install_initiator_user_id");
Long timestamp = (Long) session.getAttribute("install_timestamp");
String sign = (String) session.getAttribute("install_sign");
if (workspaceId == null) {
return Result.error("安装会话已过期");
}
// 3. 检查租户是否已被绑定
if (bindingService.isTenantBound(request.getTenantId())) {
return Result.error("该租户已绑定到其他 IM 工作空间");
}
// 4. 检查工作空间是否已被绑定
if (bindingService.isWorkspaceBound(workspaceId)) {
return Result.error("该 IM 工作空间已绑定到其他租户");
}
// 5. 调用 IM 确认安装接口
InstallConfirmResponse response = imApiClient.confirmInstall(
workspaceId,
request.getTenantId().toString(),
initiatorUserId,
timestamp,
sign
);
// 6. 保存工作空间绑定
WorkspaceBinding binding = bindingService.createBinding(
request.getTenantId(),
workspaceId,
workspaceName,
response.getBotToken(),
response.getExpiresIn(),
initiatorUserId
);
// 7. 自动绑定安装者(如果有)
if (initiatorUserId != null && request.getCurrentUserId() != null) {
try {
imBindingService.createAutoBinding(
request.getCurrentUserId(),
request.getTenantId(),
initiatorUserId,
workspaceId
);
log.info("安装者自动绑定成功: oaUserId={}, imUserId={}",
request.getCurrentUserId(), initiatorUserId);
} catch (Exception e) {
// 自动绑定失败不影响安装流程
log.warn("安装者自动绑定失败: {}", e.getMessage());
}
}
// 8. 清理 Session
clearInstallSession(session);
return Result.success(new InstallResult(binding.getId(), "安装成功"));
}
private void clearInstallSession(HttpSession session) {
session.removeAttribute("install_workspace_id");
session.removeAttribute("install_workspace_name");
session.removeAttribute("install_initiator_user_id");
session.removeAttribute("install_timestamp");
session.removeAttribute("install_sign");
session.removeAttribute("install_session_token");
}
}
SSO 免登处理
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/api/im/sso")
@RequiredArgsConstructor
@Slf4j
public class ImSsoController {
private final ImApiClient imApiClient;
private final WorkspaceBindingService bindingService;
private final ImBindingService imBindingService;
private final UserService userService;
private final AppProperties appProperties;
/**
* SSO 入口接口(IM 跳转到此接口)
*/
@GetMapping("/entry")
public String handleSsoEntry(
@RequestParam("ticket") String ticket,
@RequestParam("workspace_id") Long workspaceId
) {
log.info("收到 SSO 请求: workspaceId={}", workspaceId);
String frontendUrl = appProperties.getFrontend().getUrl();
// 1. 获取工作空间绑定
WorkspaceBinding wsBinding = bindingService.getByWorkspaceId(workspaceId);
if (wsBinding == null) {
log.warn("工作空间未绑定: workspaceId={}", workspaceId);
return "redirect:" + frontendUrl + "/error?code=NOT_INSTALLED";
}
// 2. 验证 Ticket
TicketVerifyResponse verifyResponse;
try {
verifyResponse = imApiClient.verifyTicket(wsBinding.getBotToken(), ticket);
} catch (Exception e) {
log.error("Ticket 验证失败: {}", e.getMessage());
return "redirect:" + frontendUrl + "/error?code=TICKET_INVALID";
}
// 3. 查询用户绑定
ImBinding imBinding = imBindingService.getByImUserId(
verifyResponse.getImUserId(),
workspaceId
);
if (imBinding != null && imBinding.getBindStatus() == 1) {
// 已绑定,直接登录
User user = userService.getById(imBinding.getUserId());
String token = userService.generateToken(user);
log.info("SSO 登录成功: imUserId={}, oaUserId={}",
verifyResponse.getImUserId(), user.getId());
return "redirect:" + frontendUrl + "/?token=" + token;
} else {
// 未绑定,跳转到绑定页面
log.info("用户未绑定,跳转绑定页面: imUserId={}", verifyResponse.getImUserId());
return "redirect:" + frontendUrl + "/im/bind"
+ "?im_user_id=" + verifyResponse.getImUserId()
+ "&im_user_name=" + URLEncoder.encode(
verifyResponse.getImUserName(), StandardCharsets.UTF_8)
+ "&workspace_id=" + workspaceId;
}
}
/**
* 绑定并登录接口
*/
@PostMapping("/bind-with-login")
public Result<LoginResult> bindWithLogin(@RequestBody @Valid BindWithLoginRequest request) {
// 1. 验证用户身份
User user = userService.authenticate(request.getUsername(), request.getPassword());
if (user == null) {
return Result.error("用户名或密码错误");
}
// 2. 检查 OA 用户是否已绑定其他 IM 用户
ImBinding existingBinding = imBindingService.getByUserId(
user.getId(),
user.getTenantId()
);
if (existingBinding != null && existingBinding.getBindStatus() == 1) {
return Result.error("该 OA 账号已绑定其他 IM 用户");
}
// 3. 检查 IM 用户是否已绑定其他 OA 用户
ImBinding imUserBinding = imBindingService.getByImUserId(
request.getImUserId(),
request.getImWorkspaceId()
);
if (imUserBinding != null && imUserBinding.getBindStatus() == 1) {
return Result.error("该 IM 用户已绑定其他 OA 账号");
}
// 4. 创建绑定关系
imBindingService.createBinding(
user.getId(),
user.getTenantId(),
request.getImUserId(),
request.getImWorkspaceId(),
"manual"
);
// 5. 生成登录态
String token = userService.generateToken(user);
log.info("用户绑定成功: oaUserId={}, imUserId={}",
user.getId(), request.getImUserId());
return Result.success(new LoginResult(token, user.getId(), user.getUsername()));
}
}
消息推送服务
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class ImNotificationService {
private final ImApiClient imApiClient;
private final WorkspaceBindingService bindingService;
private final ImBindingService imBindingService;
/**
* 发送审批通知
*/
public void sendApprovalNotification(
Long userId,
Long tenantId,
String approvalType,
String approvalResult,
String remark
) {
String message = String.format(
"【审批通知】\n类型:%s\n结果:%s\n备注:%s",
approvalType, approvalResult, remark
);
sendNotification(userId, tenantId, message);
}
/**
* 发送待办提醒
*/
public void sendTodoReminder(Long userId, Long tenantId, String todoTitle) {
String message = String.format("【待办提醒】\n您有一条待办事项:%s", todoTitle);
sendNotification(userId, tenantId, message);
}
/**
* 发送通知到频道
*/
public void sendChannelAnnouncement(Long tenantId, Long channelId, String content) {
// 1. 获取 Bot Token
WorkspaceBinding wsBinding = bindingService.getByTenantId(tenantId);
if (wsBinding == null) {
log.warn("租户未安装应用: tenantId={}", tenantId);
return;
}
// 2. 发送频道消息
try {
BotMessageResponse response = imApiClient.sendChannelMessage(
wsBinding.getBotToken(),
channelId,
content
);
log.info("频道消息发送成功: channelId={}, messageId={}",
channelId, response.getMessageId());
} catch (Exception e) {
log.error("频道消息发送失败: channelId={}", channelId, e);
}
}
/**
* 批量发送通知
*/
public void sendBatchNotification(List<Long> userIds, Long tenantId, String message) {
for (Long userId : userIds) {
try {
sendNotification(userId, tenantId, message);
} catch (Exception e) {
log.error("批量通知发送失败: userId={}", userId, e);
}
}
}
private void sendNotification(Long userId, Long tenantId, String message) {
// 1. 查询用户绑定
ImBinding binding = imBindingService.getByUserId(userId, tenantId);
if (binding == null || binding.getBindStatus() != 1) {
log.debug("用户未绑定 IM: userId={}", userId);
return;
}
// 2. 获取 Bot Token
WorkspaceBinding wsBinding = bindingService.getByTenantId(tenantId);
if (wsBinding == null) {
log.warn("租户未安装应用: tenantId={}", tenantId);
return;
}
// 3. 发送消息
try {
BotMessageResponse response = imApiClient.sendMessage(
wsBinding.getBotToken(),
binding.getImWorkspaceId(),
binding.getImUserId(),
message
);
if ("sent".equals(response.getStatus())) {
log.debug("消息发送成功: userId={}", userId);
} else {
log.warn("消息发送失败: userId={}, error={}",
userId, response.getErrorMessage());
}
} catch (Exception e) {
log.error("消息发送异常: userId={}", userId, e);
}
}
}
业务集成示例
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
@Service
@RequiredArgsConstructor
public class LeaveApprovalService {
private final LeaveRepository leaveRepository;
private final ImNotificationService notificationService;
/**
* 提交请假申请
*/
@Transactional
public Leave submitLeave(LeaveRequest request, User currentUser) {
// 1. 保存请假记录
Leave leave = new Leave();
leave.setUserId(currentUser.getId());
leave.setTenantId(currentUser.getTenantId());
leave.setLeaveType(request.getLeaveType());
leave.setStartDate(request.getStartDate());
leave.setEndDate(request.getEndDate());
leave.setReason(request.getReason());
leave.setStatus("PENDING");
leaveRepository.save(leave);
// 2. 通知审批人
Long approverId = getApproverId(currentUser);
notificationService.sendApprovalNotification(
approverId,
currentUser.getTenantId(),
"请假申请",
"待审批",
String.format("%s 提交了请假申请,请及时处理", currentUser.getUsername())
);
return leave;
}
/**
* 审批请假
*/
@Transactional
public void approveLeave(Long leaveId, boolean approved, String remark, User approver) {
Leave leave = leaveRepository.findById(leaveId)
.orElseThrow(() -> new RuntimeException("请假记录不存在"));
// 1. 更新状态
leave.setStatus(approved ? "APPROVED" : "REJECTED");
leave.setApproverRemark(remark);
leave.setApproveTime(LocalDateTime.now());
leaveRepository.save(leave);
// 2. 通知申请人
notificationService.sendApprovalNotification(
leave.getUserId(),
leave.getTenantId(),
"请假申请",
approved ? "已通过" : "已拒绝",
remark
);
}
private Long getApproverId(User user) {
// 根据业务逻辑获取审批人ID
// 例如:获取用户的直属上级
return user.getManagerId();
}
}
绑定方式说明
| 绑定方式 | binding_method | 触发时机 | 说明 |
|---|---|---|---|
| 安装时自动绑定 | installer_auto | 应用安装时 | 安装者自动绑定,无需手动操作 |
| 手动绑定 | manual | 首次 SSO 登录时 | 其他用户需要手动登录并绑定 |
| OAuth 绑定 | oauth | OAuth 授权流程 | 通过标准 OAuth 流程绑定 |
测试验证
测试检查清单
安装流程:
- IM 跳转到 OA 回调接口
- OA 保存签名信息到 Session
- 用户登录并选择租户
- 调用 IM 确认安装接口成功
- workspace_bindings 表有记录
- 安装者自动绑定成功
SSO 免登:
- IM 跳转到 OA SSO 入口
- Ticket 验证成功
- 已绑定用户直接登录
- 未绑定用户跳转绑定页面
- 绑定成功后自动登录
消息推送:
- 获取用户绑定信息成功
- 获取 Bot Token 成功
- 消息发送成功
- IM 客户端收到消息
数据库验证
-- 检查工作空间绑定
SELECT * FROM workspace_bindings WHERE oa_tenant_id = 1;
-- 检查用户绑定
SELECT * FROM im_bindings WHERE tenant_id = 1;
-- 检查绑定方式分布
SELECT binding_method, COUNT(*)
FROM im_bindings
GROUP BY binding_method;
常见问题
Q: 安装失败,提示"签名验证失败"
检查以下几点:
- Client Secret 是否正确 - 检查配置的 clientSecret 是否与创建应用时获取的一致
- 时间戳格式 - 必须是秒级时间戳(10位数字),不是毫秒级
- 签名计算顺序 - 确保拼接顺序为
clientId + timestamp + clientSecret
排查方法:
// 打印签名内容进行调试
String signContent = clientId + timestamp + clientSecret;
System.out.println("signContent: " + signContent);
System.out.println("sign: " + SecureUtil.sha256(signContent));
Q: SSO 登录后提示"Ticket 无效"
可能原因:
- Ticket 已过期 - Ticket 有效期为 5 分钟
- Ticket 已被使用 - Ticket 是一次性的,不能重复使用
- Bot Token 无效 - 检查 Bot Token 是否过期
Q: 消息推送失败
检查以下几点:
- 用户是否已绑定 - 查询 im_bindings 表确认绑定关系
- Bot Token 是否有效 - 检查 workspace_bindings 表中的 token 过期时间
- 目标用户 ID 是否正确 - 确保使用的是 IM 用户ID,不是 OA 用户ID
Q: 如何处理 Bot Token 过期?
Bot Token 默认有效期为 1 年。过期后需要:
- 用户重新在 IM 中安装应用
- 或者调用刷新 Token 接口(如果支持)
建议在 Token 过期前提醒管理员重新安装。
Q: 多租户场景下如何选择正确的租户?
在安装流程中:
- 用户登录后,获取其所属的所有租户列表
- 如果只有一个租户,自动选择
- 如果有多个租户,让用户选择要绑定的租户
- 绑定前检查该租户是否已被其他工作空间绑定