package com.sinata.appointment.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.sinata.appointment.constants.AppointmentFieldCodes; import com.sinata.appointment.constants.BizErrorCode; import com.sinata.appointment.dto.MiniVisitorCreateRequest; import com.sinata.appointment.dto.MiniVisitorDetailVo; import com.sinata.appointment.dto.MiniVisitorListVo; import com.sinata.appointment.dto.MiniVisitorUpdateRequest; import com.sinata.appointment.entity.BizFieldConfig; import com.sinata.appointment.entity.MpUser; import com.sinata.appointment.entity.MpVisitor; import com.sinata.appointment.exception.BizException; import com.sinata.appointment.mapper.BizFieldConfigMapper; import com.sinata.appointment.mapper.MpVisitorMapper; import com.sinata.appointment.util.IdCardUtil; import com.sinata.appointment.util.SensitiveDataUtil; import com.sinata.appointment.util.UuidAssert; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor public class MiniVisitorApplicationService { private final MiniAuthApplicationService miniAuthApplicationService; private final MpVisitorMapper mpVisitorMapper; private final BizFieldConfigMapper bizFieldConfigMapper; @Transactional(rollbackFor = Exception.class) public void create(MiniVisitorCreateRequest req) { MpUser current = miniAuthApplicationService.currentMpUser(); Set requiredCodes = loadRequiredMpVisitorFieldCodes(); VisitorNormalizedInput input = normalizeAndValidate( req.getVisitorName(), req.getVisitorMobile(), req.getVisitorCompany(), req.getIdCard(), req.getGender(), requiredCodes); assertIdCardUniqueUnderUser(current.getId(), input.idCard(), null); MpVisitor row = new MpVisitor(); row.setMpUserId(current.getId()); // 兼容当前表结构 NOT NULL:未填字段统一落空字符串 row.setVisitorName(input.name() == null ? "" : input.name()); row.setVisitorMobile(input.mobile() == null ? "" : input.mobile()); row.setVisitorCompany(input.company() == null ? "" : input.company()); row.setIdCardCipher( StringUtils.hasText(input.idCard()) ? SensitiveDataUtil.encryptIdCard(input.idCard()) : ""); row.setGender(input.gender() == null ? "" : input.gender()); row.setIsDefault(hasAnyActiveVisitor(current.getId()) ? 0 : 1); mpVisitorMapper.insert(row); } @Transactional(rollbackFor = Exception.class) public void update(Long id, String uuid, MiniVisitorUpdateRequest req) { MpUser current = miniAuthApplicationService.currentMpUser(); MpVisitor row = loadOwnedVisitor(id, uuid, current.getId()); Set requiredCodes = loadRequiredMpVisitorFieldCodes(); VisitorNormalizedInput input = normalizeAndValidate( req.getVisitorName(), req.getVisitorMobile(), req.getVisitorCompany(), req.getIdCard(), req.getGender(), requiredCodes); assertIdCardUniqueUnderUser(current.getId(), input.idCard(), row.getId()); row.setVisitorName(input.name() == null ? "" : input.name()); row.setVisitorMobile(input.mobile() == null ? "" : input.mobile()); row.setVisitorCompany(input.company() == null ? "" : input.company()); row.setIdCardCipher( StringUtils.hasText(input.idCard()) ? SensitiveDataUtil.encryptIdCard(input.idCard()) : ""); row.setGender(input.gender() == null ? "" : input.gender()); mpVisitorMapper.updateById(row); } @Transactional(rollbackFor = Exception.class) public void setDefault(Long id, String uuid) { MpUser current = miniAuthApplicationService.currentMpUser(); MpVisitor row = loadOwnedVisitor(id, uuid, current.getId()); mpVisitorMapper.update( null, new LambdaUpdateWrapper() .eq(MpVisitor::getMpUserId, current.getId()) .eq(MpVisitor::getDelFlag, 0) .set(MpVisitor::getIsDefault, 0)); row.setIsDefault(1); mpVisitorMapper.updateById(row); } @Transactional(rollbackFor = Exception.class) public void delete(Long id, String uuid) { MpUser current = miniAuthApplicationService.currentMpUser(); MpVisitor row = loadOwnedVisitor(id, uuid, current.getId()); boolean wasDefault = isDefault(row); mpVisitorMapper.deleteById(row.getId()); if (wasDefault) { resetEarliestVisitorAsDefault(current.getId()); } } public MiniVisitorDetailVo detail(Long id, String uuid) { MpUser current = miniAuthApplicationService.currentMpUser(); MpVisitor row = loadOwnedVisitor(id, uuid, current.getId()); MiniVisitorDetailVo vo = new MiniVisitorDetailVo(); vo.setId(row.getId()); vo.setUuid(row.getUuid()); vo.setVisitorName(row.getVisitorName()); vo.setVisitorMobile(row.getVisitorMobile()); vo.setVisitorCompany(row.getVisitorCompany()); vo.setIdCard( StringUtils.hasText(row.getIdCardCipher()) ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher()) : ""); vo.setGender(row.getGender()); vo.setIsDefault(isDefault(row)); return vo; } public List listMine() { MpUser current = miniAuthApplicationService.currentMpUser(); Set requiredCodes = loadRequiredMpVisitorFieldCodes(); List rows = mpVisitorMapper.selectList( new LambdaQueryWrapper() .eq(MpVisitor::getMpUserId, current.getId()) .eq(MpVisitor::getDelFlag, 0) .orderByDesc(MpVisitor::getIsDefault) .orderByDesc(MpVisitor::getCreateTime) .orderByDesc(MpVisitor::getId)); return rows.stream().map(row -> toListVo(row, requiredCodes)).collect(Collectors.toList()); } private MiniVisitorListVo toListVo(MpVisitor row, Set requiredCodes) { MiniVisitorListVo vo = new MiniVisitorListVo(); vo.setId(row.getId()); vo.setUuid(row.getUuid()); vo.setVisitorName(row.getVisitorName()); vo.setGender(row.getGender()); if (StringUtils.hasText(row.getIdCardCipher())) { String plain = SensitiveDataUtil.decryptIdCard(row.getIdCardCipher()); vo.setIdCardMasked(SensitiveDataUtil.maskIdCard(plain)); } else { vo.setIdCardMasked(null); } vo.setVisitorCompany(row.getVisitorCompany()); vo.setIsDefault(isDefault(row)); vo.setMissingFieldCodes(resolveMissingFieldCodes(row, requiredCodes)); return vo; } private boolean isDefault(MpVisitor row) { return row != null && row.getIsDefault() != null && row.getIsDefault() == 1; } private boolean hasAnyActiveVisitor(Long mpUserId) { return mpVisitorMapper.selectCount( new LambdaQueryWrapper() .eq(MpVisitor::getMpUserId, mpUserId) .eq(MpVisitor::getDelFlag, 0)) > 0; } private void resetEarliestVisitorAsDefault(Long mpUserId) { MpVisitor next = mpVisitorMapper.selectOne( new LambdaQueryWrapper() .eq(MpVisitor::getMpUserId, mpUserId) .eq(MpVisitor::getDelFlag, 0) .orderByAsc(MpVisitor::getCreateTime) .orderByAsc(MpVisitor::getId) .last("limit 1")); if (next != null) { next.setIsDefault(1); mpVisitorMapper.updateById(next); } } private List resolveMissingFieldCodes(MpVisitor row, Set requiredCodes) { List missing = new ArrayList<>(); for (String code : requiredCodes) { if (AppointmentFieldCodes.VISITOR_NAME.equals(code) && !StringUtils.hasText(trim(row.getVisitorName()))) { missing.add(code); } else if (AppointmentFieldCodes.ID_CARD.equals(code) && !StringUtils.hasText(trim(row.getIdCardCipher()))) { missing.add(code); } else if (AppointmentFieldCodes.GENDER.equals(code) && !StringUtils.hasText(trim(row.getGender()))) { missing.add(code); } else if (AppointmentFieldCodes.COMPANY.equals(code) && !StringUtils.hasText(trim(row.getVisitorCompany()))) { missing.add(code); } else if (AppointmentFieldCodes.MOBILE.equals(code) && !StringUtils.hasText(trim(row.getVisitorMobile()))) { missing.add(code); } } return missing; } private Set loadRequiredMpVisitorFieldCodes() { List rows = bizFieldConfigMapper.selectList( new LambdaQueryWrapper() .eq(BizFieldConfig::getDelFlag, 0) .eq(BizFieldConfig::getVisible, 1) .eq(BizFieldConfig::getRequiredFlag, 1)); Set supported = Set.of( AppointmentFieldCodes.VISITOR_NAME, AppointmentFieldCodes.ID_CARD, AppointmentFieldCodes.GENDER, AppointmentFieldCodes.COMPANY, AppointmentFieldCodes.MOBILE); return rows.stream() .map(BizFieldConfig::getFieldCode) .filter(StringUtils::hasText) .map(String::trim) .filter(supported::contains) .collect(Collectors.toCollection(LinkedHashSet::new)); } private static String trim(String s) { return s == null ? null : s.trim(); } private static String trimToNull(String s) { String t = trim(s); return (t == null || t.isEmpty()) ? null : t; } private MpVisitor loadOwnedVisitor(Long visitorId, String uuid, Long mpUserId) { MpVisitor row = mpVisitorMapper.selectById(visitorId); if (row == null || (row.getDelFlag() != null && row.getDelFlag() == 1)) { throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "访客不存在"); } UuidAssert.match(row, uuid); if (!mpUserId.equals(row.getMpUserId())) { throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权操作该访客"); } return row; } private VisitorNormalizedInput normalizeAndValidate( String visitorName, String visitorMobile, String visitorCompany, String idCard, String gender, Set requiredCodes) { String name = trimToNull(visitorName); String mobile = trimToNull(visitorMobile); String company = trimToNull(visitorCompany); String idCardPlain = trimToNull(idCard); String genderText = trimToNull(gender); if (requiredCodes.contains(AppointmentFieldCodes.VISITOR_NAME) && !StringUtils.hasText(name)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人姓名不能为空"); } if (StringUtils.hasText(name) && name.length() > 30) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人姓名不能超过30个字"); } if (requiredCodes.contains(AppointmentFieldCodes.MOBILE) && !StringUtils.hasText(mobile)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人电话不能为空"); } if (StringUtils.hasText(mobile) && !mobile.matches("^1[3-9]\\d{9}$")) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人电话格式不正确"); } if (requiredCodes.contains(AppointmentFieldCodes.COMPANY) && !StringUtils.hasText(company)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人单位不能为空"); } if (requiredCodes.contains(AppointmentFieldCodes.ID_CARD) && !StringUtils.hasText(idCardPlain)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "身份证号码不能为空"); } if (StringUtils.hasText(idCardPlain) && !IdCardUtil.isValidMainland18(idCardPlain)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "身份证号码格式不正确"); } if (requiredCodes.contains(AppointmentFieldCodes.GENDER) && !StringUtils.hasText(genderText)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别不能为空"); } if (StringUtils.hasText(genderText) && !"男".equals(genderText) && !"女".equals(genderText)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别仅支持男/女"); } return new VisitorNormalizedInput(name, mobile, company, idCardPlain, genderText); } private void assertIdCardUniqueUnderUser(Long mpUserId, String idCardPlain, Long excludeVisitorId) { String normalizedIdCard = normalizeIdCard(idCardPlain); if (!StringUtils.hasText(normalizedIdCard) || mpUserId == null) { return; } List rows = mpVisitorMapper.selectList( new LambdaQueryWrapper() .eq(MpVisitor::getMpUserId, mpUserId) .eq(MpVisitor::getDelFlag, 0)); for (MpVisitor row : rows) { if (excludeVisitorId != null && excludeVisitorId.equals(row.getId())) { continue; } if (!StringUtils.hasText(row.getIdCardCipher())) { continue; } String existing = normalizeIdCard(SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())); if (normalizedIdCard.equals(existing)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "同一账号下身份证号码不可重复"); } } } private String normalizeIdCard(String idCard) { String value = trimToNull(idCard); return value == null ? null : value.toUpperCase(); } private record VisitorNormalizedInput( String name, String mobile, String company, String idCard, String gender) {} }