| | |
| | | import com.tourguide.modules.user.entity.GuideApplication; |
| | | import com.tourguide.modules.user.entity.GuideScenicConfig; |
| | | import com.tourguide.modules.user.entity.GuideTeam; |
| | | import com.tourguide.modules.user.entity.GuideTeamScenicConfig; |
| | | import com.tourguide.modules.user.entity.GuideUser; |
| | | import com.tourguide.modules.user.mapper.GuideAutoDisableConfigMapper; |
| | | import com.tourguide.modules.user.mapper.GuideScenicConfigMapper; |
| | | import com.tourguide.modules.user.mapper.GuideTeamMapper; |
| | | import com.tourguide.modules.user.mapper.GuideTeamScenicConfigMapper; |
| | | import com.tourguide.modules.user.mapper.GuideUserMapper; |
| | | import com.tourguide.modules.user.mapper.GuideApplicationMapper; |
| | | import com.tourguide.modules.operation.entity.OperationCity; |
| | | import com.tourguide.modules.operation.entity.OperationScenicSpot; |
| | | import com.tourguide.modules.operation.mapper.OperationCityMapper; |
| | | import com.tourguide.modules.operation.mapper.OperationScenicSpotMapper; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | 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.DateTimeException; |
| | | import java.time.LocalDate; |
| | | import java.time.format.DateTimeFormatter; |
| | | import java.util.Collections; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import java.util.UUID; |
| | | import java.util.regex.Matcher; |
| | | import java.util.regex.Pattern; |
| | | import java.util.stream.Collectors; |
| | | |
| | | /** 仅处理导游端账号,避免导游身份与游客或后台管理员混用。 */ |
| | |
| | | public static final int STATUS_PENDING = 2; |
| | | public static final int STATUS_CANCELLED = 3; |
| | | private static final String DEFAULT_GUIDE_PASSWORD = "123456"; |
| | | private static final Pattern WORK_YEAR_START_PATTERN = Pattern.compile("^(\\d+)"); |
| | | private final GuideUserMapper userMapper; |
| | | private final GuideTeamMapper teamMapper; |
| | | private final GuideApplicationMapper applicationMapper; |
| | | private final GuideTeamScenicConfigMapper teamScenicConfigMapper; |
| | | private final GuideScenicConfigMapper scenicConfigMapper; |
| | | private final GuideAutoDisableConfigMapper autoDisableConfigMapper; |
| | | private final OperationScenicSpotMapper scenicSpotMapper; |
| | |
| | | OperationScenicSpotMapper scenicSpotMapper, |
| | | OperationCityMapper cityMapper, |
| | | PasswordEncoder passwordEncoder) { |
| | | this(userMapper, teamMapper, null, null, scenicConfigMapper, autoDisableConfigMapper, |
| | | scenicSpotMapper, cityMapper, passwordEncoder); |
| | | } |
| | | |
| | | public GuideUserService(GuideUserMapper userMapper, GuideTeamMapper teamMapper, |
| | | GuideApplicationMapper applicationMapper, |
| | | GuideScenicConfigMapper scenicConfigMapper, |
| | | GuideAutoDisableConfigMapper autoDisableConfigMapper, |
| | | OperationScenicSpotMapper scenicSpotMapper, |
| | | OperationCityMapper cityMapper, |
| | | PasswordEncoder passwordEncoder) { |
| | | this(userMapper, teamMapper, applicationMapper, null, scenicConfigMapper, autoDisableConfigMapper, |
| | | scenicSpotMapper, cityMapper, passwordEncoder); |
| | | } |
| | | |
| | | @Autowired |
| | | public GuideUserService(GuideUserMapper userMapper, GuideTeamMapper teamMapper, |
| | | GuideApplicationMapper applicationMapper, |
| | | GuideTeamScenicConfigMapper teamScenicConfigMapper, |
| | | GuideScenicConfigMapper scenicConfigMapper, |
| | | GuideAutoDisableConfigMapper autoDisableConfigMapper, |
| | | OperationScenicSpotMapper scenicSpotMapper, |
| | | OperationCityMapper cityMapper, |
| | | PasswordEncoder passwordEncoder) { |
| | | this.userMapper = userMapper; |
| | | this.teamMapper = teamMapper; |
| | | this.applicationMapper = applicationMapper; |
| | | this.teamScenicConfigMapper = teamScenicConfigMapper; |
| | | this.scenicConfigMapper = scenicConfigMapper; |
| | | this.autoDisableConfigMapper = autoDisableConfigMapper; |
| | | this.scenicSpotMapper = scenicSpotMapper; |
| | |
| | | public GuideUser findByPhone(String phone) { |
| | | return userMapper.selectOne(new LambdaQueryWrapper<GuideUser>() |
| | | .and(query -> query.eq(GuideUser::getUsername, phone).or().eq(GuideUser::getMobile, phone)) |
| | | .orderByDesc(GuideUser::getId) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | |
| | | .or().like(GuideUser::getCertificateNo, searchKeyword)); |
| | | } |
| | | query.eq(status != null, GuideUser::getStatus, status) |
| | | .ne(GuideUser::getStatus, STATUS_PENDING) |
| | | .eq(whitelisted != null, GuideUser::getAutoDisableWhitelisted, Boolean.TRUE.equals(whitelisted) ? 1 : 0) |
| | | .ge(startDate != null, GuideUser::getCreatedAt, startDate == null ? null : startDate.atStartOfDay()) |
| | | .lt(endDate != null, GuideUser::getCreatedAt, endDate == null ? null : endDate.plusDays(1).atStartOfDay()); |
| | |
| | | GuideTeam team = user.getTeamId() == null ? null : teamMapper.selectById(user.getTeamId()); |
| | | List<GuideScenicConfig> scenicConfigs = scenicConfigMapper.selectList( |
| | | new LambdaQueryWrapper<GuideScenicConfig>().eq(GuideScenicConfig::getGuideId, id).orderByAsc(GuideScenicConfig::getId)); |
| | | return new GuideUserDetailResponse(user, team == null ? null : team.getTeamName(), scenicConfigs); |
| | | GuideApplication application = findLatestApplication(user); |
| | | LocalDate birthDate = user.getBirthDate(); |
| | | Integer workYears = user.getWorkYears(); |
| | | if (birthDate == null || workYears == null) { |
| | | if (birthDate == null && application != null) birthDate = parseBirthDate(application.getIdCardNo()); |
| | | if (workYears == null && application != null) workYears = parseWorkYears(application.getWorkYearRange()); |
| | | } |
| | | return new GuideUserDetailResponse(user, team == null ? null : team.getTeamName(), scenicConfigs, |
| | | birthDate, workYears, |
| | | application == null ? null : application.getAgeRange(), |
| | | application == null ? null : application.getWorkYearRange()); |
| | | } |
| | | |
| | | private GuideApplication findLatestApplication(GuideUser user) { |
| | | if (applicationMapper == null) return null; |
| | | if (StringUtils.hasText(user.getIdCardNo())) { |
| | | GuideApplication application = applicationMapper.selectOne(new LambdaQueryWrapper<GuideApplication>() |
| | | .eq(GuideApplication::getIdCardNo, user.getIdCardNo().trim()) |
| | | .in(GuideApplication::getAuditStatus, 0, 1) |
| | | .orderByDesc(GuideApplication::getId) |
| | | .last("LIMIT 1")); |
| | | if (application != null) return application; |
| | | } |
| | | if (!StringUtils.hasText(user.getMobile())) return null; |
| | | return applicationMapper.selectOne(new LambdaQueryWrapper<GuideApplication>() |
| | | .eq(GuideApplication::getMobile, user.getMobile().trim()) |
| | | .in(GuideApplication::getAuditStatus, 0, 1) |
| | | .orderByDesc(GuideApplication::getId) |
| | | .last("LIMIT 1")); |
| | | } |
| | | |
| | | @Transactional |
| | | public List<GuideScenicConfigResponse> addScenicConfig(Long guideId, Long scenicSpotId) { |
| | | findById(guideId); |
| | | GuideUser guide = findById(guideId); |
| | | OperationScenicSpot scenicSpot = scenicSpotMapper.selectById(scenicSpotId); |
| | | if (scenicSpot == null || scenicSpot.getStatus() == null || scenicSpot.getStatus() != 1) { |
| | | throw new BusinessException(404, "景区不存在或未启用"); |
| | | } |
| | | OperationCity city = scenicSpot.getCityId() == null ? null : cityMapper.selectById(scenicSpot.getCityId()); |
| | | ensureScenicBelongsToGuideTeam(guide, scenicSpot); |
| | | GuideScenicConfig config = new GuideScenicConfig(); |
| | | config.setGuideId(guideId); |
| | | config.setScenicName(scenicSpot.getScenicName()); |
| | | config.setCityName(city == null ? null : city.getCityName()); |
| | | scenicConfigMapper.upsert(config); |
| | | return scenicConfigs(guideId); |
| | | } |
| | | |
| | | private void ensureScenicBelongsToGuideTeam(GuideUser guide, OperationScenicSpot scenicSpot) { |
| | | if (guide.getTeamId() == null) return; |
| | | long configured = teamScenicConfigMapper.selectCount( |
| | | new LambdaQueryWrapper<GuideTeamScenicConfig>() |
| | | .eq(GuideTeamScenicConfig::getTeamId, guide.getTeamId()) |
| | | .eq(GuideTeamScenicConfig::getScenicName, scenicSpot.getScenicName())); |
| | | if (configured == 0) { |
| | | throw new BusinessException(400, "只能配置所属讲解队下的景区"); |
| | | } |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | } |
| | | String username = application.getMobile().trim(); |
| | | GuideUser existingUser = findByPhone(username); |
| | | if (existingUser != null && existingUser.getStatus() == STATUS_CANCELLED) { |
| | | anonymizeCancelledGuide(existingUser); |
| | | existingUser = null; |
| | | } |
| | | if (existingUser != null && (existingUser.getStatus() == null || existingUser.getStatus() != 2)) { |
| | | throw new BusinessException(409, "该手机号已存在导游账号"); |
| | | } |
| | | if (StringUtils.hasText(application.getCertificateNo()) |
| | | && userMapper.selectCount(new LambdaQueryWrapper<GuideUser>() |
| | | .eq(GuideUser::getCertificateNo, application.getCertificateNo()) |
| | | .ne(existingUser != null, GuideUser::getId, existingUser == null ? null : existingUser.getId())) > 0) { |
| | | throw new BusinessException(409, "该导游证号已存在导游账号"); |
| | | } |
| | | ensureUniqueGuideIdentity(application, existingUser); |
| | | if (existingUser != null) { |
| | | copyApplicationProfile(existingUser, application); |
| | | existingUser.setStatus(1); |
| | |
| | | @Transactional |
| | | public GuideUserResponse createPendingFromApplication(GuideApplication application) { |
| | | GuideUser user = findByPhone(application.getMobile()); |
| | | if (user != null && user.getStatus() == STATUS_CANCELLED) { |
| | | anonymizeCancelledGuide(user); |
| | | user = null; |
| | | } |
| | | if (user == null) { |
| | | user = new GuideUser(); |
| | | user.setUsername(application.getMobile().trim()); |
| | | user.setPassword(passwordEncoder.encode(DEFAULT_GUIDE_PASSWORD)); |
| | | user.setStatus(2); |
| | | user.setDeleted(0); |
| | | } else if (user.getStatus() == null || user.getStatus() != 2) { |
| | | } else if (user.getStatus() == null |
| | | || (user.getStatus() != STATUS_PENDING && user.getStatus() != STATUS_CANCELLED)) { |
| | | throw new BusinessException(409, "该手机号已存在导游账号"); |
| | | } |
| | | ensureUniqueGuideIdentity(application, user); |
| | | copyApplicationProfile(user, application); |
| | | if (user.getId() == null) userMapper.insert(user); else userMapper.updateById(user); |
| | | return GuideUserResponse.from(user); |
| | | } |
| | | |
| | | private void anonymizeCancelledGuide(GuideUser user) { |
| | | String deletedUsername = "deleted_" + UUID.randomUUID().toString().replace("-", "").substring(0, 24); |
| | | userMapper.anonymizeCancelledGuide(user.getId(), deletedUsername); |
| | | } |
| | | |
| | | private void ensureUniqueGuideIdentity(GuideApplication application, GuideUser existingUser) { |
| | | if (StringUtils.hasText(application.getIdCardNo())) { |
| | | // 兼容旧注销数据:释放已注销账号占用的身份证唯一索引。 |
| | | userMapper.clearCancelledIdentityByIdCard(application.getIdCardNo().trim().toUpperCase()); |
| | | } |
| | | if (StringUtils.hasText(application.getCertificateNo()) |
| | | && userMapper.selectCount(new LambdaQueryWrapper<GuideUser>() |
| | | .eq(GuideUser::getCertificateNo, application.getCertificateNo()) |
| | | .ne(GuideUser::getStatus, STATUS_CANCELLED) |
| | | .ne(existingUser != null && existingUser.getId() != null, |
| | | GuideUser::getId, existingUser == null ? null : existingUser.getId())) > 0) { |
| | | throw new BusinessException(409, "该导游证号已存在导游账号"); |
| | | } |
| | | if (StringUtils.hasText(application.getIdCardNo()) |
| | | && userMapper.selectCount(new LambdaQueryWrapper<GuideUser>() |
| | | .eq(GuideUser::getIdCardNo, application.getIdCardNo().trim().toUpperCase()) |
| | | .ne(GuideUser::getStatus, STATUS_CANCELLED) |
| | | .ne(existingUser != null && existingUser.getId() != null, |
| | | GuideUser::getId, existingUser == null ? null : existingUser.getId())) > 0) { |
| | | throw new BusinessException(409, "该身份证号已存在导游账号"); |
| | | } |
| | | } |
| | | |
| | | private void copyApplicationProfile(GuideUser user, GuideApplication application) { |
| | | user.setRealName(application.getRealName()); |
| | | user.setMobile(application.getMobile()); |
| | | user.setGender(application.getGender()); |
| | | user.setBirthDate(parseBirthDate(application.getIdCardNo())); |
| | | user.setWorkYears(parseWorkYears(application.getWorkYearRange())); |
| | | user.setCertificateNo(application.getCertificateNo()); |
| | | user.setIdCardNo(application.getIdCardNo()); |
| | | user.setIdCardFrontUrl(application.getIdCardFrontUrl()); |
| | |
| | | user.setGuideCertificateUrl(application.getCertificateUrl()); |
| | | user.setQualificationCertificateUrl(application.getQualificationCertificateUrl()); |
| | | user.setTeamId(application.getTeamId()); |
| | | } |
| | | |
| | | private LocalDate parseBirthDate(String idCardNo) { |
| | | if (!StringUtils.hasText(idCardNo)) return null; |
| | | String normalized = idCardNo.trim().toUpperCase(); |
| | | if (normalized.length() < 14) return null; |
| | | try { |
| | | return LocalDate.parse(normalized.substring(6, 14), DateTimeFormatter.BASIC_ISO_DATE); |
| | | } catch (DateTimeException exception) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | private Integer parseWorkYears(String workYearRange) { |
| | | if (!StringUtils.hasText(workYearRange)) return null; |
| | | String normalized = workYearRange.trim(); |
| | | Matcher matcher = WORK_YEAR_START_PATTERN.matcher(normalized); |
| | | if (!matcher.find()) return null; |
| | | try { |
| | | int startYears = Integer.parseInt(matcher.group(1)); |
| | | if (startYears > 100) return null; |
| | | return startYears == 1 && normalized.indexOf('-') < 0 ? 0 : startYears; |
| | | } catch (NumberFormatException exception) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | @Transactional |
| | |
| | | return GuideUserResponse.from(user); |
| | | } |
| | | |
| | | @Transactional |
| | | public GuideUserResponse updateAvatarByAdmin(Long id, String avatar) { |
| | | GuideUser user = findById(id); |
| | | user.setAvatar(avatar); |
| | | userMapper.updateById(user); |
| | | return GuideUserResponse.from(user); |
| | | } |
| | | |
| | | public boolean supportsDocumentType(String documentType) { |
| | | return "idCardFrontUrl".equals(documentType) |
| | | || "idCardBackUrl".equals(documentType) |
| | | || "guideCertificateUrl".equals(documentType) |
| | | || "qualificationCertificateUrl".equals(documentType); |
| | | } |
| | | |
| | | @Transactional |
| | | public String updateDocumentByAdmin(Long id, String documentType, String documentUrl) { |
| | | GuideUser user = findById(id); |
| | | if ("idCardFrontUrl".equals(documentType)) { |
| | | user.setIdCardFrontUrl(documentUrl); |
| | | } else if ("idCardBackUrl".equals(documentType)) { |
| | | user.setIdCardBackUrl(documentUrl); |
| | | } else if ("guideCertificateUrl".equals(documentType)) { |
| | | user.setGuideCertificateUrl(documentUrl); |
| | | } else if ("qualificationCertificateUrl".equals(documentType)) { |
| | | user.setQualificationCertificateUrl(documentUrl); |
| | | } else { |
| | | throw new BusinessException(400, "不支持的证件类型"); |
| | | } |
| | | userMapper.updateById(user); |
| | | return documentUrl; |
| | | } |
| | | |
| | | public void ensurePhoneAvailable(Long currentGuideId, String phone) { |
| | | GuideUser existing = findByPhone(phone); |
| | | if (existing != null && !existing.getId().equals(currentGuideId)) { |
| | |
| | | @Transactional |
| | | public GuideUserResponse changeMobile(Long id, String newPhone) { |
| | | GuideUser user = findEnabledById(id); |
| | | ensurePhoneAvailable(id, newPhone); |
| | | String oldPhone = user.getMobile(); |
| | | user.setMobile(newPhone); |
| | | if (oldPhone != null && oldPhone.equals(user.getUsername())) { |
| | | user.setUsername(newPhone); |
| | | } |
| | | updateMobileAndLoginUsername(user, newPhone); |
| | | userMapper.updateById(user); |
| | | return GuideUserResponse.from(user); |
| | | } |
| | | |
| | | @Transactional |
| | | public GuideUserDetailResponse updateDetail(Long id, GuideUserDetailUpdateRequest request) { |
| | | GuideUser user = findById(id); |
| | | GuideUser user = userMapper.selectForUpdate(id); |
| | | if (user == null) throw new BusinessException(404, "导游用户不存在"); |
| | | if (!java.util.Objects.equals(user.getTeamId(), request.getTeamId()) |
| | | && teamMapper.selectCount(new LambdaQueryWrapper<GuideTeam>() |
| | | .eq(GuideTeam::getCollectionGuideId, id)) > 0) { |
| | | throw new BusinessException(409, "该导游已配置为讲解队收款导游,请先更换收款导游"); |
| | | } |
| | | updateMobileAndLoginUsername(user, request.getMobile()); |
| | | user.setRealName(request.getRealName()); |
| | | user.setMobile(request.getMobile()); |
| | | user.setAvatar(request.getAvatar()); |
| | | user.setCertificateNo(request.getCertificateNo()); |
| | | user.setGender(request.getGender()); |
| | |
| | | return detail(id); |
| | | } |
| | | |
| | | private void updateMobileAndLoginUsername(GuideUser user, String newPhone) { |
| | | String oldPhone = user.getMobile(); |
| | | if (!java.util.Objects.equals(oldPhone, newPhone) && StringUtils.hasText(newPhone)) { |
| | | ensurePhoneAvailable(user.getId(), newPhone); |
| | | } |
| | | user.setMobile(newPhone); |
| | | if (oldPhone != null && oldPhone.equals(user.getUsername()) && StringUtils.hasText(newPhone)) { |
| | | user.setUsername(newPhone); |
| | | } |
| | | } |
| | | |
| | | @Transactional |
| | | public void updateWhitelist(Long id, boolean enabled) { |
| | | GuideUser user = findById(id); |
| | | user.setAutoDisableWhitelisted(enabled ? 1 : 0); |
| | | // 将因自动禁用规则停用的导游加入白名单后,立即恢复账号状态。 |
| | | // 仅处理 autoDisabled 标记,避免误启用管理员手动停用的导游。 |
| | | if (enabled |
| | | && Integer.valueOf(STATUS_DISABLED).equals(user.getStatus()) |
| | | && Integer.valueOf(1).equals(user.getAutoDisabled())) { |
| | | user.setStatus(STATUS_ENABLED); |
| | | user.setAutoDisabled(0); |
| | | } |
| | | userMapper.updateById(user); |
| | | } |
| | | |
| | | @Transactional |
| | | public void updatePremiumPermission(Long id, boolean enabled) { |
| | | GuideUser user = findById(id); |
| | | user.setPremiumEnabled(enabled ? 1 : 0); |
| | | userMapper.updateById(user); |
| | | } |
| | | |
| | |
| | | |
| | | @Transactional |
| | | public void updateStatus(Long id, Integer status) { |
| | | GuideUser user = userMapper.selectById(id); |
| | | GuideUser user = userMapper.selectForUpdate(id); |
| | | if (user == null) { throw new BusinessException(404, "导游用户不存在"); } |
| | | if (!Integer.valueOf(STATUS_ENABLED).equals(status) |
| | | && teamMapper.selectCount(new LambdaQueryWrapper<GuideTeam>() |
| | | .eq(GuideTeam::getCollectionGuideId, id)) > 0) { |
| | | throw new BusinessException(409, "该导游已配置为讲解队收款导游,请先更换收款导游"); |
| | | } |
| | | if (user.getStatus() != null && user.getStatus() == STATUS_CANCELLED) { |
| | | throw new BusinessException(409, "已注销导游账号不可重新启用"); |
| | | } |
| | | if (Integer.valueOf(STATUS_ENABLED).equals(status) |
| | | && Integer.valueOf(1).equals(user.getAutoDisabled()) |
| | | && !Integer.valueOf(1).equals(user.getAutoDisableWhitelisted())) { |
| | | throw new BusinessException(409, "该导游由自动禁用规则停用,请先在自动禁用设置中添加白名单后再启用"); |
| | | } |
| | | user.setStatus(status); |
| | | user.setAutoDisabled(0); |
| | | userMapper.updateById(user); |
| | | } |
| | | } |