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")); + } +}