feat: 搭建后端多租户名片服务

- 初始化 Spring Boot 多模块工程与通用基础能力

- 增加租户、组织、用户、名片、文件、统计等业务模块

- 补充数据库迁移脚本与基础测试
This commit is contained in:
2026-03-20 12:43:21 +08:00
parent 1a2a078c0f
commit 9ef50288e9
95 changed files with 6722 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-org</artifactId>
<name>easycard-module-org</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,123 @@
package com.easycard.module.org.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.org.service.TenantOrgService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tenant")
public class TenantOrgController {
private final TenantOrgService tenantOrgService;
public TenantOrgController(TenantOrgService tenantOrgService) {
this.tenantOrgService = tenantOrgService;
}
@GetMapping("/firm-profile")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.FirmProfileView> getFirmProfile() {
return ApiResponse.success(tenantOrgService.getFirmProfile());
}
@PutMapping("/firm-profile")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.FirmProfileView> saveFirmProfile(@Valid @RequestBody UpsertFirmProfileCommand request) {
return ApiResponse.success(tenantOrgService.saveFirmProfile(request.toServiceRequest()));
}
@GetMapping("/practice-areas")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<TenantOrgService.PracticeAreaView>> listPracticeAreas() {
return ApiResponse.success(tenantOrgService.listPracticeAreas());
}
@PostMapping("/practice-areas")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.PracticeAreaView> createPracticeArea(@Valid @RequestBody UpsertPracticeAreaCommand request) {
return ApiResponse.success(tenantOrgService.createPracticeArea(request.toServiceRequest()));
}
@PutMapping("/practice-areas/{areaId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.PracticeAreaView> updatePracticeArea(@PathVariable Long areaId, @Valid @RequestBody UpsertPracticeAreaCommand request) {
return ApiResponse.success(tenantOrgService.updatePracticeArea(areaId, request.toServiceRequest()));
}
@GetMapping("/departments")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<TenantOrgService.DepartmentView>> listDepartments() {
return ApiResponse.success(tenantOrgService.listDepartments());
}
@PostMapping("/departments")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.DepartmentView> createDepartment(@Valid @RequestBody UpsertDepartmentCommand request) {
return ApiResponse.success(tenantOrgService.createDepartment(request.toServiceRequest()));
}
@PutMapping("/departments/{deptId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.DepartmentView> updateDepartment(@PathVariable Long deptId, @Valid @RequestBody UpsertDepartmentCommand request) {
return ApiResponse.success(tenantOrgService.updateDepartment(deptId, request.toServiceRequest()));
}
}
record UpsertFirmProfileCommand(
@NotBlank(message = "事务所名称不能为空") String firmName,
String firmShortName,
String englishName,
Long logoAssetId,
Long heroAssetId,
String intro,
String hotlinePhone,
String websiteUrl,
String wechatOfficialAccount,
String hqAddress,
String hqLatitude,
String hqLongitude
) {
TenantOrgService.UpsertFirmProfileRequest toServiceRequest() {
return new TenantOrgService.UpsertFirmProfileRequest(
firmName, firmShortName, englishName, logoAssetId, heroAssetId, intro, hotlinePhone,
websiteUrl, wechatOfficialAccount, hqAddress, hqLatitude, hqLongitude
);
}
}
record UpsertPracticeAreaCommand(
@NotBlank(message = "专业编码不能为空") String areaCode,
@NotBlank(message = "专业名称不能为空") String areaName,
Integer displayOrder,
@NotBlank(message = "状态不能为空") String areaStatus
) {
TenantOrgService.UpsertPracticeAreaRequest toServiceRequest() {
return new TenantOrgService.UpsertPracticeAreaRequest(areaCode, areaName, displayOrder == null ? 0 : displayOrder, areaStatus);
}
}
record UpsertDepartmentCommand(
Long parentId,
@NotBlank(message = "组织编码不能为空") String deptCode,
@NotBlank(message = "组织名称不能为空") String deptName,
@NotBlank(message = "组织类型不能为空") String deptType,
String contactPhone,
String address,
Integer displayOrder,
@NotBlank(message = "状态不能为空") String deptStatus
) {
TenantOrgService.UpsertDepartmentRequest toServiceRequest() {
return new TenantOrgService.UpsertDepartmentRequest(parentId, deptCode, deptName, deptType, contactPhone, address, displayOrder == null ? 0 : displayOrder, deptStatus);
}
}

View File

@@ -0,0 +1,20 @@
package com.easycard.module.org.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("file_asset")
public class FileAssetLiteDO {
@TableId(type = IdType.AUTO)
private Long id;
private String bucketName;
private String objectKey;
private String accessUrl;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,33 @@
package com.easycard.module.org.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("org_department")
public class OrgDepartmentDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long parentId;
private String deptCode;
private String deptName;
private String deptType;
private Long leaderUserId;
private String contactPhone;
private String address;
private Integer displayOrder;
private String deptStatus;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,28 @@
package com.easycard.module.org.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("org_firm_practice_area")
public class OrgFirmPracticeAreaDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String areaCode;
private String areaName;
private Integer displayOrder;
private String areaStatus;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,37 @@
package com.easycard.module.org.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("org_firm_profile")
public class OrgFirmProfileDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String firmName;
private String firmShortName;
private String englishName;
private Long logoAssetId;
private Long heroAssetId;
private String intro;
private String hotlinePhone;
private String websiteUrl;
private String wechatOfficialAccount;
private String hqAddress;
private BigDecimal hqLatitude;
private BigDecimal hqLongitude;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.org.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.org.dal.entity.FileAssetLiteDO;
public interface FileAssetLiteMapper extends BaseMapper<FileAssetLiteDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.org.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
public interface OrgDepartmentMapper extends BaseMapper<OrgDepartmentDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.org.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.org.dal.entity.OrgFirmPracticeAreaDO;
public interface OrgFirmPracticeAreaMapper extends BaseMapper<OrgFirmPracticeAreaDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.org.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
public interface OrgFirmProfileMapper extends BaseMapper<OrgFirmProfileDO> {
}

View File

@@ -0,0 +1,303 @@
package com.easycard.module.org.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.easycard.common.auth.LoginUser;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.exception.BusinessException;
import com.easycard.common.storage.StorageUrlUtils;
import com.easycard.module.org.dal.entity.FileAssetLiteDO;
import com.easycard.module.org.dal.mapper.FileAssetLiteMapper;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.entity.OrgFirmPracticeAreaDO;
import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
import com.easycard.module.org.dal.mapper.OrgFirmPracticeAreaMapper;
import com.easycard.module.org.dal.mapper.OrgFirmProfileMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TenantOrgService {
@Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
private String publicEndpoint;
private final OrgFirmProfileMapper orgFirmProfileMapper;
private final OrgFirmPracticeAreaMapper orgFirmPracticeAreaMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final FileAssetLiteMapper fileAssetLiteMapper;
public FirmProfileView getFirmProfile() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.<OrgFirmProfileDO>lambdaQuery()
.eq(OrgFirmProfileDO::getTenantId, loginUser.tenantId())
.eq(OrgFirmProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (profile == null) {
return new FirmProfileView(null, "", "", "", null, "", null, "", "", "", "", "", "", null, null);
}
return toView(profile);
}
@Transactional
public FirmProfileView saveFirmProfile(UpsertFirmProfileRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.<OrgFirmProfileDO>lambdaQuery()
.eq(OrgFirmProfileDO::getTenantId, loginUser.tenantId())
.eq(OrgFirmProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (profile == null) {
profile = new OrgFirmProfileDO();
profile.setTenantId(loginUser.tenantId());
profile.setCreatedBy(loginUser.userId());
profile.setDeleted(0);
}
profile.setFirmName(request.firmName());
profile.setFirmShortName(request.firmShortName());
profile.setEnglishName(request.englishName());
profile.setLogoAssetId(request.logoAssetId());
profile.setHeroAssetId(request.heroAssetId());
profile.setIntro(request.intro());
profile.setHotlinePhone(request.hotlinePhone());
profile.setWebsiteUrl(request.websiteUrl());
profile.setWechatOfficialAccount(request.wechatOfficialAccount());
profile.setHqAddress(request.hqAddress());
profile.setHqLatitude(toBigDecimal(request.hqLatitude()));
profile.setHqLongitude(toBigDecimal(request.hqLongitude()));
profile.setUpdatedBy(loginUser.userId());
if (profile.getId() == null) {
orgFirmProfileMapper.insert(profile);
} else {
orgFirmProfileMapper.updateById(profile);
}
return toView(profile);
}
public List<PracticeAreaView> listPracticeAreas() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return orgFirmPracticeAreaMapper.selectList(Wrappers.<OrgFirmPracticeAreaDO>lambdaQuery()
.eq(OrgFirmPracticeAreaDO::getTenantId, loginUser.tenantId())
.eq(OrgFirmPracticeAreaDO::getDeleted, 0)
.orderByAsc(OrgFirmPracticeAreaDO::getDisplayOrder, OrgFirmPracticeAreaDO::getId))
.stream()
.map(item -> new PracticeAreaView(item.getId(), item.getAreaCode(), item.getAreaName(), item.getDisplayOrder(), item.getAreaStatus()))
.toList();
}
@Transactional
public PracticeAreaView createPracticeArea(UpsertPracticeAreaRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgFirmPracticeAreaDO existed = orgFirmPracticeAreaMapper.selectOne(Wrappers.<OrgFirmPracticeAreaDO>lambdaQuery()
.eq(OrgFirmPracticeAreaDO::getTenantId, loginUser.tenantId())
.eq(OrgFirmPracticeAreaDO::getAreaCode, request.areaCode())
.eq(OrgFirmPracticeAreaDO::getDeleted, 0)
.last("LIMIT 1"));
if (existed != null) {
throw new BusinessException("AREA_CODE_DUPLICATED", "专业领域编码已存在");
}
OrgFirmPracticeAreaDO area = new OrgFirmPracticeAreaDO();
area.setTenantId(loginUser.tenantId());
area.setAreaCode(request.areaCode());
area.setAreaName(request.areaName());
area.setDisplayOrder(request.displayOrder());
area.setAreaStatus(request.areaStatus());
area.setCreatedBy(loginUser.userId());
area.setUpdatedBy(loginUser.userId());
area.setDeleted(0);
orgFirmPracticeAreaMapper.insert(area);
return new PracticeAreaView(area.getId(), area.getAreaCode(), area.getAreaName(), area.getDisplayOrder(), area.getAreaStatus());
}
@Transactional
public PracticeAreaView updatePracticeArea(Long areaId, UpsertPracticeAreaRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgFirmPracticeAreaDO area = getPracticeArea(areaId, loginUser.tenantId());
area.setAreaName(request.areaName());
area.setDisplayOrder(request.displayOrder());
area.setAreaStatus(request.areaStatus());
area.setUpdatedBy(loginUser.userId());
orgFirmPracticeAreaMapper.updateById(area);
return new PracticeAreaView(area.getId(), area.getAreaCode(), area.getAreaName(), area.getDisplayOrder(), area.getAreaStatus());
}
public List<DepartmentView> listDepartments() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>lambdaQuery()
.eq(OrgDepartmentDO::getTenantId, loginUser.tenantId())
.eq(OrgDepartmentDO::getDeleted, 0)
.orderByAsc(OrgDepartmentDO::getDisplayOrder, OrgDepartmentDO::getId))
.stream()
.map(item -> new DepartmentView(item.getId(), item.getParentId(), item.getDeptCode(), item.getDeptName(), item.getDeptType(), item.getContactPhone(), item.getAddress(), item.getDisplayOrder(), item.getDeptStatus()))
.toList();
}
@Transactional
public DepartmentView createDepartment(UpsertDepartmentRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgDepartmentDO existed = orgDepartmentMapper.selectOne(Wrappers.<OrgDepartmentDO>lambdaQuery()
.eq(OrgDepartmentDO::getTenantId, loginUser.tenantId())
.eq(OrgDepartmentDO::getDeptCode, request.deptCode())
.eq(OrgDepartmentDO::getDeleted, 0)
.last("LIMIT 1"));
if (existed != null) {
throw new BusinessException("DEPT_CODE_DUPLICATED", "组织编码已存在");
}
OrgDepartmentDO department = new OrgDepartmentDO();
department.setTenantId(loginUser.tenantId());
department.setParentId(request.parentId() == null ? 0L : request.parentId());
department.setDeptCode(request.deptCode());
department.setDeptName(request.deptName());
department.setDeptType(request.deptType());
department.setContactPhone(request.contactPhone());
department.setAddress(request.address());
department.setDisplayOrder(request.displayOrder());
department.setDeptStatus(request.deptStatus());
department.setCreatedBy(loginUser.userId());
department.setUpdatedBy(loginUser.userId());
department.setDeleted(0);
orgDepartmentMapper.insert(department);
return new DepartmentView(department.getId(), department.getParentId(), department.getDeptCode(), department.getDeptName(), department.getDeptType(), department.getContactPhone(), department.getAddress(), department.getDisplayOrder(), department.getDeptStatus());
}
@Transactional
public DepartmentView updateDepartment(Long deptId, UpsertDepartmentRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
OrgDepartmentDO department = getDepartment(deptId, loginUser.tenantId());
department.setParentId(request.parentId() == null ? 0L : request.parentId());
department.setDeptName(request.deptName());
department.setDeptType(request.deptType());
department.setContactPhone(request.contactPhone());
department.setAddress(request.address());
department.setDisplayOrder(request.displayOrder());
department.setDeptStatus(request.deptStatus());
department.setUpdatedBy(loginUser.userId());
orgDepartmentMapper.updateById(department);
return new DepartmentView(department.getId(), department.getParentId(), department.getDeptCode(), department.getDeptName(), department.getDeptType(), department.getContactPhone(), department.getAddress(), department.getDisplayOrder(), department.getDeptStatus());
}
private OrgDepartmentDO getDepartment(Long deptId, Long tenantId) {
OrgDepartmentDO department = orgDepartmentMapper.selectById(deptId);
if (department == null || Integer.valueOf(1).equals(department.getDeleted()) || !tenantId.equals(department.getTenantId())) {
throw new BusinessException("DEPARTMENT_NOT_FOUND", "组织不存在");
}
return department;
}
private OrgFirmPracticeAreaDO getPracticeArea(Long areaId, Long tenantId) {
OrgFirmPracticeAreaDO area = orgFirmPracticeAreaMapper.selectById(areaId);
if (area == null || Integer.valueOf(1).equals(area.getDeleted()) || !tenantId.equals(area.getTenantId())) {
throw new BusinessException("PRACTICE_AREA_NOT_FOUND", "专业领域不存在");
}
return area;
}
private FirmProfileView toView(OrgFirmProfileDO profile) {
return new FirmProfileView(
profile.getId(),
profile.getFirmName(),
profile.getFirmShortName(),
profile.getEnglishName(),
profile.getLogoAssetId(),
resolveAssetUrl(profile.getLogoAssetId()),
profile.getHeroAssetId(),
resolveAssetUrl(profile.getHeroAssetId()),
profile.getIntro(),
profile.getHotlinePhone(),
profile.getWebsiteUrl(),
profile.getWechatOfficialAccount(),
profile.getHqAddress(),
profile.getHqLatitude(),
profile.getHqLongitude()
);
}
private BigDecimal toBigDecimal(String value) {
if (value == null || value.isBlank()) {
return null;
}
return new BigDecimal(value);
}
private String resolveAssetUrl(Long assetId) {
if (assetId == null) {
return "";
}
FileAssetLiteDO asset = fileAssetLiteMapper.selectById(assetId);
if (asset == null || Integer.valueOf(1).equals(asset.getDeleted())) {
return "";
}
return StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl());
}
public record FirmProfileView(
Long id,
String firmName,
String firmShortName,
String englishName,
Long logoAssetId,
String logoUrl,
Long heroAssetId,
String heroUrl,
String intro,
String hotlinePhone,
String websiteUrl,
String wechatOfficialAccount,
String hqAddress,
BigDecimal hqLatitude,
BigDecimal hqLongitude
) {
}
public record UpsertFirmProfileRequest(
String firmName,
String firmShortName,
String englishName,
Long logoAssetId,
Long heroAssetId,
String intro,
String hotlinePhone,
String websiteUrl,
String wechatOfficialAccount,
String hqAddress,
String hqLatitude,
String hqLongitude
) {
}
public record PracticeAreaView(Long id, String areaCode, String areaName, Integer displayOrder, String areaStatus) {
}
public record UpsertPracticeAreaRequest(String areaCode, String areaName, Integer displayOrder, String areaStatus) {
}
public record DepartmentView(
Long id,
Long parentId,
String deptCode,
String deptName,
String deptType,
String contactPhone,
String address,
Integer displayOrder,
String deptStatus
) {
}
public record UpsertDepartmentRequest(
Long parentId,
String deptCode,
String deptName,
String deptType,
String contactPhone,
String address,
Integer displayOrder,
String deptStatus
) {
}
}