feat(crm): add person, identity, conversation, tag tables and APIs

This commit is contained in:
2026-07-07 12:06:19 +08:00
parent 855f77595d
commit 3f2c0d7d16
34 changed files with 1086 additions and 1 deletions

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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<Long> tagIds;
}

View File

@@ -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<String, Object> receiveConfig;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Conversation>()
.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<ChannelAccountConversation>()
.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<ConversationTagBinding>()
.eq(ConversationTagBinding::getConversationId, conversationId));
if (!CollectionUtils.isEmpty(request.getTagIds())) {
List<ConversationTagBinding> 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);
}
}
}
}

View File

@@ -11,7 +11,7 @@ import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource; import javax.sql.DataSource;
@Configuration @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 { public class MybatisPlusConfig {
@Bean @Bean

View File

@@ -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();
}
}

View File

@@ -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));
}
}

View File

@@ -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<Long> tagIds;
}

View File

@@ -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<String, Object> extInfo;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<ChannelIdentity> {
}

View File

@@ -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<Person> {
}

View File

@@ -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<PersonTagBinding> {
}

View File

@@ -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<Tag> {
}

View File

@@ -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<ChannelIdentity> listIdentities(String tenantCode, Long personId) {
getPerson(tenantCode, personId);
return identityMapper.selectList(
new LambdaQueryWrapper<ChannelIdentity>()
.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<ChannelIdentity>()
.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<PersonTagBinding>()
.eq(PersonTagBinding::getPersonId, personId));
if (!CollectionUtils.isEmpty(request.getTagIds())) {
List<PersonTagBinding> 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);
}
}
}
}

View File

@@ -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<Tag>()
.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<Tag> listTags(String tenantCode) {
return tagMapper.selectList(
new LambdaQueryWrapper<Tag>()
.eq(Tag::getTenantCode, tenantCode)
.eq(Tag::getStatus, 1)
.orderByAsc(Tag::getTagPath));
}
}

View File

@@ -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 '父标签 ID0 表示根标签',
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);

View File

@@ -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));
}
}

View File

@@ -21,5 +21,13 @@
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>

View File

@@ -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> T fromJson(String json, Class<T> 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;
}
}
}