package com.tourguide.modules.user.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.tourguide.common.api.PageResponse; import com.tourguide.common.exception.BusinessException; import com.tourguide.modules.user.dto.GuideAutoDisableConfigRequest; import com.tourguide.modules.user.dto.GuideAutoDisableConfigResponse; import com.tourguide.modules.user.dto.GuideProfileUpdateRequest; import com.tourguide.modules.user.dto.GuideScenicConfigResponse; import com.tourguide.modules.user.dto.GuideUserCreateRequest; import com.tourguide.modules.user.dto.GuideUserDetailResponse; import com.tourguide.modules.user.dto.GuideUserDetailUpdateRequest; import com.tourguide.modules.user.dto.GuideUserResponse; import com.tourguide.modules.user.entity.GuideAutoDisableConfig; 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.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; /** 仅处理导游端账号,避免导游身份与游客或后台管理员混用。 */ @Service public class GuideUserService { public static final int STATUS_DISABLED = 0; public static final int STATUS_ENABLED = 1; 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; private final OperationCityMapper cityMapper; private final PasswordEncoder passwordEncoder; public GuideUserService(GuideUserMapper userMapper, GuideTeamMapper teamMapper, GuideScenicConfigMapper scenicConfigMapper, GuideAutoDisableConfigMapper autoDisableConfigMapper, 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; this.cityMapper = cityMapper; this.passwordEncoder = passwordEncoder; } public GuideUser findEnabledById(Long id) { GuideUser user = userMapper.selectById(id); if (user == null || user.getStatus() == null || user.getStatus() != STATUS_ENABLED) { throw new BusinessException(401, "导游用户不存在或已停用"); } return user; } public GuideUser findWorkbenchAccessibleById(Long id) { GuideUser user = findById(id); if (user.getStatus() == null || user.getStatus() == STATUS_CANCELLED) { throw new BusinessException(401, "导游用户不存在或已注销"); } return user; } public GuideUser findByUsername(String username) { return userMapper.selectOne(new LambdaQueryWrapper().eq(GuideUser::getUsername, username).last("LIMIT 1")); } public GuideUser findByPhone(String phone) { return userMapper.selectOne(new LambdaQueryWrapper() .and(query -> query.eq(GuideUser::getUsername, phone).or().eq(GuideUser::getMobile, phone)) .last("LIMIT 1")); } public PageResponse page(long page, long size, String keyword, Integer status, LocalDate startDate, LocalDate endDate, String ratingLevel, String teamType, Boolean whitelisted) { LambdaQueryWrapper query = new LambdaQueryWrapper(); if (StringUtils.hasText(keyword)) { String searchKeyword = keyword.trim(); query.and(wrapper -> wrapper.like(GuideUser::getUsername, searchKeyword) .or().like(GuideUser::getRealName, searchKeyword) .or().like(GuideUser::getMobile, searchKeyword) .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()); if ("personal".equals(teamType)) query.isNull(GuideUser::getTeamId); if ("team".equals(teamType)) query.isNotNull(GuideUser::getTeamId); if ("high".equals(ratingLevel)) query.ge(GuideUser::getRating, 4); if ("medium".equals(ratingLevel)) query.ge(GuideUser::getRating, 3).lt(GuideUser::getRating, 4); if ("low".equals(ratingLevel)) query.lt(GuideUser::getRating, 3); query.orderByDesc(GuideUser::getId); Page result = userMapper.selectPage(new Page(page, size), query); Map teamNames = loadTeamNames(result.getRecords()); return new PageResponse(result.getRecords().stream() .map(user -> GuideUserResponse.from(user, teamNames.get(user.getTeamId()))) .collect(Collectors.toList()), result.getTotal(), result.getCurrent(), result.getSize()); } public GuideUserDetailResponse detail(Long id) { GuideUser user = findById(id); GuideTeam team = user.getTeamId() == null ? null : teamMapper.selectById(user.getTeamId()); List scenicConfigs = scenicConfigMapper.selectList( new LambdaQueryWrapper().eq(GuideScenicConfig::getGuideId, id).orderByAsc(GuideScenicConfig::getId)); LocalDate birthDate = user.getBirthDate(); Integer workYears = user.getWorkYears(); if (birthDate == null || workYears == null) { GuideApplication application = findLatestApplication(user); 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); } private GuideApplication findLatestApplication(GuideUser user) { if (applicationMapper == null) return null; if (StringUtils.hasText(user.getIdCardNo())) { GuideApplication application = applicationMapper.selectOne(new LambdaQueryWrapper() .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() .eq(GuideApplication::getMobile, user.getMobile().trim()) .in(GuideApplication::getAuditStatus, 0, 1) .orderByDesc(GuideApplication::getId) .last("LIMIT 1")); } @Transactional public List addScenicConfig(Long guideId, Long scenicSpotId) { 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() .eq(GuideTeamScenicConfig::getTeamId, guide.getTeamId()) .eq(GuideTeamScenicConfig::getScenicName, scenicSpot.getScenicName())); if (configured == 0) { throw new BusinessException(400, "只能配置所属讲解队下的景区"); } } @Transactional public List deleteScenicConfig(Long guideId, Long configId) { findById(guideId); GuideScenicConfig config = scenicConfigMapper.selectById(configId); if (config == null || !guideId.equals(config.getGuideId())) { throw new BusinessException(404, "导游景区配置不存在"); } scenicConfigMapper.deleteById(configId); return scenicConfigs(guideId); } private List scenicConfigs(Long guideId) { return scenicConfigMapper.selectList(new LambdaQueryWrapper() .eq(GuideScenicConfig::getGuideId, guideId) .orderByAsc(GuideScenicConfig::getId)) .stream().map(GuideScenicConfigResponse::new).collect(Collectors.toList()); } public GuideUser findById(Long id) { GuideUser user = userMapper.selectById(id); if (user == null) { throw new BusinessException(404, "导游用户不存在"); } return user; } private Map loadTeamNames(List users) { Set teamIds = users.stream().map(GuideUser::getTeamId).filter(id -> id != null).collect(Collectors.toSet()); if (teamIds.isEmpty()) return Collections.emptyMap(); Map names = new HashMap(); for (GuideTeam team : teamMapper.selectBatchIds(teamIds)) names.put(team.getId(), team.getTeamName()); return names; } @Transactional public GuideUserResponse create(GuideUserCreateRequest request) { if (userMapper.selectCount(new LambdaQueryWrapper().eq(GuideUser::getUsername, request.getUsername())) > 0) { throw new BusinessException(409, "导游用户名已存在"); } GuideUser user = new GuideUser(); user.setUsername(request.getUsername()); user.setPassword(passwordEncoder.encode(request.getPassword())); user.setRealName(request.getRealName()); user.setMobile(request.getMobile()); user.setCertificateNo(request.getCertificateNo()); user.setStatus(1); user.setDeleted(0); userMapper.insert(user); return GuideUserResponse.from(user); } /** 审核通过后创建正式导游账号,申请手机号作为登录用户名。 */ @Transactional public GuideUserResponse createFromApprovedApplication(GuideApplication application) { if (!StringUtils.hasText(application.getMobile())) { throw new BusinessException(400, "申请手机号不能为空"); } String username = application.getMobile().trim(); GuideUser existingUser = findByPhone(username); if (existingUser != null && (existingUser.getStatus() == null || existingUser.getStatus() != 2)) { throw new BusinessException(409, "该手机号已存在导游账号"); } ensureUniqueGuideIdentity(application, existingUser); if (existingUser != null) { copyApplicationProfile(existingUser, application); existingUser.setStatus(1); userMapper.updateById(existingUser); return GuideUserResponse.from(existingUser); } GuideUser user = new GuideUser(); user.setUsername(username); user.setPassword(passwordEncoder.encode(DEFAULT_GUIDE_PASSWORD)); copyApplicationProfile(user, application); user.setStatus(1); user.setDeleted(0); userMapper.insert(user); return GuideUserResponse.from(user); } @Transactional public GuideUserResponse createPendingFromApplication(GuideApplication application) { GuideUser user = findByPhone(application.getMobile()); 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() != STATUS_PENDING && user.getStatus() != STATUS_CANCELLED)) { throw new BusinessException(409, "该手机号已存在导游账号"); } ensureUniqueGuideIdentity(application, user); if (user.getStatus() == STATUS_CANCELLED) { user.setStatus(STATUS_PENDING); user.setAutoDisableWhitelisted(0); user.setAutoDisabled(0); user.setDeleted(0); } copyApplicationProfile(user, application); if (user.getId() == null) userMapper.insert(user); else userMapper.updateById(user); return GuideUserResponse.from(user); } private void ensureUniqueGuideIdentity(GuideApplication application, GuideUser existingUser) { if (StringUtils.hasText(application.getCertificateNo()) && userMapper.selectCount(new LambdaQueryWrapper() .eq(GuideUser::getCertificateNo, application.getCertificateNo()) .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() .eq(GuideUser::getIdCardNo, application.getIdCardNo()) .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.setIdCardBackUrl(application.getIdCardBackUrl()); 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 public GuideUserResponse updateProfile(Long id, GuideProfileUpdateRequest request) { GuideUser user = findEnabledById(id); user.setRealName(request.getRealName()); user.setAvatar(request.getAvatar()); user.setCertificateNo(request.getCertificateNo()); userMapper.updateById(user); return GuideUserResponse.from(user); } @Transactional public GuideUserResponse updateAvatar(Long id, String avatar) { GuideUser user = findEnabledById(id); user.setAvatar(avatar); userMapper.updateById(user); 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)) { throw new BusinessException(409, "该手机号已绑定其他导游账号"); } } @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); } userMapper.updateById(user); return GuideUserResponse.from(user); } @Transactional public GuideUserDetailResponse updateDetail(Long id, GuideUserDetailUpdateRequest request) { 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() .eq(GuideTeam::getCollectionGuideId, id)) > 0) { throw new BusinessException(409, "该导游已配置为讲解队收款导游,请先更换收款导游"); } user.setRealName(request.getRealName()); user.setMobile(request.getMobile()); user.setAvatar(request.getAvatar()); user.setCertificateNo(request.getCertificateNo()); user.setGender(request.getGender()); user.setBirthDate(request.getBirthDate()); user.setWorkYears(request.getWorkYears()); user.setIdCardNo(request.getIdCardNo()); user.setIdCardFrontUrl(request.getIdCardFrontUrl()); user.setIdCardBackUrl(request.getIdCardBackUrl()); user.setGuideCertificateUrl(request.getGuideCertificateUrl()); user.setQualificationCertificateUrl(request.getQualificationCertificateUrl()); user.setTeamId(request.getTeamId()); user.setWechatOpenId(request.getWechatOpenId()); userMapper.updateById(user); return detail(id); } @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); } public GuideAutoDisableConfigResponse getAutoDisableConfig() { GuideAutoDisableConfig config = autoDisableConfigMapper.selectById(1); if (config == null) { config = new GuideAutoDisableConfig(); config.setId(1); config.setOrderThreshold(1); config.setRatingThreshold(new java.math.BigDecimal(3.00)); autoDisableConfigMapper.insert(config); } return new GuideAutoDisableConfigResponse(config); } @Transactional public GuideAutoDisableConfigResponse updateAutoDisableConfig(GuideAutoDisableConfigRequest request) { GuideAutoDisableConfig config = autoDisableConfigMapper.selectById(1); if (config == null) { config = new GuideAutoDisableConfig(); config.setId(1); config.setOrderThreshold(request.getOrderThreshold()); config.setRatingThreshold(request.getRatingThreshold()); autoDisableConfigMapper.insert(config); } else { config.setOrderThreshold(request.getOrderThreshold()); config.setRatingThreshold(request.getRatingThreshold()); autoDisableConfigMapper.updateById(config); } return new GuideAutoDisableConfigResponse(autoDisableConfigMapper.selectById(1)); } @Transactional public void updateStatus(Long id, Integer status) { GuideUser user = userMapper.selectForUpdate(id); if (user == null) { throw new BusinessException(404, "导游用户不存在"); } if (!Integer.valueOf(STATUS_ENABLED).equals(status) && teamMapper.selectCount(new LambdaQueryWrapper() .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); } }