From 8eb744bf414d12297a36a0aa3c188af4f4dcfa3c Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Tue, 28 Jul 2026 11:53:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=B7=BB=E5=8A=A0=E4=BA=86excel=E7=9A=84d?= =?UTF-8?q?ict=E6=B3=A8=E8=A7=A3=20feat:=E4=BC=98=E5=8C=96=E4=BA=86?= =?UTF-8?q?=E5=AD=A6=E7=94=9F=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pjcloud-common/pjcloud-common-excel/pom.xml | 4 + .../excel/ResponseExcelAutoConfiguration.java | 12 ++ .../common/excel/annotation/ExcelDict.java | 23 +++ .../excel/annotation/ExcelTemplate.java | 18 ++ .../common/excel/aop/ExcelTemplateAspect.java | 194 ++++++++++++++++++ .../dict/ExcelDictDataValidationWriter.java | 138 +++++++++++++ .../common/excel/dict/ExcelDictHelper.java | 40 ++++ .../common/excel/dict/ExcelDictProvider.java | 18 ++ .../handler/DefaultAnalysisEventListener.java | 57 ++++- .../vo/android/ReservationMenuQueryVo.java | 42 ++++ .../impl/ReservationMenuQueryServiceImpl.java | 44 +++- .../impl/order/HardOrderServiceImpl.java | 2 +- .../ReservationMenuQueryServiceImplTest.java | 27 ++- .../admin/api/vo/StudentUserExcelVO.java | 2 + .../admin/config/ExcelDictConfiguration.java | 29 +++ .../admin/controller/SysFileController.java | 2 + .../admin/controller/SysUserController.java | 12 +- .../pjcloud/admin/service/SysUserService.java | 5 + .../admin/service/SysUserStudentService.java | 9 +- .../service/impl/SysUserServiceImpl.java | 81 +++++--- .../impl/user/SysUserIdentityServiceImpl.java | 10 +- .../impl/user/SysUserStudentServiceImpl.java | 5 +- .../resources/file/student_user_import.xlsx | Bin 0 -> 13322 bytes 23 files changed, 725 insertions(+), 49 deletions(-) create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictHelper.java create mode 100644 pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictProvider.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/ExcelDictConfiguration.java create mode 100644 pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx diff --git a/pjcloud-common/pjcloud-common-excel/pom.xml b/pjcloud-common/pjcloud-common-excel/pom.xml index 11eb9a759..78d780b7c 100644 --- a/pjcloud-common/pjcloud-common-excel/pom.xml +++ b/pjcloud-common/pjcloud-common-excel/pom.xml @@ -37,6 +37,10 @@ + + com.bsd + pjcloud-common-core + com.alibaba diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/ResponseExcelAutoConfiguration.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/ResponseExcelAutoConfiguration.java index 0f0f42e54..eea4b6363 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/ResponseExcelAutoConfiguration.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/ResponseExcelAutoConfiguration.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.common.excel; import com.bsd.pjcloud.common.excel.aop.DynamicNameAspect; +import com.bsd.pjcloud.common.excel.aop.ExcelTemplateAspect; import com.bsd.pjcloud.common.excel.aop.RequestExcelArgumentResolver; import com.bsd.pjcloud.common.excel.aop.ResponseExcelReturnValueHandler; import com.bsd.pjcloud.common.excel.config.ExcelConfigProperties; @@ -10,11 +11,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.HandlerMethodReturnValueHandler; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; import jakarta.annotation.PostConstruct; import java.util.ArrayList; @@ -57,6 +60,15 @@ public class ResponseExcelAutoConfiguration { return new DynamicNameAspect(nameProcessor); } + /** + * 静态模板字典下拉增强切面。 + */ + @Bean + @ConditionalOnMissingBean + public ExcelTemplateAspect excelTemplateAspect(ObjectProvider handlerMappingProvider) { + return new ExcelTemplateAspect(handlerMappingProvider); + } + /** * 追加 Excel返回值处理器 到 springmvc 中 */ diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java new file mode 100644 index 000000000..2b88ff1f1 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelDict.java @@ -0,0 +1,23 @@ +package com.bsd.pjcloud.common.excel.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Excel 字典字段。 + *

导入时校验单元格内容是否为字典标签,导出模板时将同一字典标签写入下拉选项。

+ */ +@Documented +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelDict { + + /** + * 字典类型。 + */ + String value(); + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java new file mode 100644 index 000000000..b295cf396 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/annotation/ExcelTemplate.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.common.excel.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Excel 模板增强。 + *

切面自动收集 {@link RequestExcel} 导入参数中的模型,下载接口无需感知文件名或具体业务模型。

+ */ +@Documented +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ExcelTemplate { + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java new file mode 100644 index 000000000..9ba53d648 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/aop/ExcelTemplateAspect.java @@ -0,0 +1,194 @@ +package com.bsd.pjcloud.common.excel.aop; + +import com.alibaba.excel.annotation.ExcelProperty; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; +import com.bsd.pjcloud.common.excel.annotation.ExcelTemplate; +import com.bsd.pjcloud.common.excel.annotation.RequestExcel; +import com.bsd.pjcloud.common.excel.dict.ExcelDictDataValidationWriter; +import jakarta.servlet.http.HttpServletResponse; +import lombok.SneakyThrows; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.core.MethodParameter; +import org.springframework.core.ResolvableType; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.lang.reflect.Field; +import java.util.Arrays; + +/** + * 静态 Excel 模板字典增强切面。 + */ +@Aspect +public class ExcelTemplateAspect { + + private static final String DICT_SHEET_NAME = "_excel_dict"; + + private final ObjectProvider handlerMappingProvider; + + private volatile Class[] modelClasses; + + public ExcelTemplateAspect(ObjectProvider handlerMappingProvider) { + this.handlerMappingProvider = handlerMappingProvider; + } + + /** + * 下载接口仍只负责输出资源;切面缓存响应并按模型表头刷新字典下拉。 + */ + @Around("@annotation(excelTemplate)") + @SneakyThrows + public Object enhanceTemplate(ProceedingJoinPoint joinPoint, ExcelTemplate excelTemplate) { + Object[] args = joinPoint.getArgs(); + if (!isExcelTemplateRequest(args)) { + return joinPoint.proceed(); + } + for (int index = 0; index < args.length; index++) { + if (!(args[index] instanceof HttpServletResponse response)) { + continue; + } + ContentCachingResponseWrapper wrapper = new ContentCachingResponseWrapper(response); + Object[] wrappedArgs = args.clone(); + wrappedArgs[index] = wrapper; + Object result = joinPoint.proceed(wrappedArgs); + wrapper.flushBuffer(); + byte[] content = wrapper.getContentAsByteArray(); + byte[] enhancedContent = enhance(content, getModelClasses()); + response.setContentLength(enhancedContent.length); + response.getOutputStream().write(enhancedContent); + response.getOutputStream().flush(); + return result; + } + throw new IllegalStateException("@ExcelTemplate 方法必须包含 HttpServletResponse 参数"); + } + + private boolean isExcelTemplateRequest(Object[] args) { + for (Object arg : args) { + if (arg instanceof String value) { + String lowerValue = value.toLowerCase(java.util.Locale.ROOT); + if (lowerValue.endsWith(".xlsx") || lowerValue.endsWith(".xls")) { + return true; + } + } + } + return false; + } + + /** + * 导入接口的 {@code @RequestExcel List} 是模板模型的唯一来源,新增导入模型无需再修改下载接口或注册表。 + */ + private Class[] getModelClasses() { + Class[] cachedModelClasses = modelClasses; + if (cachedModelClasses != null) { + return cachedModelClasses; + } + synchronized (this) { + if (modelClasses == null) { + modelClasses = handlerMappingProvider.orderedStream() + .flatMap(handlerMapping -> handlerMapping.getHandlerMethods().values().stream()) + .flatMap(this::resolveRequestExcelModels) + .distinct() + .toArray(Class[]::new); + } + return modelClasses; + } + } + + private java.util.stream.Stream> resolveRequestExcelModels(HandlerMethod handlerMethod) { + return Arrays.stream(handlerMethod.getMethodParameters()) + .filter(parameter -> parameter.hasParameterAnnotation(RequestExcel.class)) + .map(this::resolveListElementType) + .filter(java.util.Objects::nonNull); + } + + private Class resolveListElementType(MethodParameter parameter) { + return ResolvableType.forMethodParameter(parameter).getGeneric(0).resolve(); + } + + private byte[] enhance(byte[] content, Class[] modelClasses) { + if (modelClasses.length == 0) { + return content; + } + Workbook workbook; + try { + workbook = WorkbookFactory.create(new ByteArrayInputStream(content)); + } + catch (Exception exception) { + // 通用资源接口可能下载非 Excel 文件,此时切面不得改变原有下载行为。 + return content; + } + try (workbook; ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + boolean enhanced = false; + for (Class modelClass : modelClasses) { + enhanced |= enhanceModel(workbook, modelClass); + } + if (!enhanced) { + return content; + } + workbook.write(outputStream); + return outputStream.toByteArray(); + } + catch (Exception exception) { + throw new IllegalStateException("Excel 模板字典下拉生成失败", exception); + } + } + + private boolean enhanceModel(Workbook workbook, Class modelClass) { + boolean enhanced = false; + for (Field field : modelClass.getDeclaredFields()) { + ExcelDict excelDict = field.getAnnotation(ExcelDict.class); + ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class); + if (excelDict == null || excelProperty == null || excelProperty.value().length == 0) { + continue; + } + String headerName = excelProperty.value()[excelProperty.value().length - 1]; + // 增强过程中会创建隐藏字典页,固定原始页签数量可避免遍历集合时并发修改。 + int sourceSheetCount = workbook.getNumberOfSheets(); + for (int sheetIndex = 0; sheetIndex < sourceSheetCount; sheetIndex++) { + Sheet sheet = workbook.getSheetAt(sheetIndex); + if (DICT_SHEET_NAME.equals(sheet.getSheetName())) { + continue; + } + HeaderCell headerCell = findHeaderCell(sheet, headerName); + if (headerCell != null) { + ExcelDictDataValidationWriter.write(sheet, headerCell.columnIndex(), headerCell.rowIndex() + 1, + excelDict.value()); + enhanced = true; + } + } + } + return enhanced; + } + + /** + * 模板可能包含说明行或多级表头,因此扫描已有单元格而不是假定表头固定在第一行。 + */ + private HeaderCell findHeaderCell(Sheet sheet, String headerName) { + org.apache.poi.ss.usermodel.DataFormatter dataFormatter = new org.apache.poi.ss.usermodel.DataFormatter(); + for (Row row : sheet) { + if (row.getRowNum() > 20) { + break; + } + for (Cell cell : row) { + if (headerName.equals(dataFormatter.formatCellValue(cell))) { + return new HeaderCell(cell.getRowIndex(), cell.getColumnIndex()); + } + } + } + return null; + } + + private record HeaderCell(int rowIndex, int columnIndex) { + } + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java new file mode 100644 index 000000000..f0837dce8 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictDataValidationWriter.java @@ -0,0 +1,138 @@ +package com.bsd.pjcloud.common.excel.dict; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.DataValidation; +import org.apache.poi.ss.usermodel.DataValidationConstraint; +import org.apache.poi.ss.usermodel.DataValidationHelper; +import org.apache.poi.ss.usermodel.Name; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.SheetVisibility; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.util.CellRangeAddress; +import org.apache.poi.ss.util.CellRangeAddressList; +import org.apache.poi.ss.util.CellReference; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.openxmlformats.schemas.spreadsheetml.x2006.main.CTDataValidation; +import org.openxmlformats.schemas.spreadsheetml.x2006.main.CTDataValidations; + +import java.util.List; + +/** + * Excel 字典下拉写入器。 + */ +public final class ExcelDictDataValidationWriter { + + private static final String DICT_SHEET_NAME = "_excel_dict"; + + private static final String DICT_NAME_PREFIX = "excel_dict_"; + + private ExcelDictDataValidationWriter() { + } + + /** + * 将字典标签写入隐藏页,并为目标列创建引用式下拉。 + */ + public static void write(Sheet dataSheet, int columnIndex, int firstDataRowIndex, String dictType) { + List labels = ExcelDictHelper.getLabels(dictType); + if (labels.isEmpty()) { + throw new IllegalStateException("Excel 字典未配置或没有可用项: " + dictType); + } + Workbook workbook = dataSheet.getWorkbook(); + String formulaName = writeDictColumn(workbook, dictType, labels); + removeColumnValidations(dataSheet, columnIndex); + addValidation(dataSheet, columnIndex, firstDataRowIndex, formulaName); + } + + /** + * 字典项写到隐藏页并通过名称引用,避免显式列表受到 Excel 255 字符限制。 + */ + private static String writeDictColumn(Workbook workbook, String dictType, List labels) { + String formulaName = buildFormulaName(dictType); + Name existingName = workbook.getName(formulaName); + if (existingName != null) { + // 静态模板可能保存过旧字典,名称和隐藏数据都以当前请求查询结果重新生成。 + workbook.removeName(existingName); + } + Sheet dictSheet = workbook.getSheet(DICT_SHEET_NAME); + if (dictSheet == null) { + dictSheet = workbook.createSheet(DICT_SHEET_NAME); + workbook.setSheetVisibility(workbook.getSheetIndex(dictSheet), SheetVisibility.VERY_HIDDEN); + } + int columnIndex = dictSheet.getRow(0) == null ? 0 : dictSheet.getRow(0).getLastCellNum(); + if (columnIndex < 0) { + columnIndex = 0; + } + for (int rowIndex = 0; rowIndex < labels.size(); rowIndex++) { + Row row = dictSheet.getRow(rowIndex); + if (row == null) { + row = dictSheet.createRow(rowIndex); + } + Cell cell = row.createCell(columnIndex); + cell.setCellValue(labels.get(rowIndex)); + } + + Name name = workbook.createName(); + name.setNameName(formulaName); + String columnName = CellReference.convertNumToColString(columnIndex); + name.setRefersToFormula("'" + DICT_SHEET_NAME + "'!$" + columnName + "$1:$" + columnName + "$" + labels.size()); + return formulaName; + } + + private static String buildFormulaName(String dictType) { + String safeType = dictType.replaceAll("[^A-Za-z0-9_.]", "_"); + if (safeType.length() > 120) { + safeType = safeType.substring(0, 120); + } + return DICT_NAME_PREFIX + safeType + "_" + Integer.toUnsignedString(dictType.hashCode(), 36); + } + + /** + * 静态模板可能自带旧下拉,写入当前字典前只移除覆盖目标列的校验规则。 + */ + private static void removeColumnValidations(Sheet sheet, int columnIndex) { + if (!(sheet instanceof XSSFSheet xssfSheet)) { + return; + } + if (!xssfSheet.getCTWorksheet().isSetDataValidations()) { + return; + } + CTDataValidations validations = xssfSheet.getCTWorksheet().getDataValidations(); + for (int index = validations.sizeOfDataValidationArray() - 1; index >= 0; index--) { + if (containsColumn(validations.getDataValidationArray(index), columnIndex)) { + validations.removeDataValidation(index); + } + } + validations.setCount(validations.sizeOfDataValidationArray()); + } + + private static boolean containsColumn(CTDataValidation validation, int columnIndex) { + for (Object reference : validation.getSqref()) { + for (String rangeText : reference.toString().split("\\s+")) { + try { + CellRangeAddress range = CellRangeAddress.valueOf(rangeText.replace("$", "")); + if (columnIndex >= range.getFirstColumn() && columnIndex <= range.getLastColumn()) { + return true; + } + } + catch (IllegalArgumentException ignored) { + // 无法解析的扩展引用交给 POI 原样保留,避免影响模板中其他校验规则。 + } + } + } + return false; + } + + private static void addValidation(Sheet sheet, int columnIndex, int firstDataRowIndex, String formulaName) { + DataValidationHelper helper = sheet.getDataValidationHelper(); + DataValidationConstraint constraint = helper.createFormulaListConstraint(formulaName); + CellRangeAddressList range = new CellRangeAddressList(firstDataRowIndex, + sheet.getWorkbook().getSpreadsheetVersion().getLastRowIndex(), columnIndex, columnIndex); + DataValidation validation = helper.createValidation(constraint, range); + validation.setEmptyCellAllowed(true); + validation.setShowErrorBox(true); + validation.createErrorBox("填写错误", "请选择下拉中的有效字典项"); + sheet.addValidationData(validation); + } + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictHelper.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictHelper.java new file mode 100644 index 000000000..88df8d6fc --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictHelper.java @@ -0,0 +1,40 @@ +package com.bsd.pjcloud.common.excel.dict; + +import com.bsd.pjcloud.common.core.util.SpringContextHolder; +import org.springframework.util.StringUtils; + +import java.util.Collections; +import java.util.List; + +/** + * Excel 字典访问工具。 + */ +public final class ExcelDictHelper { + + private ExcelDictHelper() { + } + + /** + * 从当前应用获取字典标签,并统一过滤空标签与重复标签。 + * @param dictType 字典类型 + * @return 可用于校验或下拉的标签列表 + */ + public static List getLabels(String dictType) { + if (!StringUtils.hasText(dictType)) { + return Collections.emptyList(); + } + ExcelDictProvider provider; + try { + provider = SpringContextHolder.getBean(ExcelDictProvider.class); + } + catch (Exception exception) { + throw new IllegalStateException("未配置 Excel 字典数据提供者", exception); + } + List labels = provider.getLabels(dictType); + if (labels == null) { + return Collections.emptyList(); + } + return labels.stream().filter(StringUtils::hasText).map(String::trim).distinct().toList(); + } + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictProvider.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictProvider.java new file mode 100644 index 000000000..f3651e6f9 --- /dev/null +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/dict/ExcelDictProvider.java @@ -0,0 +1,18 @@ +package com.bsd.pjcloud.common.excel.dict; + +import java.util.List; + +/** + * Excel 字典数据提供者。 + *

公共 Excel 模块只依赖标签列表,具体项目可从数据库、远程服务或缓存中提供字典数据。

+ */ +public interface ExcelDictProvider { + + /** + * 根据字典类型获取可填写的标签列表。 + * @param dictType 字典类型 + * @return 字典标签列表 + */ + List getLabels(String dictType); + +} diff --git a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java index e51856ac1..ade9e13d9 100644 --- a/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java +++ b/pjcloud-common/pjcloud-common-excel/src/main/java/com/bsd/pjcloud/common/excel/handler/DefaultAnalysisEventListener.java @@ -1,15 +1,21 @@ package com.bsd.pjcloud.common.excel.handler; import com.alibaba.excel.context.AnalysisContext; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; import com.bsd.pjcloud.common.excel.annotation.ExcelLine; +import com.bsd.pjcloud.common.excel.dict.ExcelDictHelper; import com.bsd.pjcloud.common.excel.kit.Validators; import com.bsd.pjcloud.common.excel.vo.ErrorMessage; import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; import jakarta.validation.ConstraintViolation; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -27,6 +33,8 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener errorMessageList = new ArrayList<>(); + private final Map> dictLabelCache = new HashMap<>(); + private Long lineNum = 1L; @Override @@ -34,10 +42,11 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener> violations = Validators.validate(o); - if (!violations.isEmpty()) { - Set messageSet = violations.stream() + Set messageSet = violations.stream() .map(ConstraintViolation::getMessage) - .collect(Collectors.toSet()); + .collect(Collectors.toCollection(LinkedHashSet::new)); + messageSet.addAll(validateExcelDict(o)); + if (!messageSet.isEmpty()) { errorMessageList.add(new ErrorMessage(lineNum, messageSet)); } else { @@ -57,6 +66,48 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener validateExcelDict(Object data) { + Set messages = new LinkedHashSet<>(); + for (Field field : data.getClass().getDeclaredFields()) { + ExcelDict excelDict = field.getAnnotation(ExcelDict.class); + if (excelDict == null) { + continue; + } + try { + field.setAccessible(true); + Object fieldValue = field.get(data); + if (fieldValue == null || !StringUtils.hasText(fieldValue.toString())) { + continue; + } + String label = fieldValue.toString().trim(); + List labels = dictLabelCache.computeIfAbsent(excelDict.value(), ExcelDictHelper::getLabels); + String fieldName = getExcelFieldName(field); + if (labels.isEmpty()) { + messages.add(fieldName + "字典未配置或没有可用项"); + } + else if (!labels.contains(label)) { + messages.add(fieldName + ":" + label + ",不在可选项中"); + } + } + catch (IllegalAccessException exception) { + throw new IllegalStateException("读取 Excel 字典字段失败: " + field.getName(), exception); + } + } + return messages; + } + + private String getExcelFieldName(Field field) { + com.alibaba.excel.annotation.ExcelProperty excelProperty = field + .getAnnotation(com.alibaba.excel.annotation.ExcelProperty.class); + if (excelProperty == null || excelProperty.value().length == 0) { + return field.getName(); + } + return excelProperty.value()[excelProperty.value().length - 1]; + } + @Override public void doAfterAllAnalysed(AnalysisContext analysisContext) { log.debug("Excel read analysed"); diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/ReservationMenuQueryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/ReservationMenuQueryVo.java index 7ba52c603..f0fd0e7e6 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/ReservationMenuQueryVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/android/ReservationMenuQueryVo.java @@ -11,36 +11,78 @@ import java.util.List; @Data public class ReservationMenuQueryVo { + /** 脱敏后的用餐人姓名。 */ private String maskedName; + /** 用餐人所属部门或班级名称。 */ private String deptName; + /** 当前终端绑定的食堂名称。 */ private String merchantName; + /** 返回结果中包含预约的日期数量。 */ private Integer totalDayCount; + /** 返回结果中包含预约的餐段数量。 */ private Integer totalMealCount; + /** 按预约日期升序排列的菜单列表。 */ private List days; @Data public static class Day { + /** 预约用餐日期。 */ private LocalDate date; + /** 当天按餐段顺序排列的预约菜单。 */ private List meals; } @Data public static class Meal { + /** 预约餐段编码,沿用硬件订单餐段契约。 */ private String hourType; + /** 预约餐段显示名称。 */ private String mealTitle; + /** 当前餐段所有预约订单的去重餐品汇总,保留用于兼容旧终端。 */ private List items; /** 同一日期餐段可能合并多笔预约,终端按订单逐笔确认核销。 */ private List orderIds; + + /** + * 同一餐段内每笔预约与餐品的明确对应关系。 + * + *

items、orderIds 继续保留给旧终端使用,新终端必须优先读取该字段, + * 避免把餐段汇总餐品误展示为每笔订单都包含的餐品。

+ */ + private List orders; + } + + @Data + public static class Order { + + /** 可核销的预约订单 ID。 */ + private Long orderId; + + /** 该笔预约订单实际购买的商品与数量明细。 */ + private List items; + } + + @Data + public static class OrderItem { + + /** 该笔预约订单购买的商品名称。 */ + private String productName; + + /** 该笔预约订单购买的商品图片地址。 */ + private String productPic; + + /** 该笔预约订单购买的商品数量;历史异常数据缺失时可能为空。 */ + private Integer productCount; } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java index 4aa2feec3..15c2b9502 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImpl.java @@ -156,11 +156,19 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ String hourType = resolveHourType(order); LinkedHashMap meals = grouped.computeIfAbsent(order.getApptUseDate(), ignored -> new LinkedHashMap<>()); MealAccumulator meal = meals.computeIfAbsent(hourType, ignored -> new MealAccumulator(hourType, resolveMealTitle(order, hourType))); - if (order.getId() != null) meal.orderIds().add(order.getId()); + List orderItems = null; + if (order.getId() != null) { + meal.orderIds().add(order.getId()); + orderItems = meal.orderItems().computeIfAbsent(order.getId(), ignored -> new ArrayList<>()); + } if (order.getCarList() == null) continue; for (HardCarVo car : order.getCarList()) { if (car != null && StrUtil.isNotBlank(car.getProductName())) { - meal.items().add(car.getProductName().trim()); + String productName = car.getProductName().trim(); + meal.items().add(productName); + if (orderItems != null) { + orderItems.add(new OrderItemAccumulator(productName, car.getProductPic(), car.getProductCount())); + } } } } @@ -192,6 +200,18 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ meal.setMealTitle(source.mealTitle()); meal.setItems(new ArrayList<>(source.items())); meal.setOrderIds(new ArrayList<>(source.orderIds())); + meal.setOrders(source.orderItems().entrySet().stream().map(entry -> { + ReservationMenuQueryVo.Order order = new ReservationMenuQueryVo.Order(); + order.setOrderId(entry.getKey()); + order.setItems(entry.getValue().stream().map(sourceItem -> { + ReservationMenuQueryVo.OrderItem item = new ReservationMenuQueryVo.OrderItem(); + item.setProductName(sourceItem.productName()); + item.setProductPic(sourceItem.productPic()); + item.setProductCount(sourceItem.productCount()); + return item; + }).toList()); + return order; + }).toList()); return meal; } @@ -231,11 +251,25 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ record TerminalMerchant(MchManageVo merchant) { } - private record MealAccumulator(String hourType, String mealTitle, LinkedHashSet items, - LinkedHashSet orderIds) { + private record MealAccumulator( + /** 餐段编码。 */ String hourType, + /** 餐段显示名称。 */ String mealTitle, + /** 当前餐段所有订单的去重餐品汇总。 */ LinkedHashSet items, + /** 当前餐段可核销的订单 ID 集合。 */ LinkedHashSet orderIds, + /** 每笔订单与其实际商品、购买数量的对应关系。 */ LinkedHashMap> orderItems) { + + /** + * 餐段聚合过程中的强类型临时模型:items 保留旧终端汇总,orderItems 维护新终端逐单餐品。 + */ private MealAccumulator(String hourType, String mealTitle) { - this(hourType, mealTitle, new LinkedHashSet<>(), new LinkedHashSet<>()); + this(hourType, mealTitle, new LinkedHashSet<>(), new LinkedHashSet<>(), new LinkedHashMap<>()); } } + + private record OrderItemAccumulator( + /** 商品名称。 */ String productName, + /** 商品图片地址。 */ String productPic, + /** 购买数量。 */ Integer productCount) { + } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index 0b3ba2fb1..d77961640 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -957,7 +957,7 @@ public class HardOrderServiceImpl extends ServiceImpl 3) { + if (reserveCount >= 20) { throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,请先核销"); } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java index 29ffeffac..35faee7e9 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/ReservationMenuQueryServiceImplTest.java @@ -103,9 +103,12 @@ class ReservationMenuQueryServiceImplTest { stubFaceUser(300L, "王小华", 400L); stubDept(400L, "三年级1班"); when(hardOrderService.scopeList(any(), isNull())).thenReturn(List.of( - order(2L, LocalDate.of(2026, 7, 25), "1", "早餐预约", LocalDateTime.of(2026, 7, 20, 9, 0), "牛奶", "鸡蛋"), - order(1L, LocalDate.of(2026, 7, 24), "2", "午餐预约", LocalDateTime.of(2026, 7, 20, 8, 0), "米饭", "青菜"), - order(3L, LocalDate.of(2026, 7, 24), "2", "午餐预约", LocalDateTime.of(2026, 7, 20, 10, 0), "青菜", "红烧肉"))); + order(2L, LocalDate.of(2026, 7, 25), "1", "早餐预约", LocalDateTime.of(2026, 7, 20, 9, 0), + car("牛奶", "/menu/milk.jpg", 1), car("鸡蛋", "/menu/egg.jpg", 1)), + order(1L, LocalDate.of(2026, 7, 24), "2", "午餐预约", LocalDateTime.of(2026, 7, 20, 8, 0), + car("小青菜", "/menu/vegetable.jpg", 1)), + order(3L, LocalDate.of(2026, 7, 24), "2", "午餐预约", LocalDateTime.of(2026, 7, 20, 10, 0), + car("黄瓜炒鸡蛋", "/menu/cucumber-egg.jpg", 2)))); ReservationMenuQueryVo result = service.query(faceQuery(100L, 300L)); @@ -114,8 +117,16 @@ class ReservationMenuQueryServiceImplTest { assertEquals(LocalDate.of(2026, 7, 24), result.getDays().get(0).getDate()); assertEquals("2", result.getDays().get(0).getMeals().get(0).getHourType()); assertEquals("午餐", result.getDays().get(0).getMeals().get(0).getMealTitle()); - assertEquals(List.of("米饭", "青菜", "红烧肉"), result.getDays().get(0).getMeals().get(0).getItems()); + assertEquals(List.of("小青菜", "黄瓜炒鸡蛋"), result.getDays().get(0).getMeals().get(0).getItems()); assertEquals(List.of(1L, 3L), result.getDays().get(0).getMeals().get(0).getOrderIds()); + assertEquals(1L, result.getDays().get(0).getMeals().get(0).getOrders().get(0).getOrderId()); + assertEquals("小青菜", result.getDays().get(0).getMeals().get(0).getOrders().get(0).getItems().get(0).getProductName()); + assertEquals("/menu/vegetable.jpg", result.getDays().get(0).getMeals().get(0).getOrders().get(0).getItems().get(0).getProductPic()); + assertEquals(1, result.getDays().get(0).getMeals().get(0).getOrders().get(0).getItems().get(0).getProductCount()); + assertEquals(3L, result.getDays().get(0).getMeals().get(0).getOrders().get(1).getOrderId()); + assertEquals("黄瓜炒鸡蛋", result.getDays().get(0).getMeals().get(0).getOrders().get(1).getItems().get(0).getProductName()); + assertEquals("/menu/cucumber-egg.jpg", result.getDays().get(0).getMeals().get(0).getOrders().get(1).getItems().get(0).getProductPic()); + assertEquals(2, result.getDays().get(0).getMeals().get(0).getOrders().get(1).getItems().get(0).getProductCount()); } @Test @@ -242,20 +253,22 @@ class ReservationMenuQueryServiceImplTest { return cancel; } - private static HardOrderVo order(Long id, LocalDate date, String hourType, String title, LocalDateTime createTime, String... items) { + private static HardOrderVo order(Long id, LocalDate date, String hourType, String title, LocalDateTime createTime, HardCarVo... items) { HardOrderVo order = new HardOrderVo(); order.setId(id); order.setApptUseDate(date); order.setHourType(hourType); order.setApptTitle(title); order.setCreateTime(createTime); - order.setCarList(java.util.Arrays.stream(items).map(ReservationMenuQueryServiceImplTest::car).toList()); + order.setCarList(List.of(items)); return order; } - private static HardCarVo car(String productName) { + private static HardCarVo car(String productName, String productPic, int productCount) { HardCarVo car = new HardCarVo(); car.setProductName(productName); + car.setProductPic(productPic); + car.setProductCount(productCount); return car; } } diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java index c693fbf73..b20c514bc 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/StudentUserExcelVO.java @@ -3,6 +3,7 @@ package com.bsd.pjcloud.admin.api.vo; import com.alibaba.excel.annotation.ExcelIgnore; import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.write.style.ColumnWidth; +import com.bsd.pjcloud.common.excel.annotation.ExcelDict; import com.bsd.pjcloud.common.excel.annotation.ExcelLine; import jakarta.validation.constraints.NotBlank; import lombok.Data; @@ -54,6 +55,7 @@ public class StudentUserExcelVO { */ @NotBlank(message = "学校名称") @ExcelProperty("学校名称") + @ExcelDict("user_student_school") private String schoolName; /** diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/ExcelDictConfiguration.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/ExcelDictConfiguration.java new file mode 100644 index 000000000..1b0af5b13 --- /dev/null +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/config/ExcelDictConfiguration.java @@ -0,0 +1,29 @@ +package com.bsd.pjcloud.admin.config; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.bsd.pjcloud.admin.api.entity.SysDictItem; +import com.bsd.pjcloud.admin.service.SysDictItemService; +import com.bsd.pjcloud.common.excel.dict.ExcelDictProvider; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * UPMS Excel 字典配置。 + */ +@Configuration +public class ExcelDictConfiguration { + + /** + * UPMS 服务直接查询当前租户字典,避免本服务通过 Feign 回调自身。 + */ + @Bean + public ExcelDictProvider excelDictProvider(SysDictItemService sysDictItemService) { + return dictType -> sysDictItemService.list(Wrappers.lambdaQuery() + .eq(SysDictItem::getDictType, dictType) + .orderByDesc(SysDictItem::getSortOrder)) + .stream() + .map(SysDictItem::getLabel) + .toList(); + } + +} diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java index 55dedf43c..8279a2159 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysFileController.java @@ -27,6 +27,7 @@ import com.bsd.pjcloud.admin.api.entity.SysFile; import com.bsd.pjcloud.admin.api.entity.SysFileGroup; import com.bsd.pjcloud.admin.service.SysFileService; import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.excel.annotation.ExcelTemplate; import com.bsd.pjcloud.common.log.annotation.SysLog; import com.bsd.pjcloud.common.security.annotation.HasPermission; import com.bsd.pjcloud.common.security.annotation.Inner; @@ -151,6 +152,7 @@ public class SysFileController { * @param response 本地文件 */ @SneakyThrows + @ExcelTemplate @GetMapping("/local/file/{fileName}") public void localFile(@PathVariable String fileName, HttpServletResponse response) { ClassPathResource resource = new ClassPathResource("file/" + fileName); diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 26e40528a..a7a42436d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -273,6 +273,17 @@ public class SysUserController { return R.ok(userService.saveUser(userDto)); } + /** + * 自动生成未被占用的用户登录账号。 + * + * @return 随机登录账号 + */ + @GetMapping("/generate-username") + @HasPermission("sys_user_add") + public R generateUsername() { + return R.ok(userService.generateUniqueSysUserUsername()); + } + /** * 分页查询用户 */ @@ -395,7 +406,6 @@ public class SysUserController { return userService.importUser(excelVOList, bindingResult); } - /** * 导入学生用户 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java index fc9a517d1..6e2394041 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java @@ -101,6 +101,11 @@ public interface SysUserService extends IService { */ Long saveUserWithoutAudit(UserDTO userDto); + /** + * 生成未被占用的系统用户登录账号。 + */ + String generateUniqueSysUserUsername(); + /** * 查询全部的用户 */ diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java index 531c88f66..13f2467d3 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserStudentService.java @@ -6,7 +6,12 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity; public interface SysUserStudentService extends IService { /** - * 判断学号是否已被其他用户使用 + * 判断同一学校内的学号是否已被其他用户使用。 + * + * @param userId 编辑时排除的当前用户 ID + * @param schoolName 学校名称 + * @param studentNo 学号 + * @return 是否存在其他相同学校、相同学号的学生 */ - boolean existsStudentNo(Long userId, String studentNo); + boolean existsStudentNo(Long userId, String schoolName, String studentNo); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java index d5eeb81f3..83a10e622 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java @@ -23,6 +23,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Validator; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.RandomUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.fastjson.JSON; @@ -119,6 +120,12 @@ public class SysUserServiceImpl extends ServiceImpl impl private static final String TAG = "系统用户"; + private static final int RANDOM_USERNAME_LENGTH = 12; + + private static final int RANDOM_USERNAME_MAX_ATTEMPTS = 10; + + private static final int STUDENT_NO_MAX_LENGTH = 255; + private static final PasswordEncoder ENCODER = new BCryptPasswordEncoder(); private final SysMenuService sysMenuService; @@ -693,24 +700,28 @@ public class SysUserServiceImpl extends ServiceImpl impl .stream() .collect(Collectors.toMap(SysUser::getPhone, user -> user, (first, second) -> first)); - // 同时统计 Excel 内重复学号,并批量查询数据库中的学生学号和登录账号占用情况。 + // 学号只要求在同一学校内唯一,按“学校名称 + 学号”统计 Excel 重复并批量查询已有学生。 List studentNoList = studentUserExcelList.stream() .map(StudentUserExcelVO::getStudentNo) .filter(StrUtil::isNotBlank) .toList(); - Map studentNoCountMap = studentNoList.stream() - .collect(Collectors.groupingBy(studentNo -> studentNo, Collectors.counting())); + Map> schoolStudentNoCountMap = studentUserExcelList.stream() + .filter(excel -> StrUtil.isNotBlank(excel.getSchoolName()) && StrUtil.isNotBlank(excel.getStudentNo())) + .collect(Collectors.groupingBy(StudentUserExcelVO::getSchoolName, + Collectors.groupingBy(StudentUserExcelVO::getStudentNo, Collectors.counting()))); List distinctStudentNoList = studentNoList.stream().distinct().toList(); - Set existingStudentNoSet = distinctStudentNoList.isEmpty() ? Set.of() : sysUserStudentMapper.selectList( - Wrappers.lambdaQuery().in(SysUserStudentEntity::getStudentNo, distinctStudentNoList)) + List distinctSchoolNameList = studentUserExcelList.stream() + .map(StudentUserExcelVO::getSchoolName) + .filter(StrUtil::isNotBlank) + .distinct() + .toList(); + Map> existingSchoolStudentNoMap = distinctSchoolNameList.isEmpty() || distinctStudentNoList.isEmpty() + ? Map.of() : sysUserStudentMapper.selectList(Wrappers.lambdaQuery() + .in(SysUserStudentEntity::getSchoolName, distinctSchoolNameList) + .in(SysUserStudentEntity::getStudentNo, distinctStudentNoList)) .stream() - .map(SysUserStudentEntity::getStudentNo) - .collect(Collectors.toSet()); - Set existingUsernameSet = distinctStudentNoList.isEmpty() ? Set.of() : this.list( - Wrappers.lambdaQuery().in(SysUser::getUsername, distinctStudentNoList)) - .stream() - .map(SysUser::getUsername) - .collect(Collectors.toSet()); + .collect(Collectors.groupingBy(SysUserStudentEntity::getSchoolName, + Collectors.mapping(SysUserStudentEntity::getStudentNo, Collectors.toSet()))); // 监护关系属于可配置业务数据,导入前必须确认当前租户已配置并启用 GUARDIAN_WARD。 SysUserRelationTypeEntity guardianWardRelationType = sysUserRelationTypeService.getOne( @@ -731,22 +742,23 @@ public class SysUserServiceImpl extends ServiceImpl impl errorMsg.add("请输入学生姓名"); } - // 学号既是学生扩展信息的唯一标识,也是本次创建用户使用的登录账号。 + // 学号只在当前学校范围内判重;登录账号由系统单独生成,不再复用学号。 if (StrUtil.isBlank(excel.getStudentNo())) { errorMsg.add("请输入学号"); } else { - if (studentNoCountMap.getOrDefault(excel.getStudentNo(), 0L) > 1) { - errorMsg.add("学生,学号:" + excel.getStudentNo() + ",在excel中重复"); + // 学校名称缺失由 Excel 通用校验返回;此处跳过组合判重,避免异常行影响整批导入。 + if (StrUtil.isNotBlank(excel.getSchoolName())) { + Map currentSchoolStudentNoCountMap = schoolStudentNoCountMap.getOrDefault(excel.getSchoolName(), Map.of()); + if (currentSchoolStudentNoCountMap.getOrDefault(excel.getStudentNo(), 0L) > 1) { + errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在excel中重复"); + } + Set currentSchoolStudentNoSet = existingSchoolStudentNoMap.getOrDefault(excel.getSchoolName(), Set.of()); + if (currentSchoolStudentNoSet.contains(excel.getStudentNo())) { + errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在系统中重复"); + } } - if (existingStudentNoSet.contains(excel.getStudentNo())) { - errorMsg.add("学生,学号:" + excel.getStudentNo() + ",在系统中重复"); - } - // 学号同时作为学生登录账号,需避免与非学生用户的现有账号冲突。 - if (existingUsernameSet.contains(excel.getStudentNo())) { - errorMsg.add("学生,学号:" + excel.getStudentNo() + ",已被系统用户登录账号占用"); - } - if (StrUtil.isNotBlank(excel.getStudentNo()) && (StrUtil.length(excel.getStudentNo()) < 5 || StrUtil.length(excel.getStudentNo()) > 60)) { - errorMsg.add("学生,学号:" + excel.getStudentNo() + ",长度必须在5到60个字符之间"); + if (StrUtil.length(excel.getStudentNo()) > STUDENT_NO_MAX_LENGTH) { + errorMsg.add("学生,学号:" + excel.getStudentNo() + ",长度不能超过255个字符"); } } @@ -788,8 +800,8 @@ public class SysUserServiceImpl extends ServiceImpl impl // GUARDIAN_WARD 是有向关系,监护人作为主语,当前新建学生作为关系对象。 relationUserDto.setSubjectUserId(excel.getGuardianUserId()); relationUserDto.setRelationTypeId(guardianWardRelationTypeId); - // 学生没有独立手机号字段,使用学号作为登录账号、监护人手机号作为首次登录密码。 - relationUserDto.setUsername(excel.getStudentNo()); + // 学生登录账号与学号解耦,使用数据库中未占用的随机数字账号,监护人手机号仍作为首次登录密码。 + relationUserDto.setUsername(generateUniqueSysUserUsername()); relationUserDto.setPassword(excel.getGuardianPhone()); relationUserDto.setName(excel.getUsername()); relationUserDto.setNickname(excel.getUsername()); @@ -806,6 +818,23 @@ public class SysUserServiceImpl extends ServiceImpl impl sysUserRelationService.createUserAndRelation(relationUserDto); } + /** + * 生成数据库中未占用的 sys_user 登录账号。 + *

随机账号满足现有用户名长度和字符约束,并在返回前查询数据库规避已有账号冲突。

+ * + * @return 未被占用的随机数字账号 + */ + @Override + public String generateUniqueSysUserUsername() { + for (int attempt = 0; attempt < RANDOM_USERNAME_MAX_ATTEMPTS; attempt++) { + String username = RandomUtil.randomNumbers(RANDOM_USERNAME_LENGTH); + if (!this.exists(Wrappers.lambdaQuery().eq(SysUser::getUsername, username))) { + return username; + } + } + throw new IllegalStateException("自动生成用户账号失败,请重试"); + } + @Override public R importUserFace(MultipartFile file, List excelList, BindingResult bindingResult) { try { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java index def199f9c..2226fe62a 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserIdentityServiceImpl.java @@ -128,14 +128,20 @@ public class SysUserIdentityServiceImpl extends ServiceImpl STUDENT_NO_MAX_LENGTH) { throw new IllegalArgumentException("学号不能超过255个字符"); } - if (sysUserStudentService.existsStudentNo(userDto.getUserId(), studentNo)) { - throw new IllegalArgumentException("学号已存在"); + // 不同学校允许使用相同学号,仅限制同一学校内重复。 + if (sysUserStudentService.existsStudentNo(userDto.getUserId(), schoolName, studentNo)) { + throw new IllegalArgumentException("当前学校下学号已存在"); } studentInfo.setStudentNo(studentNo); + studentInfo.setSchoolName(schoolName); } private void validateDefaultIdentity(UserDTO userDto) { diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserStudentServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserStudentServiceImpl.java index 10d8a4a73..254295d25 100755 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserStudentServiceImpl.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/user/SysUserStudentServiceImpl.java @@ -15,11 +15,12 @@ import org.springframework.stereotype.Service; public class SysUserStudentServiceImpl extends ServiceImpl implements SysUserStudentService { @Override - public boolean existsStudentNo(Long userId, String studentNo) { - if (StrUtil.isBlank(studentNo)) { + public boolean existsStudentNo(Long userId, String schoolName, String studentNo) { + if (StrUtil.isBlank(schoolName) || StrUtil.isBlank(studentNo)) { return false; } return exists(Wrappers.lambdaQuery() + .eq(SysUserStudentEntity::getSchoolName, schoolName.trim()) .eq(SysUserStudentEntity::getStudentNo, studentNo.trim()) .ne(userId != null, SysUserStudentEntity::getUserId, userId)); } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..284f54b7451bea42d18a8c6d806e80d411119582 GIT binary patch literal 13322 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 i_@MhMY6JhF`