From 3f2c0d7d16ee15bb149db6479d8bf3a9a2e4e1b8 Mon Sep 17 00:00:00 2001 From: marsal Date: Tue, 7 Jul 2026 12:06:19 +0800 Subject: [PATCH] feat(crm): add person, identity, conversation, tag tables and APIs --- .../controller/ConversationController.java | 50 ++++++ .../dto/BindConversationAccountRequest.java | 16 ++ .../dto/BindConversationTagsRequest.java | 13 ++ .../dto/CreateConversationRequest.java | 26 +++ .../entity/ChannelAccountConversation.java | 18 +++ .../sino/mci/channel/entity/Conversation.java | 28 ++++ .../entity/ConversationParticipant.java | 23 +++ .../entity/ConversationTagBinding.java | 21 +++ .../ChannelAccountConversationMapper.java | 9 ++ .../repository/ConversationMapper.java | 9 ++ .../ConversationParticipantMapper.java | 9 ++ .../ConversationTagBindingMapper.java | 9 ++ .../channel/service/ConversationService.java | 120 ++++++++++++++ .../sino/mci/config/MybatisPlusConfig.java | 2 +- .../mci/crm/controller/PersonController.java | 57 +++++++ .../mci/crm/controller/TagController.java | 28 ++++ .../com/sino/mci/crm/dto/BindTagsRequest.java | 13 ++ .../mci/crm/dto/CreateIdentityRequest.java | 23 +++ .../sino/mci/crm/dto/CreatePersonRequest.java | 22 +++ .../sino/mci/crm/dto/CreateTagRequest.java | 17 ++ .../sino/mci/crm/entity/ChannelIdentity.java | 21 +++ .../java/com/sino/mci/crm/entity/Person.java | 21 +++ .../sino/mci/crm/entity/PersonTagBinding.java | 21 +++ .../java/com/sino/mci/crm/entity/Tag.java | 20 +++ .../crm/repository/ChannelIdentityMapper.java | 9 ++ .../sino/mci/crm/repository/PersonMapper.java | 9 ++ .../repository/PersonTagBindingMapper.java | 9 ++ .../sino/mci/crm/repository/TagMapper.java | 9 ++ .../sino/mci/crm/service/PersonService.java | 121 ++++++++++++++ .../com/sino/mci/crm/service/TagService.java | 64 ++++++++ .../V4__init_person_conversation_tag.sql | 150 ++++++++++++++++++ .../mci/crm/controller/CrmControllerTest.java | 74 +++++++++ backend/mci-shared/pom.xml | 8 + .../com/sino/mci/shared/util/JsonUtils.java | 38 +++++ 34 files changed, 1086 insertions(+), 1 deletion(-) create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationTagsRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccountConversation.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/entity/Conversation.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationParticipant.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationTagBinding.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountConversationMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationParticipantMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationTagBindingMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/controller/TagController.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/dto/BindTagsRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateIdentityRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreatePersonRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateTagRequest.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/entity/ChannelIdentity.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/entity/Person.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/entity/PersonTagBinding.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/entity/Tag.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/repository/ChannelIdentityMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonTagBindingMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/repository/TagMapper.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java create mode 100644 backend/mci-server/src/main/java/com/sino/mci/crm/service/TagService.java create mode 100644 backend/mci-server/src/main/resources/db/migration/V4__init_person_conversation_tag.sql create mode 100644 backend/mci-server/src/test/java/com/sino/mci/crm/controller/CrmControllerTest.java create mode 100644 backend/mci-shared/src/main/java/com/sino/mci/shared/util/JsonUtils.java diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java new file mode 100644 index 0000000..3b21773 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/controller/ConversationController.java @@ -0,0 +1,50 @@ +package com.sino.mci.channel.controller; + +import com.sino.mci.channel.dto.BindConversationAccountRequest; +import com.sino.mci.channel.dto.BindConversationTagsRequest; +import com.sino.mci.channel.dto.CreateConversationRequest; +import com.sino.mci.channel.service.ConversationService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/conversation") +@RequiredArgsConstructor +public class ConversationController { + + private final ConversationService conversationService; + + @PostMapping + public ApiResponse createConversation( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody CreateConversationRequest request) { + return ApiResponse.ok(conversationService.createConversation(tenantCode, request)); + } + + @GetMapping("/{conversation_id}") + public ApiResponse getConversation( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("conversation_id") Long conversationId) { + return ApiResponse.ok(conversationService.getConversation(tenantCode, conversationId)); + } + + @PostMapping("/{conversation_id}/bind-account") + public ApiResponse bindAccount( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("conversation_id") Long conversationId, + @Valid @RequestBody BindConversationAccountRequest request) { + conversationService.bindAccount(tenantCode, conversationId, request); + return ApiResponse.ok(); + } + + @PostMapping("/{conversation_id}/bind-tags") + public ApiResponse bindTags( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("conversation_id") Long conversationId, + @Valid @RequestBody BindConversationTagsRequest request) { + conversationService.bindTags(tenantCode, conversationId, request); + return ApiResponse.ok(); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java new file mode 100644 index 0000000..53cf4a8 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationAccountRequest.java @@ -0,0 +1,16 @@ +package com.sino.mci.channel.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class BindConversationAccountRequest { + + @NotNull + private Long accountId; + + @NotNull + private Integer bindingType; + + private Integer sortOrder; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationTagsRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationTagsRequest.java new file mode 100644 index 0000000..03622dc --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/BindConversationTagsRequest.java @@ -0,0 +1,13 @@ +package com.sino.mci.channel.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class BindConversationTagsRequest { + + @NotEmpty + private List tagIds; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java new file mode 100644 index 0000000..893b0fb --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/dto/CreateConversationRequest.java @@ -0,0 +1,26 @@ +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 CreateConversationRequest { + + @NotNull + private Integer conversationType; + + @NotBlank + private String channelType; + + @NotBlank + private String channelConversationId; + + private Long ownerAccountId; + private Long subjectId; + private String conversationName; + private Integer createSource; + private Map receiveConfig; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccountConversation.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccountConversation.java new file mode 100644 index 0000000..344660a --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ChannelAccountConversation.java @@ -0,0 +1,18 @@ +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_account_conversation") +public class ChannelAccountConversation extends BaseEntity { + + private Long accountId; + private Long conversationId; + private Integer bindingType; + private Integer sortOrder; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/Conversation.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/Conversation.java new file mode 100644 index 0000000..ae775a7 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/Conversation.java @@ -0,0 +1,28 @@ +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_conversation") +public class Conversation extends BaseEntity { + + private String tenantCode; + private Integer conversationType; + private String channelType; + private String channelConversationId; + private Long ownerAccountId; + private Long subjectId; + private String conversationName; + private Integer createSource; + private Long createTaskId; + private Long institutionId; + private Long supplierId; + private Long contractId; + private String channelCode; + private String receiveConfig; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationParticipant.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationParticipant.java new file mode 100644 index 0000000..b50cbb4 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationParticipant.java @@ -0,0 +1,23 @@ +package com.sino.mci.channel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("mci_conversation_participant") +public class ConversationParticipant { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long conversationId; + private Long personId; + private Long channelIdentityId; + private Integer participantType; + private LocalDateTime joinTime; + private LocalDateTime createTime; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationTagBinding.java b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationTagBinding.java new file mode 100644 index 0000000..61cf603 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/entity/ConversationTagBinding.java @@ -0,0 +1,21 @@ +package com.sino.mci.channel.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("mci_conversation_tag_binding") +public class ConversationTagBinding { + + @TableId(type = IdType.AUTO) + private Long id; + + private String tenantCode; + private Long tagId; + private Long conversationId; + private LocalDateTime createTime; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountConversationMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountConversationMapper.java new file mode 100644 index 0000000..3cad174 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ChannelAccountConversationMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.channel.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.channel.entity.ChannelAccountConversation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ChannelAccountConversationMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationMapper.java new file mode 100644 index 0000000..28628c5 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.channel.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.channel.entity.Conversation; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ConversationMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationParticipantMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationParticipantMapper.java new file mode 100644 index 0000000..aaea9d5 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationParticipantMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.channel.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.channel.entity.ConversationParticipant; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ConversationParticipantMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationTagBindingMapper.java b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationTagBindingMapper.java new file mode 100644 index 0000000..4dd2a6d --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/repository/ConversationTagBindingMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.channel.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.channel.entity.ConversationTagBinding; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ConversationTagBindingMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java new file mode 100644 index 0000000..b45c6ed --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/channel/service/ConversationService.java @@ -0,0 +1,120 @@ +package com.sino.mci.channel.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.channel.dto.BindConversationAccountRequest; +import com.sino.mci.channel.dto.BindConversationTagsRequest; +import com.sino.mci.channel.dto.CreateConversationRequest; +import com.sino.mci.channel.entity.ChannelAccountConversation; +import com.sino.mci.channel.entity.Conversation; +import com.sino.mci.channel.entity.ConversationTagBinding; +import com.sino.mci.channel.repository.ChannelAccountConversationMapper; +import com.sino.mci.channel.repository.ConversationMapper; +import com.sino.mci.channel.repository.ConversationTagBindingMapper; +import com.sino.mci.crm.entity.Tag; +import com.sino.mci.crm.repository.TagMapper; +import com.sino.mci.exception.BusinessException; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ConversationService { + + private final ConversationMapper conversationMapper; + private final ChannelAccountConversationMapper bindingMapper; + private final ConversationTagBindingMapper conversationTagBindingMapper; + private final TagMapper tagMapper; + + @Transactional + public Conversation createConversation(String tenantCode, CreateConversationRequest request) { + Conversation existing = conversationMapper.selectOne( + new LambdaQueryWrapper() + .eq(Conversation::getTenantCode, tenantCode) + .eq(Conversation::getChannelType, request.getChannelType()) + .eq(Conversation::getChannelConversationId, request.getChannelConversationId())); + if (existing != null) { + throw new BusinessException("会话已存在: " + request.getChannelConversationId()); + } + + Conversation conversation = new Conversation(); + conversation.setTenantCode(tenantCode); + conversation.setConversationType(request.getConversationType()); + conversation.setChannelType(request.getChannelType()); + conversation.setChannelConversationId(request.getChannelConversationId()); + conversation.setOwnerAccountId(request.getOwnerAccountId()); + conversation.setSubjectId(request.getSubjectId()); + conversation.setConversationName(request.getConversationName()); + conversation.setCreateSource(request.getCreateSource() != null ? request.getCreateSource() : 2); + conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig())); + conversation.setStatus(1); + conversationMapper.insert(conversation); + return conversation; + } + + public Conversation getConversation(String tenantCode, Long conversationId) { + Conversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null || !tenantCode.equals(conversation.getTenantCode())) { + throw new BusinessException("会话不存在"); + } + return conversation; + } + + @Transactional + public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) { + getConversation(tenantCode, conversationId); + + ChannelAccountConversation existing = bindingMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelAccountConversation::getAccountId, request.getAccountId()) + .eq(ChannelAccountConversation::getConversationId, conversationId) + .eq(ChannelAccountConversation::getBindingType, request.getBindingType())); + if (existing != null) { + throw new BusinessException("该账号已绑定此会话的相同类型"); + } + + ChannelAccountConversation binding = new ChannelAccountConversation(); + binding.setAccountId(request.getAccountId()); + binding.setConversationId(conversationId); + binding.setBindingType(request.getBindingType()); + binding.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + binding.setStatus(1); + bindingMapper.insert(binding); + } + + @Transactional + public void bindTags(String tenantCode, Long conversationId, BindConversationTagsRequest request) { + getConversation(tenantCode, conversationId); + for (Long tagId : request.getTagIds()) { + Tag tag = tagMapper.selectById(tagId); + if (tag == null || !tenantCode.equals(tag.getTenantCode())) { + throw new BusinessException("标签不存在: " + tagId); + } + } + + conversationTagBindingMapper.delete( + new LambdaQueryWrapper() + .eq(ConversationTagBinding::getConversationId, conversationId)); + + if (!CollectionUtils.isEmpty(request.getTagIds())) { + List bindings = request.getTagIds().stream() + .distinct() + .map(tagId -> { + ConversationTagBinding binding = new ConversationTagBinding(); + binding.setTenantCode(tenantCode); + binding.setTagId(tagId); + binding.setConversationId(conversationId); + return binding; + }) + .collect(Collectors.toList()); + for (ConversationTagBinding binding : bindings) { + conversationTagBindingMapper.insert(binding); + } + } + } +} 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 b6fd46a..390b95e 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", "com.sino.mci.channel.repository"}) +@MapperScan({"com.sino.mci.admin.repository", "com.sino.mci.channel.repository", "com.sino.mci.crm.repository"}) public class MybatisPlusConfig { @Bean diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java b/backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java new file mode 100644 index 0000000..2deda72 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/controller/PersonController.java @@ -0,0 +1,57 @@ +package com.sino.mci.crm.controller; + +import com.sino.mci.crm.dto.BindTagsRequest; +import com.sino.mci.crm.dto.CreateIdentityRequest; +import com.sino.mci.crm.dto.CreatePersonRequest; +import com.sino.mci.crm.service.PersonService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/person") +@RequiredArgsConstructor +public class PersonController { + + private final PersonService personService; + + @PostMapping + public ApiResponse createPerson( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody CreatePersonRequest request) { + return ApiResponse.ok(personService.createPerson(tenantCode, request)); + } + + @GetMapping("/{person_id}") + public ApiResponse getPerson( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("person_id") Long personId) { + return ApiResponse.ok(personService.getPerson(tenantCode, personId)); + } + + @PostMapping("/{person_id}/identities") + public ApiResponse createIdentity( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("person_id") Long personId, + @Valid @RequestBody CreateIdentityRequest request) { + request.setPersonId(personId); + return ApiResponse.ok(personService.createIdentity(tenantCode, request)); + } + + @GetMapping("/{person_id}/identities") + public ApiResponse listIdentities( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("person_id") Long personId) { + return ApiResponse.ok(personService.listIdentities(tenantCode, personId)); + } + + @PostMapping("/{person_id}/bind-tags") + public ApiResponse bindTags( + @RequestHeader("X-Tenant-Code") String tenantCode, + @PathVariable("person_id") Long personId, + @Valid @RequestBody BindTagsRequest request) { + personService.bindTags(tenantCode, personId, request); + return ApiResponse.ok(); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/controller/TagController.java b/backend/mci-server/src/main/java/com/sino/mci/crm/controller/TagController.java new file mode 100644 index 0000000..bdc1eae --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/controller/TagController.java @@ -0,0 +1,28 @@ +package com.sino.mci.crm.controller; + +import com.sino.mci.crm.dto.CreateTagRequest; +import com.sino.mci.crm.service.TagService; +import com.sino.mci.shared.web.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/tag") +@RequiredArgsConstructor +public class TagController { + + private final TagService tagService; + + @PostMapping + public ApiResponse createTag( + @RequestHeader("X-Tenant-Code") String tenantCode, + @Valid @RequestBody CreateTagRequest request) { + return ApiResponse.ok(tagService.createTag(tenantCode, request)); + } + + @GetMapping("/list") + public ApiResponse listTags(@RequestHeader("X-Tenant-Code") String tenantCode) { + return ApiResponse.ok(tagService.listTags(tenantCode)); + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/dto/BindTagsRequest.java b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/BindTagsRequest.java new file mode 100644 index 0000000..525fe5f --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/BindTagsRequest.java @@ -0,0 +1,13 @@ +package com.sino.mci.crm.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class BindTagsRequest { + + @NotEmpty + private List tagIds; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateIdentityRequest.java b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateIdentityRequest.java new file mode 100644 index 0000000..f79c466 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateIdentityRequest.java @@ -0,0 +1,23 @@ +package com.sino.mci.crm.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +@Data +public class CreateIdentityRequest { + + private Long personId; + + @NotBlank + private String channelType; + + @NotBlank + private String channelUserId; + + private String channelUserName; + private Long subjectId; + private Map extInfo; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreatePersonRequest.java b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreatePersonRequest.java new file mode 100644 index 0000000..5495fd3 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreatePersonRequest.java @@ -0,0 +1,22 @@ +package com.sino.mci.crm.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CreatePersonRequest { + + private String personCode; + + @NotBlank + private String personName; + + private String phone; + + @NotNull + private Integer personType; + + private Long institutionId; + private Long supplierId; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateTagRequest.java b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateTagRequest.java new file mode 100644 index 0000000..9d8ec3c --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/dto/CreateTagRequest.java @@ -0,0 +1,17 @@ +package com.sino.mci.crm.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Data +public class CreateTagRequest { + + @NotNull + private Long parentId; + + @NotBlank + private String tagName; + + private String tagSource; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/entity/ChannelIdentity.java b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/ChannelIdentity.java new file mode 100644 index 0000000..692f1ba --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/ChannelIdentity.java @@ -0,0 +1,21 @@ +package com.sino.mci.crm.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_identity") +public class ChannelIdentity extends BaseEntity { + + private String tenantCode; + private Long personId; + private String channelType; + private String channelUserId; + private String channelUserName; + private Long subjectId; + private Integer status; + private String extInfo; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Person.java b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Person.java new file mode 100644 index 0000000..8246cec --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Person.java @@ -0,0 +1,21 @@ +package com.sino.mci.crm.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_person") +public class Person extends BaseEntity { + + private String tenantCode; + private String personCode; + private String personName; + private String phone; + private Integer personType; + private Long institutionId; + private Long supplierId; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/entity/PersonTagBinding.java b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/PersonTagBinding.java new file mode 100644 index 0000000..0dc3bee --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/PersonTagBinding.java @@ -0,0 +1,21 @@ +package com.sino.mci.crm.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +@TableName("mci_person_tag_binding") +public class PersonTagBinding { + + @TableId(type = IdType.AUTO) + private Long id; + + private String tenantCode; + private Long tagId; + private Long personId; + private LocalDateTime createTime; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Tag.java b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Tag.java new file mode 100644 index 0000000..2d8ebea --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/entity/Tag.java @@ -0,0 +1,20 @@ +package com.sino.mci.crm.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_tag") +public class Tag extends BaseEntity { + + private String tenantCode; + private Long parentId; + private String tagPath; + private Integer level; + private String tagName; + private String tagSource; + private Integer status; +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/repository/ChannelIdentityMapper.java b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/ChannelIdentityMapper.java new file mode 100644 index 0000000..7906230 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/ChannelIdentityMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.crm.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.crm.entity.ChannelIdentity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ChannelIdentityMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonMapper.java b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonMapper.java new file mode 100644 index 0000000..5cefccf --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.crm.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.crm.entity.Person; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PersonMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonTagBindingMapper.java b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonTagBindingMapper.java new file mode 100644 index 0000000..fc73b04 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/PersonTagBindingMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.crm.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.crm.entity.PersonTagBinding; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface PersonTagBindingMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/repository/TagMapper.java b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/TagMapper.java new file mode 100644 index 0000000..501cc15 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/repository/TagMapper.java @@ -0,0 +1,9 @@ +package com.sino.mci.crm.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.sino.mci.crm.entity.Tag; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TagMapper extends BaseMapper { +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java b/backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java new file mode 100644 index 0000000..98fc3e3 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/service/PersonService.java @@ -0,0 +1,121 @@ +package com.sino.mci.crm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.crm.dto.BindTagsRequest; +import com.sino.mci.crm.dto.CreateIdentityRequest; +import com.sino.mci.crm.dto.CreatePersonRequest; +import com.sino.mci.crm.entity.ChannelIdentity; +import com.sino.mci.crm.entity.Person; +import com.sino.mci.crm.entity.PersonTagBinding; +import com.sino.mci.crm.entity.Tag; +import com.sino.mci.crm.repository.ChannelIdentityMapper; +import com.sino.mci.crm.repository.PersonMapper; +import com.sino.mci.crm.repository.PersonTagBindingMapper; +import com.sino.mci.crm.repository.TagMapper; +import com.sino.mci.exception.BusinessException; +import com.sino.mci.shared.util.JsonUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PersonService { + + private final PersonMapper personMapper; + private final ChannelIdentityMapper identityMapper; + private final PersonTagBindingMapper personTagBindingMapper; + private final TagMapper tagMapper; + + @Transactional + public Person createPerson(String tenantCode, CreatePersonRequest request) { + Person person = new Person(); + person.setTenantCode(tenantCode); + person.setPersonCode(request.getPersonCode()); + person.setPersonName(request.getPersonName()); + person.setPhone(request.getPhone()); + person.setPersonType(request.getPersonType()); + person.setInstitutionId(request.getInstitutionId()); + person.setSupplierId(request.getSupplierId()); + person.setStatus(1); + personMapper.insert(person); + return person; + } + + public Person getPerson(String tenantCode, Long personId) { + Person person = personMapper.selectById(personId); + if (person == null || !tenantCode.equals(person.getTenantCode())) { + throw new BusinessException("联系人不存在"); + } + return person; + } + + public List listIdentities(String tenantCode, Long personId) { + getPerson(tenantCode, personId); + return identityMapper.selectList( + new LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, tenantCode) + .eq(ChannelIdentity::getPersonId, personId)); + } + + @Transactional + public ChannelIdentity createIdentity(String tenantCode, CreateIdentityRequest request) { + getPerson(tenantCode, request.getPersonId()); + + ChannelIdentity existing = identityMapper.selectOne( + new LambdaQueryWrapper() + .eq(ChannelIdentity::getTenantCode, tenantCode) + .eq(ChannelIdentity::getChannelType, request.getChannelType()) + .eq(ChannelIdentity::getChannelUserId, request.getChannelUserId())); + if (existing != null) { + throw new BusinessException("渠道身份已存在: " + request.getChannelUserId()); + } + + ChannelIdentity identity = new ChannelIdentity(); + identity.setTenantCode(tenantCode); + identity.setPersonId(request.getPersonId()); + identity.setChannelType(request.getChannelType()); + identity.setChannelUserId(request.getChannelUserId()); + identity.setChannelUserName(request.getChannelUserName()); + identity.setSubjectId(request.getSubjectId()); + identity.setStatus(1); + identity.setExtInfo(JsonUtils.toJson(request.getExtInfo())); + identityMapper.insert(identity); + return identity; + } + + @Transactional + public void bindTags(String tenantCode, Long personId, BindTagsRequest request) { + getPerson(tenantCode, personId); + for (Long tagId : request.getTagIds()) { + Tag tag = tagMapper.selectById(tagId); + if (tag == null || !tenantCode.equals(tag.getTenantCode())) { + throw new BusinessException("标签不存在: " + tagId); + } + } + + personTagBindingMapper.delete( + new LambdaQueryWrapper() + .eq(PersonTagBinding::getPersonId, personId)); + + if (!CollectionUtils.isEmpty(request.getTagIds())) { + List bindings = request.getTagIds().stream() + .distinct() + .map(tagId -> { + PersonTagBinding binding = new PersonTagBinding(); + binding.setTenantCode(tenantCode); + binding.setTagId(tagId); + binding.setPersonId(personId); + return binding; + }) + .collect(Collectors.toList()); + for (PersonTagBinding binding : bindings) { + personTagBindingMapper.insert(binding); + } + } + } +} diff --git a/backend/mci-server/src/main/java/com/sino/mci/crm/service/TagService.java b/backend/mci-server/src/main/java/com/sino/mci/crm/service/TagService.java new file mode 100644 index 0000000..ecc9c08 --- /dev/null +++ b/backend/mci-server/src/main/java/com/sino/mci/crm/service/TagService.java @@ -0,0 +1,64 @@ +package com.sino.mci.crm.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.sino.mci.crm.dto.CreateTagRequest; +import com.sino.mci.crm.entity.Tag; +import com.sino.mci.crm.repository.TagMapper; +import com.sino.mci.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class TagService { + + private final TagMapper tagMapper; + + @Transactional + public Tag createTag(String tenantCode, CreateTagRequest request) { + Long parentId = request.getParentId() != null ? request.getParentId() : 0L; + int level = 1; + String parentPath = ""; + + if (parentId > 0) { + Tag parent = tagMapper.selectById(parentId); + if (parent == null || !tenantCode.equals(parent.getTenantCode())) { + throw new BusinessException("父标签不存在"); + } + level = parent.getLevel() + 1; + parentPath = parent.getTagPath(); + } + + String tagPath = parentPath.isEmpty() ? request.getTagName() : parentPath + "/" + request.getTagName(); + + Tag existing = tagMapper.selectOne( + new LambdaQueryWrapper() + .eq(Tag::getTenantCode, tenantCode) + .eq(Tag::getTagPath, tagPath)); + if (existing != null) { + throw new BusinessException("标签路径已存在: " + tagPath); + } + + Tag tag = new Tag(); + tag.setTenantCode(tenantCode); + tag.setParentId(parentId); + tag.setTagPath(tagPath); + tag.setLevel(level); + tag.setTagName(request.getTagName()); + tag.setTagSource(request.getTagSource() != null ? request.getTagSource() : "manual"); + tag.setStatus(1); + tagMapper.insert(tag); + return tag; + } + + public List listTags(String tenantCode) { + return tagMapper.selectList( + new LambdaQueryWrapper() + .eq(Tag::getTenantCode, tenantCode) + .eq(Tag::getStatus, 1) + .orderByAsc(Tag::getTagPath)); + } +} diff --git a/backend/mci-server/src/main/resources/db/migration/V4__init_person_conversation_tag.sql b/backend/mci-server/src/main/resources/db/migration/V4__init_person_conversation_tag.sql new file mode 100644 index 0000000..fa5adfa --- /dev/null +++ b/backend/mci-server/src/main/resources/db/migration/V4__init_person_conversation_tag.sql @@ -0,0 +1,150 @@ +CREATE TABLE IF NOT EXISTS mci_person ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + person_code VARCHAR(64) COMMENT '业务人员编码', + person_name VARCHAR(128) NOT NULL COMMENT '姓名', + phone VARCHAR(32) COMMENT '手机号', + person_type TINYINT NOT NULL DEFAULT 1 COMMENT '类型:1内部人员 2供应商人员 3客户 4司机', + institution_id BIGINT COMMENT '所属机构/公司', + supplier_id BIGINT COMMENT '供应商 ID', + 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_person_code (tenant_code, person_code, deleted), + KEY idx_person_name (person_name), + KEY idx_phone (phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='联系人表'; + +CREATE TABLE IF NOT EXISTS mci_channel_identity ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + person_id BIGINT NOT NULL COMMENT '联系人 ID', + channel_type VARCHAR(32) NOT NULL COMMENT '渠道:wecom / wechat_personal / dingtalk / feishu', + channel_user_id VARCHAR(128) NOT NULL COMMENT '渠道用户 ID', + channel_user_name VARCHAR(128) COMMENT '渠道昵称', + subject_id BIGINT COMMENT '所属渠道主体 mci_channel_subject', + status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0禁用 1启用', + ext_info JSON 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_user (tenant_code, channel_type, channel_user_id, deleted), + KEY idx_person_id (person_id), + KEY idx_subject_id (subject_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='渠道身份表'; + +CREATE TABLE IF NOT EXISTS mci_conversation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + conversation_type TINYINT NOT NULL DEFAULT 1 COMMENT '1单聊 2群聊', + channel_type VARCHAR(32) NOT NULL COMMENT 'wecom / wechat_personal / dingtalk / feishu', + channel_conversation_id VARCHAR(128) NOT NULL COMMENT '渠道会话 ID', + owner_account_id BIGINT COMMENT '群主/所有者账号', + subject_id BIGINT COMMENT '所属渠道主体', + conversation_name VARCHAR(256) COMMENT '会话名称', + create_source TINYINT NOT NULL DEFAULT 1 COMMENT '来源:1渠道同步 2API 创建 3手动创建', + create_task_id BIGINT COMMENT '建群任务 ID', + institution_id BIGINT COMMENT '关联机构', + supplier_id BIGINT COMMENT '关联供应商', + contract_id BIGINT COMMENT '关联合同', + channel_code VARCHAR(64) COMMENT '业务渠道编码', + receive_config JSON COMMENT '收消息配置', + 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_conv (tenant_code, channel_type, channel_conversation_id, deleted), + KEY idx_owner_account (owner_account_id), + KEY idx_subject_id (subject_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话表'; + +CREATE TABLE IF NOT EXISTS mci_conversation_participant ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + conversation_id BIGINT NOT NULL COMMENT '会话 ID', + person_id BIGINT COMMENT '人员 ID', + channel_identity_id BIGINT COMMENT '渠道身份 ID', + participant_type TINYINT NOT NULL DEFAULT 1 COMMENT '1成员 2群主 3管理员', + join_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_conv_participant (conversation_id, person_id, channel_identity_id), + KEY idx_conversation_id (conversation_id), + KEY idx_person_id (person_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话参与者表'; + +CREATE TABLE IF NOT EXISTS mci_channel_account_conversation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + account_id BIGINT NOT NULL COMMENT '渠道账号 ID', + conversation_id BIGINT NOT NULL COMMENT '会话 ID', + binding_type TINYINT NOT NULL COMMENT '1发送主号 2发送备用号 3接收主号 4接收备用号', + sort_order INT NOT NULL DEFAULT 0 COMMENT '优先级', + 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, + UNIQUE KEY uk_account_conv_binding (account_id, conversation_id, binding_type), + KEY idx_conversation_id (conversation_id), + KEY idx_account_id (account_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号-会话绑定表'; + +CREATE TABLE IF NOT EXISTS mci_tag ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父标签 ID,0 表示根标签', + tag_path VARCHAR(512) NOT NULL COMMENT '标签路径,如 销售/销售一部/华北组', + level TINYINT NOT NULL DEFAULT 1 COMMENT '层级', + tag_name VARCHAR(128) NOT NULL COMMENT '标签名', + tag_source VARCHAR(32) NOT NULL DEFAULT 'manual' COMMENT '来源:system / business_sync / manual', + 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_tag_path (tenant_code, tag_path, deleted), + KEY idx_parent_id (parent_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='标签表'; + +CREATE TABLE IF NOT EXISTS mci_person_tag_binding ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + tag_id BIGINT NOT NULL COMMENT '标签 ID', + person_id BIGINT NOT NULL COMMENT '联系人 ID', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_person_tag (tag_id, person_id), + KEY idx_person_id (person_id), + KEY idx_tag_id (tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='人员标签绑定表'; + +CREATE TABLE IF NOT EXISTS mci_conversation_tag_binding ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL COMMENT '所属租户', + tag_id BIGINT NOT NULL COMMENT '标签 ID', + conversation_id BIGINT NOT NULL COMMENT '会话 ID', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_conversation_tag (tag_id, conversation_id), + KEY idx_conversation_id (conversation_id), + KEY idx_tag_id (tag_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话标签绑定表'; + +ALTER TABLE mci_channel_identity + ADD CONSTRAINT fk_identity_person_id FOREIGN KEY (person_id) REFERENCES mci_person (id), + ADD CONSTRAINT fk_identity_subject_id FOREIGN KEY (subject_id) REFERENCES mci_channel_subject (id); + +ALTER TABLE mci_conversation + ADD CONSTRAINT fk_conversation_owner_account FOREIGN KEY (owner_account_id) REFERENCES mci_channel_account (id), + ADD CONSTRAINT fk_conversation_subject_id FOREIGN KEY (subject_id) REFERENCES mci_channel_subject (id); + +ALTER TABLE mci_conversation_participant + ADD CONSTRAINT fk_participant_conversation_id FOREIGN KEY (conversation_id) REFERENCES mci_conversation (id), + ADD CONSTRAINT fk_participant_person_id FOREIGN KEY (person_id) REFERENCES mci_person (id), + ADD CONSTRAINT fk_participant_identity_id FOREIGN KEY (channel_identity_id) REFERENCES mci_channel_identity (id); + +ALTER TABLE mci_channel_account_conversation + ADD CONSTRAINT fk_binding_account_id FOREIGN KEY (account_id) REFERENCES mci_channel_account (id), + ADD CONSTRAINT fk_binding_conversation_id FOREIGN KEY (conversation_id) REFERENCES mci_conversation (id); + +ALTER TABLE mci_person_tag_binding + ADD CONSTRAINT fk_person_tag_tag_id FOREIGN KEY (tag_id) REFERENCES mci_tag (id), + ADD CONSTRAINT fk_person_tag_person_id FOREIGN KEY (person_id) REFERENCES mci_person (id); + +ALTER TABLE mci_conversation_tag_binding + ADD CONSTRAINT fk_conv_tag_tag_id FOREIGN KEY (tag_id) REFERENCES mci_tag (id), + ADD CONSTRAINT fk_conv_tag_conversation_id FOREIGN KEY (conversation_id) REFERENCES mci_conversation (id); diff --git a/backend/mci-server/src/test/java/com/sino/mci/crm/controller/CrmControllerTest.java b/backend/mci-server/src/test/java/com/sino/mci/crm/controller/CrmControllerTest.java new file mode 100644 index 0000000..c197259 --- /dev/null +++ b/backend/mci-server/src/test/java/com/sino/mci/crm/controller/CrmControllerTest.java @@ -0,0 +1,74 @@ +package com.sino.mci.crm.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 CrmControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldCreatePersonTagAndConversation() throws Exception { + String tenantCode = "crm-" + UUID.randomUUID().toString().substring(0, 8); + String tenantPayload = String.format("{\"tenantCode\":\"%s\",\"tenantName\":\"CRM测试\"}", 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 personPayload = "{\"personCode\":\"P001\",\"personName\":\"张三\",\"phone\":\"13800138000\",\"personType\":3}"; + mockMvc.perform(post("/msg-platform/api/v1/person") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(personPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.personName").value("张三")); + + String tagPayload = "{\"parentId\":0,\"tagName\":\"客户\"}"; + mockMvc.perform(post("/msg-platform/api/v1/tag") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(tagPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.tagPath").value("客户")); + + String conversationPayload = "{\"conversationType\":2,\"channelType\":\"wecom\",\"channelConversationId\":\"roomid_123\",\"conversationName\":\"测试群\"}"; + mockMvc.perform(post("/msg-platform/api/v1/conversation") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode) + .contentType(MediaType.APPLICATION_JSON) + .content(conversationPayload)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.conversationName").value("测试群")); + + mockMvc.perform(get("/msg-platform/api/v1/tag/list") + .contextPath("/msg-platform") + .header("X-Tenant-Code", tenantCode)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.size()").value(1)); + } +} diff --git a/backend/mci-shared/pom.xml b/backend/mci-shared/pom.xml index d32ece9..8511389 100644 --- a/backend/mci-shared/pom.xml +++ b/backend/mci-shared/pom.xml @@ -21,5 +21,13 @@ lombok true + + com.fasterxml.jackson.core + jackson-databind + + + org.slf4j + slf4j-api + diff --git a/backend/mci-shared/src/main/java/com/sino/mci/shared/util/JsonUtils.java b/backend/mci-shared/src/main/java/com/sino/mci/shared/util/JsonUtils.java new file mode 100644 index 0000000..51e38d4 --- /dev/null +++ b/backend/mci-shared/src/main/java/com/sino/mci/shared/util/JsonUtils.java @@ -0,0 +1,38 @@ +package com.sino.mci.shared.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public final class JsonUtils { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private JsonUtils() { + } + + public static String toJson(Object value) { + if (value == null) { + return null; + } + try { + return OBJECT_MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + log.warn("JSON serialize failed", e); + return null; + } + } + + public static T fromJson(String json, Class clazz) { + if (json == null || json.isBlank()) { + return null; + } + try { + return OBJECT_MAPPER.readValue(json, clazz); + } catch (JsonProcessingException e) { + log.warn("JSON deserialize failed", e); + return null; + } + } +}