feat(channel): add channel-common SPI, channel tables, subject/account APIs and WeCom callback placeholder

This commit is contained in:
2026-07-07 11:55:53 +08:00
parent 5828404418
commit 855f77595d
25 changed files with 604 additions and 1 deletions

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.sino</groupId>
<artifactId>sino-mci-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>mci-channel-common</artifactId>
<name>mci-channel-common</name>
<description>Channel adapter SPI and shared models</description>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,14 @@
package com.sino.mci.channel.common.dto;
import lombok.Data;
import java.util.Map;
@Data
public class CallbackPayload {
private String channelType;
private String eventType;
private String rawBody;
private Map<String, Object> normalized;
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.channel.common.dto;
import com.sino.mci.channel.common.model.AccountType;
import com.sino.mci.channel.common.model.ChannelType;
import lombok.Data;
import java.util.Map;
@Data
public class ChannelInitRequest {
private ChannelType channelType;
private AccountType accountType;
private Long subjectId;
private Map<String, Object> config;
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.channel.common.dto;
import lombok.Data;
import java.util.Map;
@Data
public class ChannelInitResult {
private boolean success;
private String accountId;
private String accountName;
private String qrCodeUrl;
private String message;
private Map<String, Object> extra;
}

View File

@@ -0,0 +1,15 @@
package com.sino.mci.channel.common.dto;
import lombok.Data;
import java.util.Map;
@Data
public class SendMessageRequest {
private String conversationId;
private String receiverId;
private String contentType;
private Map<String, Object> content;
private Map<String, Object> extra;
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.channel.common.dto;
import lombok.Data;
@Data
public class SendMessageResult {
private boolean success;
private String channelMessageId;
private String message;
}

View File

@@ -0,0 +1,34 @@
package com.sino.mci.channel.common.model;
public enum AccountType {
WECOM_INTERNAL(1, "企微内部账号"),
WECOM_AGENT(2, "企微应用"),
WECHAT_PERSONAL(3, "pywechat 个人号"),
DINGTALK_BOT(4, "钉钉机器人"),
FEISHU_BOT(5, "飞书机器人");
private final int code;
private final String label;
AccountType(int code, String label) {
this.code = code;
this.label = label;
}
public int getCode() {
return code;
}
public String getLabel() {
return label;
}
public static AccountType of(int code) {
for (AccountType type : values()) {
if (type.code == code) {
return type;
}
}
return null;
}
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.channel.common.model;
public enum ChannelType {
WECOM,
WECHAT_PERSONAL,
DINGTALK,
FEISHU,
IVR
}

View File

@@ -0,0 +1,19 @@
package com.sino.mci.channel.common.spi;
import com.sino.mci.channel.common.dto.CallbackPayload;
import com.sino.mci.channel.common.dto.ChannelInitRequest;
import com.sino.mci.channel.common.dto.ChannelInitResult;
import com.sino.mci.channel.common.dto.SendMessageRequest;
import com.sino.mci.channel.common.dto.SendMessageResult;
import com.sino.mci.channel.common.model.ChannelType;
public interface ChannelAdapter {
ChannelType getChannelType();
ChannelInitResult initAccount(ChannelInitRequest request);
SendMessageResult sendMessage(SendMessageRequest request);
CallbackPayload parseCallback(String rawBody);
}

View File

@@ -16,6 +16,10 @@
<groupId>com.sino</groupId>
<artifactId>mci-shared</artifactId>
</dependency>
<dependency>
<groupId>com.sino</groupId>
<artifactId>mci-channel-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

@@ -0,0 +1,31 @@
package com.sino.mci.channel.controller;
import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/channel-account")
@RequiredArgsConstructor
public class ChannelAccountController {
private final ChannelAccountService accountService;
@PostMapping
public ApiResponse<?> createAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody CreateChannelAccountRequest request) {
return ApiResponse.ok(accountService.createAccount(tenantCode, request));
}
@PostMapping("/init")
public ApiResponse<?> initAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody ChannelAccountInitRequest request) {
return ApiResponse.ok(accountService.initAccount(tenantCode, request));
}
}

View File

@@ -0,0 +1,23 @@
package com.sino.mci.channel.controller;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.service.ChannelSubjectService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/channel-subject")
@RequiredArgsConstructor
public class ChannelSubjectController {
private final ChannelSubjectService subjectService;
@PostMapping
public ApiResponse<?> createSubject(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody CreateChannelSubjectRequest request) {
return ApiResponse.ok(subjectService.createSubject(tenantCode, request));
}
}

View File

@@ -0,0 +1,32 @@
package com.sino.mci.channel.controller;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.Enumeration;
@Slf4j
@RestController
@RequestMapping("/callback/wecom")
public class WeComCallbackController {
@GetMapping
public String verifyUrl(HttpServletRequest request) {
log.info("WeCom callback URL verification: {}", request.getQueryString());
return request.getParameter("echostr") != null ? request.getParameter("echostr") : "success";
}
@PostMapping
public String receiveCallback(@RequestBody String body, HttpServletRequest request) {
log.info("WeCom callback received, body length={}, query={}", body.length(), request.getQueryString());
log.debug("WeCom callback body: {}", body);
Enumeration<String> headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String name = headerNames.nextElement();
log.debug("Header {}: {}", name, request.getHeader(name));
}
// Phase 2: only accept and acknowledge; event parsing and session archive pull will be implemented later
return "success";
}
}

View File

@@ -0,0 +1,21 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.Map;
@Data
public class ChannelAccountInitRequest {
@NotBlank
private String channelType;
@NotNull
private Integer accountType;
private Long subjectId;
private Map<String, Object> config;
}

View File

@@ -0,0 +1,25 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class CreateChannelAccountRequest {
@NotNull
private Integer accountType;
@NotBlank
private String channelType;
private Long subjectId;
@NotBlank
private String accountId;
private String accountName;
private Integer sessionArchiveEnabled;
private String serverHost;
private String extInfo;
}

View File

@@ -0,0 +1,24 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateChannelSubjectRequest {
@NotBlank
private String channelType;
@NotBlank
private String subjectCode;
@NotBlank
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;
private String sessionArchivePrivateKey;
}

View File

@@ -0,0 +1,29 @@
package com.sino.mci.channel.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.sino.mci.shared.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mci_channel_account")
public class ChannelAccount extends BaseEntity {
private String tenantCode;
private Integer accountType;
private String channelType;
private Long subjectId;
private String accountId;
private String accountName;
private Integer sessionArchiveEnabled;
private String serverHost;
private Integer loginStatus;
private Integer onlineStatus;
private Integer riskLevel;
private String extInfo;
private Integer status;
private LocalDateTime lastHeartbeat;
}

View File

@@ -0,0 +1,24 @@
package com.sino.mci.channel.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import com.sino.mci.shared.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mci_channel_subject")
public class ChannelSubject extends BaseEntity {
private String tenantCode;
private String channelType;
private String subjectCode;
private String subjectName;
private String corpId;
private String corpSecret;
private String agentId;
private String agentSecret;
private Integer sessionArchiveEnabled;
private String sessionArchivePrivateKey;
private Integer status;
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.channel.entity.ChannelAccount;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ChannelAccountMapper extends BaseMapper<ChannelAccount> {
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.channel.repository;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.sino.mci.channel.entity.ChannelSubject;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ChannelSubjectMapper extends BaseMapper<ChannelSubject> {
}

View File

@@ -0,0 +1,62 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ChannelAccountService {
private final ChannelAccountMapper accountMapper;
private final ChannelSubjectMapper subjectMapper;
@Transactional
public ChannelAccount createAccount(String tenantCode, CreateChannelAccountRequest request) {
if (request.getSubjectId() != null) {
ChannelSubject subject = subjectMapper.selectById(request.getSubjectId());
if (subject == null) {
throw new BusinessException("渠道主体不存在");
}
}
ChannelAccount existing = accountMapper.selectOne(
new LambdaQueryWrapper<ChannelAccount>()
.eq(ChannelAccount::getTenantCode, tenantCode)
.eq(ChannelAccount::getChannelType, request.getChannelType())
.eq(ChannelAccount::getAccountId, request.getAccountId()));
if (existing != null) {
throw new BusinessException("渠道账号已存在: " + request.getAccountId());
}
ChannelAccount account = new ChannelAccount();
account.setTenantCode(tenantCode);
account.setAccountType(request.getAccountType());
account.setChannelType(request.getChannelType());
account.setSubjectId(request.getSubjectId());
account.setAccountId(request.getAccountId());
account.setAccountName(request.getAccountName());
account.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
account.setServerHost(request.getServerHost());
account.setExtInfo(request.getExtInfo());
account.setLoginStatus(0);
account.setOnlineStatus(0);
account.setRiskLevel(0);
account.setStatus(1);
accountMapper.insert(account);
return account;
}
public ChannelAccount initAccount(String tenantCode, ChannelAccountInitRequest request) {
// Phase 2 placeholder: actual adapter dispatch will be implemented later
throw new BusinessException("渠道账号初始化适配器尚未实现: " + request.getChannelType());
}
}

View File

@@ -0,0 +1,44 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ChannelSubjectService {
private final ChannelSubjectMapper subjectMapper;
@Transactional
public ChannelSubject createSubject(String tenantCode, CreateChannelSubjectRequest request) {
ChannelSubject existing = subjectMapper.selectOne(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getTenantCode, tenantCode)
.eq(ChannelSubject::getChannelType, request.getChannelType())
.eq(ChannelSubject::getSubjectCode, request.getSubjectCode()));
if (existing != null) {
throw new BusinessException("渠道主体已存在: " + request.getSubjectCode());
}
ChannelSubject subject = new ChannelSubject();
subject.setTenantCode(tenantCode);
subject.setChannelType(request.getChannelType());
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
subject.setSessionArchivePrivateKey(request.getSessionArchivePrivateKey());
subject.setStatus(1);
subjectMapper.insert(subject);
return subject;
}
}

View File

@@ -11,7 +11,7 @@ import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
@MapperScan("com.sino.mci.admin.repository")
@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository"})
public class MybatisPlusConfig {
@Bean

View File

@@ -0,0 +1,44 @@
CREATE TABLE IF NOT EXISTS mci_channel_subject (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_code VARCHAR(64) COMMENT '所属租户,为空表示平台级共享主体',
channel_type VARCHAR(32) NOT NULL COMMENT '渠道类型wecom / dingtalk / feishu',
subject_code VARCHAR(64) NOT NULL COMMENT '主体编码',
subject_name VARCHAR(128) NOT NULL COMMENT '主体名称',
corp_id VARCHAR(128) COMMENT '企微 corp_id企微用',
corp_secret VARCHAR(512) COMMENT '通讯录 secret企微用',
agent_id VARCHAR(64) COMMENT '应用 agent_id企微用',
agent_secret VARCHAR(512) COMMENT '应用 secret企微用',
session_archive_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否开通会话存档能力0否 1是',
session_archive_private_key TEXT COMMENT '会话存档 RSA 私钥',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_tenant_channel_subject (tenant_code, channel_type, subject_code, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道主体表';
CREATE TABLE IF NOT EXISTS mci_channel_account (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户',
account_type TINYINT NOT NULL COMMENT '1企微内部账号 2企微应用 3pywechat个人号 4钉钉机器人 5飞书机器人',
channel_type VARCHAR(32) NOT NULL COMMENT 'wecom / wechat_personal / dingtalk / feishu',
subject_id BIGINT COMMENT '所属渠道主体 mci_channel_subject',
account_id VARCHAR(128) COMMENT '账号唯一标识:企微员工=userid企微应用=agent_id个微=wxid/bot_code机器人=robot_id',
account_name VARCHAR(128) COMMENT '账号名称',
session_archive_enabled TINYINT NOT NULL DEFAULT 0 COMMENT '是否开启会话存档(企微内部账号用)',
server_host VARCHAR(128) COMMENT '所在服务器pywechat 用)',
login_status TINYINT NOT NULL DEFAULT 0 COMMENT '登录状态0未登录 1登录中 2已登录',
online_status TINYINT NOT NULL DEFAULT 0 COMMENT '在线状态0离线 1在线',
risk_level TINYINT NOT NULL DEFAULT 0 COMMENT '风险等级',
ext_info JSON COMMENT '渠道扩展字段',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态0禁用 1启用',
last_heartbeat DATETIME COMMENT '最后心跳',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_tenant_channel_account (tenant_code, channel_type, account_id, deleted),
KEY idx_subject_id (subject_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道账号表';
ALTER TABLE mci_channel_account
ADD CONSTRAINT fk_channel_account_subject_id FOREIGN KEY (subject_id) REFERENCES mci_channel_subject (id);

View File

@@ -0,0 +1,66 @@
package com.sino.mci.channel.controller;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
public class ChannelControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void shouldCreateChannelSubjectAndAccount() throws Exception {
String tenantCode = "channel-" + UUID.randomUUID().toString().substring(0, 8);
String tenantPayload = String.format("{\"tenantCode\":\"%s\",\"tenantName\":\"渠道测试\"}", tenantCode);
mockMvc.perform(post("/msg-platform/api/v1/account/tenant")
.contextPath("/msg-platform")
.contentType(MediaType.APPLICATION_JSON)
.content(tenantPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0));
String subjectPayload = "{\"channelType\":\"wecom\",\"subjectCode\":\"sino\",\"subjectName\":\"Sino\"}";
mockMvc.perform(post("/msg-platform/api/v1/channel-subject")
.contextPath("/msg-platform")
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(subjectPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.channelType").value("wecom"));
String accountPayload = "{\"accountType\":1,\"channelType\":\"wecom\",\"accountId\":\"ZhangSan\",\"accountName\":\"张三\"}";
mockMvc.perform(post("/msg-platform/api/v1/channel-account")
.contextPath("/msg-platform")
.header("X-Tenant-Code", tenantCode)
.contentType(MediaType.APPLICATION_JSON)
.content(accountPayload))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.accountId").value("ZhangSan"));
}
@Test
void shouldReturnEchoStrForWeComCallbackVerification() throws Exception {
mockMvc.perform(get("/msg-platform/callback/wecom")
.contextPath("/msg-platform")
.param("echostr", "hello"))
.andExpect(status().isOk())
.andExpect(result -> result.getResponse().getContentAsString().equals("hello"));
}
}