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 000000000..284f54b74 Binary files /dev/null and b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/file/student_user_import.xlsx differ