package com.sinata.appointment.service; import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.EasyExcel; import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.sinata.appointment.config.MiniStpUtil; import com.sinata.appointment.constants.BizErrorCode; import com.sinata.appointment.constants.MiniUserType; import com.sinata.appointment.dto.VisitorUserDetailVo; import com.sinata.appointment.dto.VisitorUserExportRow; import com.sinata.appointment.dto.VisitorUserListVo; import com.sinata.appointment.dto.VisitorUserStatusPatchRequest; import com.sinata.appointment.dto.VisitorUserUpdateRequest; import com.sinata.appointment.entity.AppointmentOrder; import com.sinata.appointment.entity.MpUser; import com.sinata.appointment.exception.BizException; import com.sinata.appointment.mapper.AppointmentOrderMapper; import com.sinata.appointment.mapper.MpUserMapper; import com.sinata.appointment.util.MobileMaskUtil; import com.sinata.appointment.util.PhoneNormalize; import com.sinata.appointment.util.UuidAssert; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @Service @RequiredArgsConstructor public class VisitorUserManageApplicationService { private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private final MpUserMapper mpUserMapper; private final AppointmentOrderMapper appointmentOrderMapper; public IPage page( long page, long size, String userName, String mobile, String companyName, LocalDate registerStartDate, LocalDate registerEndDate, Integer status) { Page result = mpUserMapper.selectPage( new Page<>(page, size), buildVisitorQuery( userName, mobile, companyName, registerStartDate, registerEndDate, status)); List userIds = result.getRecords().stream().map(MpUser::getId).filter(Objects::nonNull).toList(); Map bookingCountMap = loadBookingCountMap(userIds); return result.convert(user -> toListVo(user, bookingCountMap)); } public void exportExcel( HttpServletResponse response, String userName, String mobile, String companyName, LocalDate registerStartDate, LocalDate registerEndDate, Integer status) throws IOException { prepareExcelResponse(response, "visitor-users.xlsx"); long pageNo = 1L; long pageSize = 500L; boolean wroteAnyPage = false; try (ExcelWriter writer = EasyExcel.write(response.getOutputStream(), VisitorUserExportRow.class).build()) { WriteSheet sheet = EasyExcel.writerSheet("访客信息").build(); while (true) { IPage page = mpUserMapper.selectPage( new Page<>(pageNo, pageSize), buildVisitorQuery( userName, mobile, companyName, registerStartDate, registerEndDate, status)); List users = page.getRecords(); if (users == null || users.isEmpty()) { break; } List userIds = users.stream().map(MpUser::getId).filter(Objects::nonNull).toList(); Map bookingCountMap = loadBookingCountMap(userIds); List rows = users.stream().map(user -> toExportRow(user, bookingCountMap)).toList(); writer.write(rows, sheet); wroteAnyPage = true; if (pageNo * pageSize >= page.getTotal()) { break; } pageNo++; } if (!wroteAnyPage) { writer.write(List.of(), sheet); } } } private void prepareExcelResponse(HttpServletResponse response, String filename) { response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setCharacterEncoding("UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=\"" + filename + "\""); } public VisitorUserDetailVo detail(Long id, String uuid) { MpUser user = loadVisitorUserWithUuid(id, uuid); VisitorUserDetailVo vo = new VisitorUserDetailVo(); vo.setId(user.getId()); vo.setUuid(user.getUuid()); vo.setUserName(firstNonBlank(user.getNickName(), "—")); vo.setMobile(user.getMobile()); vo.setGender(trimToNull(user.getGender())); vo.setCompanyName(trimToNull(user.getCompanyName())); vo.setStatus(user.getStatus() == null ? 1 : user.getStatus()); return vo; } @Transactional(rollbackFor = Exception.class) public void update(Long id, VisitorUserUpdateRequest request) { MpUser user = loadVisitorUserWithUuid(id, request.getUuid()); String userName = trimRequired(request.getUserName(), "用户名称不能为空"); if (userName.length() > 30) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户名称不能超过30个字"); } String mobile = trimRequired(request.getMobile(), "用户电话不能为空"); if (!mobile.matches("^\\d{11}$")) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户电话须为11位数字"); } assertMobileUnique(mobile, user.getId()); String gender = trimToNull(request.getGender()); if (StringUtils.hasText(gender) && !"男".equals(gender) && !"女".equals(gender)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别仅支持男/女"); } String companyName = trimToNull(request.getCompanyName()); user.setNickName(userName); user.setMobile(mobile); user.setGender(gender); user.setCompanyName(companyName); mpUserMapper.updateById(user); } @Transactional(rollbackFor = Exception.class) public void patchStatus(Long id, VisitorUserStatusPatchRequest request) { MpUser user = loadVisitorUserWithUuid(id, request.getUuid()); Integer status = request.getStatus(); if (status == null || (status != 0 && status != 1)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "状态无效"); } user.setStatus(status); mpUserMapper.updateById(user); if (status == 0) { kickMiniUserOffline(user.getId()); } } @Transactional(rollbackFor = Exception.class) public void delete(Long id, String uuid) { MpUser user = loadVisitorUserWithUuid(id, uuid); int affected = mpUserMapper.deleteById(user.getId()); if (affected <= 0) { throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试"); } kickMiniUserOffline(user.getId()); } private Map loadBookingCountMap(List userIds) { Map result = new HashMap<>(); if (userIds == null || userIds.isEmpty()) { return result; } List rows = appointmentOrderMapper.selectList( new LambdaQueryWrapper() .eq(AppointmentOrder::getDelFlag, 0) .in(AppointmentOrder::getSubmitterId, userIds)); for (AppointmentOrder row : rows) { if (row.getSubmitterId() != null) { result.merge(row.getSubmitterId(), 1L, Long::sum); } } return result; } private VisitorUserListVo toListVo(MpUser user, Map bookingCountMap) { VisitorUserListVo vo = new VisitorUserListVo(); vo.setId(user.getId()); vo.setUuid(user.getUuid()); vo.setUserName(firstNonBlank(user.getNickName(), "—")); vo.setGender(defaultText(trimToNull(user.getGender()))); vo.setMobileMasked(MobileMaskUtil.mask(user.getMobile())); vo.setCompanyName(defaultText(trimToNull(user.getCompanyName()))); vo.setBookingCount(bookingCountMap.getOrDefault(user.getId(), 0L)); vo.setStatus(user.getStatus() == null ? 1 : user.getStatus()); vo.setCreateTime(user.getCreateTime()); return vo; } private VisitorUserExportRow toExportRow(MpUser user, Map bookingCountMap) { VisitorUserExportRow row = new VisitorUserExportRow(); row.setUserName(firstNonBlank(user.getNickName(), "—")); row.setGender(defaultText(trimToNull(user.getGender()))); row.setMobile(defaultText(trimToNull(user.getMobile()))); row.setCompanyName(defaultText(trimToNull(user.getCompanyName()))); row.setBookingCount(bookingCountMap.getOrDefault(user.getId(), 0L)); row.setStatusText(user.getStatus() != null && user.getStatus() == 0 ? "禁用" : "启用"); row.setCreateTime(formatDateTime(user.getCreateTime())); return row; } private LambdaQueryWrapper buildVisitorQuery( String userName, String mobile, String companyName, LocalDate registerStartDate, LocalDate registerEndDate, Integer status) { LambdaQueryWrapper query = new LambdaQueryWrapper<>(); query.eq(MpUser::getDelFlag, 0).eq(MpUser::getUserType, MiniUserType.VISITOR); query.like(StringUtils.hasText(userName), MpUser::getNickName, trim(userName)); query.like(StringUtils.hasText(mobile), MpUser::getMobile, trim(mobile)); query.like(StringUtils.hasText(companyName), MpUser::getCompanyName, trim(companyName)); query.eq(status != null, MpUser::getStatus, status); query.ge(registerStartDate != null, MpUser::getCreateTime, atStart(registerStartDate)); query.lt(registerEndDate != null, MpUser::getCreateTime, atNextDay(registerEndDate)); query.orderByDesc(MpUser::getCreateTime).orderByDesc(MpUser::getId); return query; } private void assertMobileUnique(String mobileRaw, Long excludeUserId) { String normalized = PhoneNormalize.mainland(mobileRaw); List rows = mpUserMapper.selectList( new LambdaQueryWrapper() .eq(MpUser::getDelFlag, 0) .isNotNull(MpUser::getMobile)); for (MpUser row : rows) { if (excludeUserId != null && excludeUserId.equals(row.getId())) { continue; } if (normalized.equals(PhoneNormalize.mainland(row.getMobile()))) { throw new BizException(BizErrorCode.MP_STAFF_MOBILE_EXISTS, "手机号已存在"); } } } private MpUser loadVisitorUserWithUuid(Long id, String uuid) { MpUser user = mpUserMapper.selectById(id); if (user == null || (user.getDelFlag() != null && user.getDelFlag() == 1)) { throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问"); } if (!MiniUserType.VISITOR.equals(user.getUserType())) { throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问"); } UuidAssert.match(user, uuid); return user; } private static void kickMiniUserOffline(Long mpUserId) { try { MiniStpUtil.kickout(mpUserId); } catch (Exception ignored) { } } private static LocalDateTime atStart(LocalDate date) { return date == null ? null : date.atStartOfDay(); } private static LocalDateTime atNextDay(LocalDate date) { return date == null ? null : date.plusDays(1).atStartOfDay(); } private static String trim(String value) { return value == null ? null : value.trim(); } private static String trimToNull(String value) { String trimmed = trim(value); return StringUtils.hasText(trimmed) ? trimmed : null; } private static String trimRequired(String value, String message) { String trimmed = trimToNull(value); if (!StringUtils.hasText(trimmed)) { throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, message); } return trimmed; } private static String defaultText(String value) { return StringUtils.hasText(value) ? value : "—"; } private static String formatDateTime(LocalDateTime value) { return value == null ? "—" : EXPORT_TIME_FORMATTER.format(value); } @SafeVarargs private static String firstNonBlank(String... values) { if (values == null) { return null; } for (String value : values) { if (StringUtils.hasText(value)) { return value.trim(); } } return null; } }