liujie
2026-09-16 a3a56121a476cd23d0f29bc391c07374dc5008f2
backend/src/main/java/com/tourguide/modules/user/service/GuideUserService.java
@@ -16,25 +16,34 @@
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;
/** 仅处理导游端账号,避免导游身份与游客或后台管理员混用。 */
@@ -45,8 +54,11 @@
    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;
@@ -59,8 +71,34 @@
                            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;
@@ -91,6 +129,7 @@
    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"));
    }
@@ -106,6 +145,7 @@
                    .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());
@@ -128,23 +168,63 @@
        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
@@ -204,15 +284,14 @@
        }
        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);
@@ -232,24 +311,60 @@
    @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());
@@ -257,6 +372,31 @@
        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
@@ -277,6 +417,39 @@
        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)) {
@@ -287,21 +460,22 @@
    @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());
@@ -319,10 +493,36 @@
        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);
    }
@@ -357,12 +557,23 @@
    @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);
    }
}