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,67 @@
package com.easycard.module.card.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.common.web.ClientRequestUtils;
import com.easycard.module.card.service.CardProfileService;
import jakarta.servlet.http.HttpServletRequest;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/open")
public class OpenMiniappController {
private final CardProfileService cardProfileService;
public OpenMiniappController(CardProfileService cardProfileService) {
this.cardProfileService = cardProfileService;
}
@GetMapping("/firm/profile")
public ApiResponse<CardProfileService.OpenFirmView> getFirmProfile() {
return ApiResponse.success(cardProfileService.getOpenFirmProfile());
}
@GetMapping("/cards")
public ApiResponse<List<CardProfileService.OpenCardListItem>> listCards(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String office,
@RequestParam(required = false) String practiceArea
) {
return ApiResponse.success(cardProfileService.listOpenCards(keyword, office, practiceArea));
}
@GetMapping("/cards/{cardId}")
public ApiResponse<CardProfileService.OpenCardDetailView> getCardDetail(
@PathVariable Long cardId,
@RequestParam(defaultValue = "DIRECT") String sourceType,
@RequestParam(required = false) Long shareFromCardId,
HttpServletRequest request
) {
return ApiResponse.success(cardProfileService.getOpenCardDetail(
cardId,
sourceType,
shareFromCardId,
ClientRequestUtils.getClientIp(request),
request.getRequestURI()
));
}
@PostMapping("/cards/{cardId}/share")
public ApiResponse<Void> share(@PathVariable Long cardId, @RequestBody(required = false) ShareRequest request) {
String shareChannel = request == null || request.shareChannel() == null ? "WECHAT_FRIEND" : request.shareChannel();
String sharePath = request == null ? null : request.sharePath();
cardProfileService.recordShare(cardId, shareChannel, sharePath);
return ApiResponse.success("记录成功", null);
}
}
record ShareRequest(String shareChannel, String sharePath) {
}

View File

@@ -0,0 +1,129 @@
package com.easycard.module.card.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.card.service.CardProfileService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tenant")
public class TenantCardController {
private final CardProfileService cardProfileService;
public TenantCardController(CardProfileService cardProfileService) {
this.cardProfileService = cardProfileService;
}
@GetMapping("/cards")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<CardProfileService.CardSummaryView>> listCards(@RequestParam(required = false) String keyword) {
return ApiResponse.success(cardProfileService.listTenantCards(keyword));
}
@PostMapping("/cards")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> createCard(@Valid @RequestBody UpsertCardCommand request) {
return ApiResponse.success(cardProfileService.createCard(request.toServiceRequest()));
}
@GetMapping("/cards/{cardId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> getCard(@PathVariable Long cardId) {
return ApiResponse.success(cardProfileService.getCardDetail(cardId));
}
@PutMapping("/cards/{cardId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> updateCard(@PathVariable Long cardId, @Valid @RequestBody UpsertCardCommand request) {
return ApiResponse.success(cardProfileService.saveCard(cardId, request.toServiceRequest()));
}
@DeleteMapping("/cards/{cardId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> deleteCard(@PathVariable Long cardId) {
cardProfileService.deleteCard(cardId);
return ApiResponse.success("删除成功", null);
}
@PutMapping("/cards/sort")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> sortCards(@Valid @RequestBody SortCardsRequest request) {
cardProfileService.sortCards(request.cardIds());
return ApiResponse.success("排序已生效", null);
}
@GetMapping("/cards/me")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> getMyCard() {
return ApiResponse.success(cardProfileService.getMyCard());
}
@PutMapping("/cards/me")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> saveMyCard(@Valid @RequestBody UpsertCardCommand request) {
return ApiResponse.success(cardProfileService.saveMyCard(request.toServiceRequest()));
}
@GetMapping("/stats/overview")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.DashboardStatsView> overview() {
return ApiResponse.success(cardProfileService.getDashboardStats());
}
@GetMapping("/stats/trend")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<CardProfileService.CardTrendView>> trend(@RequestParam(defaultValue = "7") int days) {
return ApiResponse.success(cardProfileService.getTrend(days));
}
}
record UpsertCardCommand(
Long userId,
Long deptId,
@NotBlank(message = "名片姓名不能为空") String cardName,
String cardTitle,
String mobile,
String telephone,
String email,
String officeAddress,
Long avatarAssetId,
Long coverAssetId,
Long wechatQrAssetId,
String bio,
String certificateNo,
String educationInfo,
String honorInfo,
@NotNull(message = "是否公开不能为空") Integer isPublic,
@NotNull(message = "是否推荐不能为空") Integer isRecommended,
@NotBlank(message = "发布状态不能为空") String publishStatus,
Integer displayOrder,
List<String> specialties
) {
CardProfileService.UpsertCardRequest toServiceRequest() {
return new CardProfileService.UpsertCardRequest(
userId, deptId, cardName, cardTitle, mobile, telephone, email, officeAddress,
avatarAssetId, coverAssetId, wechatQrAssetId, bio, certificateNo, educationInfo,
honorInfo, isPublic, isRecommended, publishStatus, displayOrder == null ? 0 : displayOrder, specialties
);
}
}
record SortCardsRequest(
@Size(min = 1, message = "排序名片不能为空") List<@NotNull(message = "名片ID不能为空") Long> cardIds
) {
}

View File

@@ -0,0 +1,46 @@
package com.easycard.module.card.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("card_profile")
public class CardProfileDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private Long deptId;
private String cardName;
private String cardTitle;
private String mobile;
private String telephone;
private String email;
private String officeAddress;
private Long avatarAssetId;
private Long coverAssetId;
private Long wechatQrAssetId;
private String bio;
private String certificateNo;
private String educationInfo;
private String honorInfo;
private Integer isPublic;
private Integer isRecommended;
private String publishStatus;
private Integer displayOrder;
private Long viewCount;
private Long shareCount;
private LocalDateTime lastPublishedAt;
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.card.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("card_profile_specialty")
public class CardProfileSpecialtyDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long cardId;
private Long practiceAreaId;
private String specialtyName;
private Integer displayOrder;
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.card.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.card.dal.entity.CardProfileDO;
public interface CardProfileMapper extends BaseMapper<CardProfileDO> {
}

View File

@@ -0,0 +1,22 @@
package com.easycard.module.card.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.card.dal.entity.CardProfileSpecialtyDO;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Param;
public interface CardProfileSpecialtyMapper extends BaseMapper<CardProfileSpecialtyDO> {
@Delete("""
DELETE FROM card_profile_specialty
WHERE tenant_id = #{tenantId}
AND card_id = #{cardId}
""")
int deleteForceByTenantIdAndCardId(@Param("tenantId") Long tenantId, @Param("cardId") Long cardId);
@Delete("""
DELETE FROM card_profile_specialty
WHERE tenant_id = #{tenantId}
""")
int deleteForceByTenantId(@Param("tenantId") Long tenantId);
}

View File

@@ -0,0 +1,823 @@
package com.easycard.module.card.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.common.tenant.TenantContext;
import com.easycard.common.tenant.TenantContextHolder;
import com.easycard.module.card.dal.entity.CardProfileDO;
import com.easycard.module.card.dal.entity.CardProfileSpecialtyDO;
import com.easycard.module.card.dal.mapper.CardProfileMapper;
import com.easycard.module.card.dal.mapper.CardProfileSpecialtyMapper;
import com.easycard.module.file.dal.entity.FileAssetDO;
import com.easycard.module.file.dal.mapper.FileAssetMapper;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
import com.easycard.module.org.dal.mapper.OrgFirmProfileMapper;
import com.easycard.module.stat.dal.entity.CardStatDailyDO;
import com.easycard.module.stat.dal.mapper.CardStatDailyMapper;
import com.easycard.module.stat.service.CardEventService;
import com.easycard.module.user.dal.entity.SysRoleDO;
import com.easycard.module.user.dal.entity.SysUserDO;
import com.easycard.module.user.dal.entity.SysUserRoleDO;
import com.easycard.module.user.dal.mapper.SysRoleMapper;
import com.easycard.module.user.dal.mapper.SysUserMapper;
import com.easycard.module.user.dal.mapper.SysUserRoleMapper;
import com.easycard.module.user.service.UserAuditService;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class CardProfileService {
private static final String AUTO_MANAGED_LAWYER_REMARK = "AUTO_MANAGED_LAWYER";
private static final String AUTO_MANAGED_ROLE_CODE = "TENANT_USER";
@Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
private String publicEndpoint;
private final CardProfileMapper cardProfileMapper;
private final CardProfileSpecialtyMapper cardProfileSpecialtyMapper;
private final SysUserMapper sysUserMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final OrgFirmProfileMapper orgFirmProfileMapper;
private final FileAssetMapper fileAssetMapper;
private final CardStatDailyMapper cardStatDailyMapper;
private final CardEventService cardEventService;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final PasswordEncoder passwordEncoder;
private final UserAuditService userAuditService;
public List<CardSummaryView> listTenantCards(String keyword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
List<CardProfileDO> cards = cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getDeleted, 0)
.like(StringUtils.hasText(keyword), CardProfileDO::getCardName, keyword)
.orderByAsc(CardProfileDO::getDisplayOrder, CardProfileDO::getId));
return filterLawyerCards(loginUser.tenantId(), cards).stream().map(this::toSummaryView).toList();
}
@Transactional
public CardDetailView createCard(UpsertCardRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
validateDepartment(loginUser.tenantId(), request.deptId());
SysUserDO hiddenUser = createHiddenLawyerUser(loginUser, request);
CardProfileDO card = new CardProfileDO();
card.setTenantId(loginUser.tenantId());
card.setUserId(hiddenUser.getId());
card.setCreatedBy(loginUser.userId());
card.setDeleted(0);
card.setViewCount(0L);
card.setShareCount(0L);
applyUpsertRequest(card, request, loginUser.userId(), true);
card.setDisplayOrder(nextCardDisplayOrder(loginUser.tenantId()));
cardProfileMapper.insert(card);
replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
return toDetailView(card);
}
public CardDetailView getCardDetail(Long cardId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
return toDetailView(card);
}
public CardDetailView getMyCard() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
CardProfileDO card = cardProfileMapper.selectOne(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getUserId, loginUser.userId())
.eq(CardProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (card == null) {
SysUserDO user = sysUserMapper.selectById(loginUser.userId());
card = new CardProfileDO();
card.setTenantId(loginUser.tenantId());
card.setUserId(loginUser.userId());
card.setDeptId(user == null ? null : user.getDeptId());
card.setCardName(user == null ? loginUser.realName() : user.getRealName());
card.setCardTitle(user == null ? "" : user.getJobTitle());
card.setMobile(user == null ? "" : user.getMobile());
card.setEmail(user == null ? "" : user.getEmail());
card.setOfficeAddress("");
card.setIsPublic(1);
card.setIsRecommended(0);
card.setPublishStatus("DRAFT");
card.setDisplayOrder(999);
card.setViewCount(0L);
card.setShareCount(0L);
card.setCreatedBy(loginUser.userId());
card.setUpdatedBy(loginUser.userId());
card.setDeleted(0);
cardProfileMapper.insert(card);
}
return toDetailView(card);
}
@Transactional
public CardDetailView saveMyCard(UpsertCardRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
CardProfileDO card = cardProfileMapper.selectOne(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getUserId, loginUser.userId())
.eq(CardProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (card == null) {
card = new CardProfileDO();
card.setTenantId(loginUser.tenantId());
card.setUserId(loginUser.userId());
card.setCreatedBy(loginUser.userId());
card.setDeleted(0);
card.setViewCount(0L);
card.setShareCount(0L);
}
applyUpsertRequest(card, request, loginUser.userId(), false);
if (card.getId() == null) {
cardProfileMapper.insert(card);
} else {
cardProfileMapper.updateById(card);
}
replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
return toDetailView(card);
}
@Transactional
public CardDetailView saveCard(Long cardId, UpsertCardRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
validateDepartment(loginUser.tenantId(), request.deptId());
applyUpsertRequest(card, request, loginUser.userId(), true);
cardProfileMapper.updateById(card);
replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
return toDetailView(card);
}
@Transactional
public void deleteCard(Long cardId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
Long userId = card.getUserId();
card.setDeleted(1);
card.setUpdatedBy(loginUser.userId());
cardProfileMapper.updateById(card);
cardProfileSpecialtyMapper.deleteForceByTenantIdAndCardId(loginUser.tenantId(), cardId);
if (userId != null) {
SysUserDO user = sysUserMapper.selectById(userId);
if (user != null
&& loginUser.tenantId().equals(user.getTenantId())
&& Integer.valueOf(0).equals(user.getDeleted())
&& AUTO_MANAGED_LAWYER_REMARK.equals(user.getRemark())) {
sysUserRoleMapper.delete(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, loginUser.tenantId())
.eq(SysUserRoleDO::getUserId, userId));
user.setDeleted(1);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
}
}
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "CARD", "CARD_PROFILE", cardId, "DELETE");
}
@Transactional
public void sortCards(List<Long> cardIds) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
if (cardIds == null || cardIds.isEmpty()) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据不能为空");
}
Set<Long> uniqueCardIds = new HashSet<>(cardIds);
if (uniqueCardIds.size() != cardIds.size()) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据存在重复名片");
}
List<CardProfileDO> tenantCards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getDeleted, 0)
.select(CardProfileDO::getId, CardProfileDO::getUserId)));
Set<Long> expectedCardIds = tenantCards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
if (expectedCardIds.size() != uniqueCardIds.size() || !expectedCardIds.equals(uniqueCardIds)) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据必须包含当前租户全部律师名片");
}
Map<Long, CardProfileDO> cardMap = cardProfileMapper.selectBatchIds(cardIds).stream()
.filter(item -> loginUser.tenantId().equals(item.getTenantId()) && Integer.valueOf(0).equals(item.getDeleted()))
.collect(Collectors.toMap(CardProfileDO::getId, Function.identity()));
for (int i = 0; i < cardIds.size(); i++) {
Long cardId = cardIds.get(i);
CardProfileDO card = cardMap.get(cardId);
if (card == null) {
throw new BusinessException("CARD_SORT_INVALID", "律师名片不存在或不属于当前租户");
}
card.setDisplayOrder((i + 1) * 10);
card.setUpdatedBy(loginUser.userId());
cardProfileMapper.updateById(card);
}
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "CARD", "CARD_PROFILE", 0L, "SORT");
}
public DashboardStatsView getDashboardStats() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
List<CardProfileDO> cards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getDeleted, 0)));
long publishedCount = cards.stream().filter(item -> "PUBLISHED".equals(item.getPublishStatus()) && Integer.valueOf(1).equals(item.getIsPublic())).count();
long totalViews = cards.stream().mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount()).sum();
long totalShares = cards.stream().mapToLong(item -> item.getShareCount() == null ? 0L : item.getShareCount()).sum();
Set<Long> lawyerCardIds = cards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
long todayViews = cardStatDailyMapper.selectList(Wrappers.<CardStatDailyDO>lambdaQuery()
.eq(CardStatDailyDO::getTenantId, loginUser.tenantId())
.eq(CardStatDailyDO::getStatDate, LocalDate.now()))
.stream()
.filter(item -> lawyerCardIds.contains(item.getCardId()))
.mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount())
.sum();
return new DashboardStatsView(cards.size(), publishedCount, totalViews, totalShares, todayViews);
}
public List<CardTrendView> getTrend(int days) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
LocalDate start = LocalDate.now().minusDays(Math.max(days - 1, 0));
return cardStatDailyMapper.selectList(Wrappers.<CardStatDailyDO>lambdaQuery()
.eq(CardStatDailyDO::getTenantId, loginUser.tenantId())
.ge(CardStatDailyDO::getStatDate, start)
.orderByAsc(CardStatDailyDO::getStatDate))
.stream()
.collect(Collectors.groupingBy(CardStatDailyDO::getStatDate))
.entrySet()
.stream()
.sorted(Map.Entry.comparingByKey())
.map(entry -> new CardTrendView(
entry.getKey().toString(),
entry.getValue().stream().mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount()).sum(),
entry.getValue().stream().mapToLong(item -> item.getShareCount() == null ? 0L : item.getShareCount()).sum()
))
.toList();
}
public OpenFirmView getOpenFirmProfile() {
TenantContext tenantContext = TenantContextHolder.getRequired();
OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.<OrgFirmProfileDO>lambdaQuery()
.eq(OrgFirmProfileDO::getTenantId, tenantContext.tenantId())
.eq(OrgFirmProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (profile == null) {
throw new BusinessException("FIRM_PROFILE_NOT_FOUND", "事务所主页未配置");
}
List<String> offices = orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>lambdaQuery()
.eq(OrgDepartmentDO::getTenantId, tenantContext.tenantId())
.eq(OrgDepartmentDO::getDeleted, 0)
.eq(OrgDepartmentDO::getDeptStatus, "ENABLED")
.orderByAsc(OrgDepartmentDO::getDisplayOrder, OrgDepartmentDO::getId))
.stream()
.map(OrgDepartmentDO::getDeptName)
.toList();
List<String> areas = cardProfileSpecialtyMapper.selectList(Wrappers.<CardProfileSpecialtyDO>lambdaQuery()
.eq(CardProfileSpecialtyDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileSpecialtyDO::getDeleted, 0))
.stream()
.map(CardProfileSpecialtyDO::getSpecialtyName)
.distinct()
.toList();
long lawyerCount = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileDO::getDeleted, 0)
.eq(CardProfileDO::getPublishStatus, "PUBLISHED")
.eq(CardProfileDO::getIsPublic, 1)))
.size();
return new OpenFirmView(
profile.getFirmName(),
resolveAssetUrl(profile.getLogoAssetId()),
resolveAssetUrl(profile.getHeroAssetId()),
profile.getIntro(),
profile.getHotlinePhone(),
profile.getHqAddress(),
profile.getHqLatitude() == null ? null : profile.getHqLatitude().doubleValue(),
profile.getHqLongitude() == null ? null : profile.getHqLongitude().doubleValue(),
(long) offices.size(),
lawyerCount,
offices,
areas
);
}
public List<OpenCardListItem> listOpenCards(String keyword, String office, String practiceArea) {
TenantContext tenantContext = TenantContextHolder.getRequired();
List<CardProfileDO> cards = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileDO::getDeleted, 0)
.eq(CardProfileDO::getPublishStatus, "PUBLISHED")
.eq(CardProfileDO::getIsPublic, 1)
.orderByAsc(CardProfileDO::getDisplayOrder, CardProfileDO::getId)));
if (cards.isEmpty()) {
return List.of();
}
List<Long> deptIds = cards.stream().map(CardProfileDO::getDeptId).filter(id -> id != null && id > 0).distinct().toList();
Map<Long, OrgDepartmentDO> deptMap = deptIds.isEmpty() ? Map.of() : orgDepartmentMapper.selectBatchIds(deptIds)
.stream()
.collect(Collectors.toMap(OrgDepartmentDO::getId, Function.identity()));
Map<Long, List<String>> specialtyMap = loadSpecialtyMap(cards.stream().map(CardProfileDO::getId).toList());
return cards.stream()
.filter(card -> {
String deptName = deptMap.containsKey(card.getDeptId()) ? deptMap.get(card.getDeptId()).getDeptName() : "";
List<String> specialties = specialtyMap.getOrDefault(card.getId(), List.of());
boolean keywordMatched = !StringUtils.hasText(keyword)
|| card.getCardName().contains(keyword)
|| deptName.contains(keyword)
|| specialties.stream().anyMatch(item -> item.contains(keyword));
boolean officeMatched = !StringUtils.hasText(office) || office.equals(deptName);
boolean areaMatched = !StringUtils.hasText(practiceArea) || specialties.stream().anyMatch(item -> item.equals(practiceArea));
return keywordMatched && officeMatched && areaMatched;
})
.map(card -> {
String deptName = deptMap.containsKey(card.getDeptId()) ? deptMap.get(card.getDeptId()).getDeptName() : "";
return new OpenCardListItem(
card.getId(),
card.getCardName(),
card.getCardTitle(),
deptName,
card.getMobile(),
card.getEmail(),
resolveAssetUrl(card.getAvatarAssetId()),
specialtyMap.getOrDefault(card.getId(), List.of())
);
})
.toList();
}
@Transactional
public OpenCardDetailView getOpenCardDetail(Long cardId, String sourceType, Long shareFromCardId, String viewerIp, String pagePath) {
TenantContext tenantContext = TenantContextHolder.getRequired();
CardProfileDO card = cardProfileMapper.selectOne(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileDO::getId, cardId)
.eq(CardProfileDO::getDeleted, 0)
.eq(CardProfileDO::getPublishStatus, "PUBLISHED")
.eq(CardProfileDO::getIsPublic, 1)
.last("LIMIT 1"));
if (card == null || !hasLawyerRole(tenantContext.tenantId(), card.getUserId())) {
throw new BusinessException("CARD_NOT_FOUND", "名片不存在或未公开");
}
card.setViewCount((card.getViewCount() == null ? 0L : card.getViewCount()) + 1);
cardProfileMapper.updateById(card);
cardEventService.recordView(tenantContext.tenantId(), tenantContext.miniappAppId(), card.getId(), sourceType, shareFromCardId, viewerIp, pagePath);
OrgDepartmentDO department = card.getDeptId() == null ? null : orgDepartmentMapper.selectById(card.getDeptId());
OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.<OrgFirmProfileDO>lambdaQuery()
.eq(OrgFirmProfileDO::getTenantId, tenantContext.tenantId())
.eq(OrgFirmProfileDO::getDeleted, 0)
.last("LIMIT 1"));
return new OpenCardDetailView(
card.getId(),
card.getCardName(),
card.getCardTitle(),
department == null ? "" : department.getDeptName(),
card.getMobile(),
card.getEmail(),
card.getOfficeAddress(),
resolveAssetUrl(card.getAvatarAssetId()),
resolveAssetUrl(card.getCoverAssetId()),
resolveAssetUrl(card.getWechatQrAssetId()),
card.getBio(),
loadSpecialtyMap(List.of(card.getId())).getOrDefault(card.getId(), List.of()),
profile == null ? "" : profile.getFirmName(),
profile == null ? "" : profile.getHqAddress(),
profile == null || profile.getHqLatitude() == null ? null : profile.getHqLatitude().doubleValue(),
profile == null || profile.getHqLongitude() == null ? null : profile.getHqLongitude().doubleValue()
);
}
@Transactional
public void recordShare(Long cardId, String shareChannel, String sharePath) {
TenantContext tenantContext = TenantContextHolder.getRequired();
CardProfileDO card = cardProfileMapper.selectOne(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileDO::getId, cardId)
.eq(CardProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (card == null || !hasLawyerRole(tenantContext.tenantId(), card.getUserId())) {
throw new BusinessException("CARD_NOT_FOUND", "名片不存在");
}
card.setShareCount((card.getShareCount() == null ? 0L : card.getShareCount()) + 1);
cardProfileMapper.updateById(card);
cardEventService.recordShare(tenantContext.tenantId(), tenantContext.miniappAppId(), cardId, shareChannel, sharePath);
}
private CardProfileDO getRequiredTenantCard(Long cardId, Long tenantId) {
CardProfileDO card = cardProfileMapper.selectById(cardId);
if (card == null || Integer.valueOf(1).equals(card.getDeleted()) || !tenantId.equals(card.getTenantId())) {
throw new BusinessException("CARD_NOT_FOUND", "名片不存在");
}
return card;
}
private void validateDepartment(Long tenantId, Long deptId) {
if (deptId == null) {
return;
}
OrgDepartmentDO department = orgDepartmentMapper.selectById(deptId);
if (department == null || Integer.valueOf(1).equals(department.getDeleted()) || !tenantId.equals(department.getTenantId())) {
throw new BusinessException("DEPARTMENT_NOT_FOUND", "所属组织不存在");
}
}
private List<CardProfileDO> filterLawyerCards(Long tenantId, List<CardProfileDO> cards) {
if (cards == null || cards.isEmpty()) {
return List.of();
}
Map<Long, String> roleCodeMap = loadUserRoleCodeMap(
tenantId,
cards.stream()
.map(CardProfileDO::getUserId)
.filter(userId -> userId != null && userId > 0)
.distinct()
.toList()
);
return cards.stream()
.filter(card -> isLawyerRoleCode(roleCodeMap.get(card.getUserId())))
.toList();
}
private boolean hasLawyerRole(Long tenantId, Long userId) {
if (userId == null || userId <= 0) {
return false;
}
return isLawyerRoleCode(loadUserRoleCodeMap(tenantId, List.of(userId)).get(userId));
}
private Map<Long, String> loadUserRoleCodeMap(Long tenantId, List<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Map.of();
}
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, tenantId)
.in(SysUserRoleDO::getUserId, userIds));
if (userRoles.isEmpty()) {
return Map.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).distinct().toList();
Map<Long, String> roleIdCodeMap = sysRoleMapper.selectList(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getDeleted, 0)
.in(SysRoleDO::getId, roleIds))
.stream()
.collect(Collectors.toMap(SysRoleDO::getId, SysRoleDO::getRoleCode));
return userRoles.stream().collect(Collectors.toMap(
SysUserRoleDO::getUserId,
item -> roleIdCodeMap.getOrDefault(item.getRoleId(), ""),
(left, right) -> left
));
}
private boolean isLawyerRoleCode(String roleCode) {
return AUTO_MANAGED_ROLE_CODE.equals(roleCode);
}
private SysUserDO createHiddenLawyerUser(LoginUser loginUser, UpsertCardRequest request) {
SysRoleDO role = getRequiredTenantRole(loginUser.tenantId(), AUTO_MANAGED_ROLE_CODE);
SysUserDO user = new SysUserDO();
user.setTenantId(loginUser.tenantId());
user.setUserType("TENANT");
user.setUsername(generateHiddenLawyerUsername(loginUser.tenantId()));
user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
user.setRealName(request.cardName());
user.setNickName(request.cardName());
user.setGender("UNKNOWN");
user.setMobile(request.mobile());
user.setEmail(request.email());
user.setDeptId(request.deptId());
user.setJobTitle(request.cardTitle());
user.setUserStatus("DISABLED");
user.setMustUpdatePassword(0);
user.setRemark(AUTO_MANAGED_LAWYER_REMARK);
user.setCreatedBy(loginUser.userId());
user.setUpdatedBy(loginUser.userId());
user.setDeleted(0);
sysUserMapper.insert(user);
SysUserRoleDO userRole = new SysUserRoleDO();
userRole.setTenantId(loginUser.tenantId());
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRole.setCreatedBy(loginUser.userId());
sysUserRoleMapper.insert(userRole);
return user;
}
private String generateHiddenLawyerUsername(Long tenantId) {
String base = "lawyer_" + tenantId + "_" + System.currentTimeMillis();
for (int i = 0; i < 10; i++) {
String candidate = i == 0 ? base : base + "_" + i;
Long count = sysUserMapper.selectCount(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getUsername, candidate)
.eq(SysUserDO::getDeleted, 0));
if (count == 0) {
return candidate;
}
}
return base + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
}
private SysRoleDO getRequiredTenantRole(Long tenantId, String roleCode) {
SysRoleDO role = sysRoleMapper.selectOne(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getRoleCode, roleCode)
.eq(SysRoleDO::getDeleted, 0)
.eq(SysRoleDO::getRoleStatus, "ENABLED")
.last("LIMIT 1"));
if (role == null) {
throw new BusinessException("ROLE_NOT_FOUND", "角色不存在");
}
return role;
}
private Integer nextCardDisplayOrder(Long tenantId) {
CardProfileDO lastCard = cardProfileMapper.selectOne(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, tenantId)
.eq(CardProfileDO::getDeleted, 0)
.orderByDesc(CardProfileDO::getDisplayOrder)
.orderByDesc(CardProfileDO::getId)
.last("LIMIT 1"));
int baseSort = lastCard == null || lastCard.getDisplayOrder() == null ? 0 : lastCard.getDisplayOrder();
return baseSort + 10;
}
private void applyUpsertRequest(CardProfileDO card, UpsertCardRequest request, Long operatorId, boolean allowChangeUser) {
if (allowChangeUser && request.userId() != null) {
card.setUserId(request.userId());
}
card.setDeptId(request.deptId());
card.setCardName(request.cardName());
card.setCardTitle(request.cardTitle());
card.setMobile(request.mobile());
card.setTelephone(request.telephone());
card.setEmail(request.email());
card.setOfficeAddress(request.officeAddress());
card.setAvatarAssetId(request.avatarAssetId());
card.setCoverAssetId(request.coverAssetId());
card.setWechatQrAssetId(request.wechatQrAssetId());
card.setBio(request.bio());
card.setCertificateNo(request.certificateNo());
card.setEducationInfo(request.educationInfo());
card.setHonorInfo(request.honorInfo());
card.setIsPublic(request.isPublic());
card.setIsRecommended(request.isRecommended());
card.setPublishStatus(request.publishStatus());
card.setDisplayOrder(request.displayOrder());
if ("PUBLISHED".equals(request.publishStatus())) {
card.setLastPublishedAt(LocalDateTime.now());
}
card.setUpdatedBy(operatorId);
}
private void replaceSpecialties(Long tenantId, Long cardId, List<String> specialties, Long operatorId) {
cardProfileSpecialtyMapper.deleteForceByTenantIdAndCardId(tenantId, cardId);
List<String> normalizedSpecialties = normalizeSpecialties(specialties);
if (normalizedSpecialties.isEmpty()) {
return;
}
for (int i = 0; i < normalizedSpecialties.size(); i++) {
String specialty = normalizedSpecialties.get(i);
CardProfileSpecialtyDO specialtyDO = new CardProfileSpecialtyDO();
specialtyDO.setTenantId(tenantId);
specialtyDO.setCardId(cardId);
specialtyDO.setSpecialtyName(specialty);
specialtyDO.setDisplayOrder((i + 1) * 10);
specialtyDO.setCreatedBy(operatorId);
specialtyDO.setUpdatedBy(operatorId);
specialtyDO.setDeleted(0);
cardProfileSpecialtyMapper.insert(specialtyDO);
}
}
private List<String> normalizeSpecialties(List<String> specialties) {
if (specialties == null || specialties.isEmpty()) {
return List.of();
}
Set<String> normalized = new LinkedHashSet<>();
for (String specialty : specialties) {
if (!StringUtils.hasText(specialty)) {
continue;
}
normalized.add(specialty.trim());
}
return List.copyOf(normalized);
}
private CardSummaryView toSummaryView(CardProfileDO card) {
return new CardSummaryView(
card.getId(),
card.getUserId(),
card.getCardName(),
card.getCardTitle(),
card.getMobile(),
card.getEmail(),
card.getPublishStatus(),
card.getDisplayOrder(),
card.getCreatedTime(),
card.getUpdatedTime()
);
}
private CardDetailView toDetailView(CardProfileDO card) {
SysUserDO user = sysUserMapper.selectById(card.getUserId());
OrgDepartmentDO department = card.getDeptId() == null ? null : orgDepartmentMapper.selectById(card.getDeptId());
return new CardDetailView(
card.getId(),
card.getUserId(),
user == null ? "" : user.getUsername(),
card.getDeptId(),
department == null ? "" : department.getDeptName(),
card.getCardName(),
card.getCardTitle(),
card.getMobile(),
card.getTelephone(),
card.getEmail(),
card.getOfficeAddress(),
card.getAvatarAssetId(),
card.getCoverAssetId(),
card.getWechatQrAssetId(),
resolveAssetUrl(card.getAvatarAssetId()),
resolveAssetUrl(card.getCoverAssetId()),
resolveAssetUrl(card.getWechatQrAssetId()),
card.getBio(),
card.getCertificateNo(),
card.getEducationInfo(),
card.getHonorInfo(),
card.getIsPublic(),
card.getIsRecommended(),
card.getPublishStatus(),
card.getDisplayOrder(),
loadSpecialtyMap(List.of(card.getId())).getOrDefault(card.getId(), List.of())
);
}
private Map<Long, List<String>> loadSpecialtyMap(List<Long> cardIds) {
if (cardIds.isEmpty()) {
return Map.of();
}
return cardProfileSpecialtyMapper.selectList(Wrappers.<CardProfileSpecialtyDO>lambdaQuery()
.in(CardProfileSpecialtyDO::getCardId, cardIds)
.eq(CardProfileSpecialtyDO::getDeleted, 0)
.orderByAsc(CardProfileSpecialtyDO::getDisplayOrder, CardProfileSpecialtyDO::getId))
.stream()
.collect(Collectors.groupingBy(
CardProfileSpecialtyDO::getCardId,
Collectors.mapping(CardProfileSpecialtyDO::getSpecialtyName, Collectors.toList())
));
}
private String resolveAssetUrl(Long assetId) {
if (assetId == null) {
return "";
}
FileAssetDO asset = fileAssetMapper.selectById(assetId);
return asset == null ? "" : StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl());
}
public record UpsertCardRequest(
Long userId,
Long deptId,
String cardName,
String cardTitle,
String mobile,
String telephone,
String email,
String officeAddress,
Long avatarAssetId,
Long coverAssetId,
Long wechatQrAssetId,
String bio,
String certificateNo,
String educationInfo,
String honorInfo,
Integer isPublic,
Integer isRecommended,
String publishStatus,
Integer displayOrder,
List<String> specialties
) {
}
public record CardSummaryView(
Long id,
Long userId,
String cardName,
String cardTitle,
String mobile,
String email,
String publishStatus,
Integer displayOrder,
LocalDateTime createdTime,
LocalDateTime updatedTime
) {
}
public record CardDetailView(
Long id,
Long userId,
String username,
Long deptId,
String deptName,
String cardName,
String cardTitle,
String mobile,
String telephone,
String email,
String officeAddress,
Long avatarAssetId,
Long coverAssetId,
Long wechatQrAssetId,
String avatarUrl,
String coverUrl,
String wechatQrUrl,
String bio,
String certificateNo,
String educationInfo,
String honorInfo,
Integer isPublic,
Integer isRecommended,
String publishStatus,
Integer displayOrder,
List<String> specialties
) {
}
public record DashboardStatsView(Integer totalCards, Long publishedCards, Long totalViews, Long totalShares, Long todayViews) {
}
public record CardTrendView(String statDate, Long viewCount, Long shareCount) {
}
public record OpenFirmView(
String name,
String logo,
String heroImage,
String intro,
String hotlinePhone,
String hqAddress,
Double hqLatitude,
Double hqLongitude,
Long officeCount,
Long lawyerCount,
List<String> officeList,
List<String> practiceAreas
) {
}
public record OpenCardListItem(
Long id,
String name,
String title,
String office,
String phone,
String email,
String avatar,
List<String> specialties
) {
}
public record OpenCardDetailView(
Long id,
String name,
String title,
String office,
String phone,
String email,
String address,
String avatar,
String coverImage,
String wechatQrImage,
String bio,
List<String> specialties,
String firmName,
String firmAddress,
Double firmLatitude,
Double firmLongitude
) {
}
}