diff --git a/backend/mci-channel-common/pom.xml b/backend/mci-channel-common/pom.xml
new file mode 100644
index 0000000..a9d528c
--- /dev/null
+++ b/backend/mci-channel-common/pom.xml
@@ -0,0 +1,22 @@
+
+
+ 4.0.0
+
+ com.sino
+ sino-mci-parent
+ 0.0.1-SNAPSHOT
+
+ mci-channel-common
+ mci-channel-common
+ Channel adapter SPI and shared models
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/CallbackPayload.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/CallbackPayload.java
new file mode 100644
index 0000000..e8b99ef
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/CallbackPayload.java
@@ -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 normalized;
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitRequest.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitRequest.java
new file mode 100644
index 0000000..74346be
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitRequest.java
@@ -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 config;
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitResult.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitResult.java
new file mode 100644
index 0000000..9bbe5cc
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/ChannelInitResult.java
@@ -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 extra;
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageRequest.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageRequest.java
new file mode 100644
index 0000000..1e92dec
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageRequest.java
@@ -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 content;
+ private Map extra;
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageResult.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageResult.java
new file mode 100644
index 0000000..8ba8740
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/dto/SendMessageResult.java
@@ -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;
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/AccountType.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/AccountType.java
new file mode 100644
index 0000000..ca96363
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/AccountType.java
@@ -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;
+ }
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/ChannelType.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/ChannelType.java
new file mode 100644
index 0000000..a4e1e15
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/model/ChannelType.java
@@ -0,0 +1,9 @@
+package com.sino.mci.channel.common.model;
+
+public enum ChannelType {
+ WECOM,
+ WECHAT_PERSONAL,
+ DINGTALK,
+ FEISHU,
+ IVR
+}
diff --git a/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/spi/ChannelAdapter.java b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/spi/ChannelAdapter.java
new file mode 100644
index 0000000..cca5bd2
--- /dev/null
+++ b/backend/mci-channel-common/src/main/java/com/sino/mci/channel/common/spi/ChannelAdapter.java
@@ -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);
+}
diff --git a/backend/mci-server/pom.xml b/backend/mci-server/pom.xml
index 8b8b8e4..d279cc6 100644
--- a/backend/mci-server/pom.xml
+++ b/backend/mci-server/pom.xml
@@ -16,6 +16,10 @@
com.sino
mci-shared
+
+ com.sino
+ mci-channel-common
+
org.springframework.boot
spring-boot-starter-web
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java
new file mode 100644
index 0000000..f976a73
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelAccountController.java
@@ -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));
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java
new file mode 100644
index 0000000..f3b2df4
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ChannelSubjectController.java
@@ -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));
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java
new file mode 100644
index 0000000..145a719
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/WeComCallbackController.java
@@ -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 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";
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountInitRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountInitRequest.java
new file mode 100644
index 0000000..0952b6e
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/ChannelAccountInitRequest.java
@@ -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 config;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelAccountRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelAccountRequest.java
new file mode 100644
index 0000000..69a9703
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelAccountRequest.java
@@ -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;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java
new file mode 100644
index 0000000..d8182f6
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateChannelSubjectRequest.java
@@ -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;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccount.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccount.java
new file mode 100644
index 0000000..328e27a
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccount.java
@@ -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;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java
new file mode 100644
index 0000000..a6f0ef2
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelSubject.java
@@ -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;
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountMapper.java
new file mode 100644
index 0000000..f0874b6
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountMapper.java
@@ -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 {
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelSubjectMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelSubjectMapper.java
new file mode 100644
index 0000000..46f9f41
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelSubjectMapper.java
@@ -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 {
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
new file mode 100644
index 0000000..dfd14d5
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelAccountService.java
@@ -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()
+ .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());
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java
new file mode 100644
index 0000000..53b8c88
--- /dev/null
+++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ChannelSubjectService.java
@@ -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()
+ .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;
+ }
+}
diff --git a/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java b/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java
index 100f710..b6fd46a 100644
--- a/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java
+++ b/backend/mci-server/src/main/java/com/sino/mci/config/MybatisPlusConfig.java
@@ -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
diff --git a/backend/mci-server/src/main/resources/db/migration/V3__init_channel.sql b/backend/mci-server/src/main/resources/db/migration/V3__init_channel.sql
new file mode 100644
index 0000000..d1264ee
--- /dev/null
+++ b/backend/mci-server/src/main/resources/db/migration/V3__init_channel.sql
@@ -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);
diff --git a/backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelControllerTest.java b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelControllerTest.java
new file mode 100644
index 0000000..ac83bb6
--- /dev/null
+++ b/backend/mci-server/src/test/java/com/sino/mci/channel/controller/ChannelControllerTest.java
@@ -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"));
+ }
+}