feat:添加了excel的dict注解
feat:优化了学生导入
This commit is contained in:
1 parent
bfd1659bdb
commit
8eb744bf41
23 files changed
+725
-49
No files matched your search
@@ -37,6 +37,10 @@
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.bsd</groupId>
|
||||
<artifactId>pjcloud-common-core</artifactId>
|
||||
</dependency>
|
||||
<!-- easyexcel:https://github.com/alibaba/easyexcel -->
|
||||
<dependency>
|
||||
<groupId>com.alibaba</groupId>
|
||||
|
||||
+12
@@ -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<RequestMappingHandlerMapping> handlerMappingProvider) {
|
||||
return new ExcelTemplateAspect(handlerMappingProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加 Excel返回值处理器 到 springmvc 中
|
||||
*/
|
||||
|
||||
+23
@@ -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 字典字段。
|
||||
* <p>导入时校验单元格内容是否为字典标签,导出模板时将同一字典标签写入下拉选项。</p>
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.FIELD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelDict {
|
||||
|
||||
/**
|
||||
* 字典类型。
|
||||
*/
|
||||
String value();
|
||||
|
||||
}
|
||||
+18
@@ -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 模板增强。
|
||||
* <p>切面自动收集 {@link RequestExcel} 导入参数中的模型,下载接口无需感知文件名或具体业务模型。</p>
|
||||
*/
|
||||
@Documented
|
||||
@Target(ElementType.METHOD)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
public @interface ExcelTemplate {
|
||||
|
||||
}
|
||||
+194
@@ -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<RequestMappingHandlerMapping> handlerMappingProvider;
|
||||
|
||||
private volatile Class<?>[] modelClasses;
|
||||
|
||||
public ExcelTemplateAspect(ObjectProvider<RequestMappingHandlerMapping> 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<T>} 是模板模型的唯一来源,新增导入模型无需再修改下载接口或注册表。
|
||||
*/
|
||||
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<Class<?>> 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) {
|
||||
}
|
||||
|
||||
}
|
||||
+138
@@ -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<String> 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<String> 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);
|
||||
}
|
||||
|
||||
}
|
||||
+40
@@ -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<String> 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<String> labels = provider.getLabels(dictType);
|
||||
if (labels == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return labels.stream().filter(StringUtils::hasText).map(String::trim).distinct().toList();
|
||||
}
|
||||
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.bsd.pjcloud.common.excel.dict;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Excel 字典数据提供者。
|
||||
* <p>公共 Excel 模块只依赖标签列表,具体项目可从数据库、远程服务或缓存中提供字典数据。</p>
|
||||
*/
|
||||
public interface ExcelDictProvider {
|
||||
|
||||
/**
|
||||
* 根据字典类型获取可填写的标签列表。
|
||||
* @param dictType 字典类型
|
||||
* @return 字典标签列表
|
||||
*/
|
||||
List<String> getLabels(String dictType);
|
||||
|
||||
}
|
||||
+54
-3
@@ -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<Obje
|
||||
|
||||
private final List<ErrorMessage> errorMessageList = new ArrayList<>();
|
||||
|
||||
private final Map<String, List<String>> dictLabelCache = new HashMap<>();
|
||||
|
||||
private Long lineNum = 1L;
|
||||
|
||||
@Override
|
||||
@@ -34,10 +42,11 @@ public class DefaultAnalysisEventListener extends ListAnalysisEventListener<Obje
|
||||
lineNum++;
|
||||
|
||||
Set<ConstraintViolation<Object>> violations = Validators.validate(o);
|
||||
if (!violations.isEmpty()) {
|
||||
Set<String> messageSet = violations.stream()
|
||||
Set<String> 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<Obje
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 字典字段统一按 Excel 中展示的标签校验,避免业务 Service 为每个字段重复编写字典判断。
|
||||
*/
|
||||
private Set<String> validateExcelDict(Object data) {
|
||||
Set<String> 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<String> 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");
|
||||
|
||||
+42
@@ -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<Day> days;
|
||||
|
||||
@Data
|
||||
public static class Day {
|
||||
|
||||
/** 预约用餐日期。 */
|
||||
private LocalDate date;
|
||||
|
||||
/** 当天按餐段顺序排列的预约菜单。 */
|
||||
private List<Meal> meals;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Meal {
|
||||
|
||||
/** 预约餐段编码,沿用硬件订单餐段契约。 */
|
||||
private String hourType;
|
||||
|
||||
/** 预约餐段显示名称。 */
|
||||
private String mealTitle;
|
||||
|
||||
/** 当前餐段所有预约订单的去重餐品汇总,保留用于兼容旧终端。 */
|
||||
private List<String> items;
|
||||
|
||||
/** 同一日期餐段可能合并多笔预约,终端按订单逐笔确认核销。 */
|
||||
private List<Long> orderIds;
|
||||
|
||||
/**
|
||||
* 同一餐段内每笔预约与餐品的明确对应关系。
|
||||
*
|
||||
* <p>items、orderIds 继续保留给旧终端使用,新终端必须优先读取该字段,
|
||||
* 避免把餐段汇总餐品误展示为每笔订单都包含的餐品。</p>
|
||||
*/
|
||||
private List<Order> orders;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Order {
|
||||
|
||||
/** 可核销的预约订单 ID。 */
|
||||
private Long orderId;
|
||||
|
||||
/** 该笔预约订单实际购买的商品与数量明细。 */
|
||||
private List<OrderItem> items;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class OrderItem {
|
||||
|
||||
/** 该笔预约订单购买的商品名称。 */
|
||||
private String productName;
|
||||
|
||||
/** 该笔预约订单购买的商品图片地址。 */
|
||||
private String productPic;
|
||||
|
||||
/** 该笔预约订单购买的商品数量;历史异常数据缺失时可能为空。 */
|
||||
private Integer productCount;
|
||||
}
|
||||
}
|
||||
+39
-5
@@ -156,11 +156,19 @@ public class ReservationMenuQueryServiceImpl implements ReservationMenuQueryServ
|
||||
String hourType = resolveHourType(order);
|
||||
LinkedHashMap<String, MealAccumulator> 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<OrderItemAccumulator> 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<String> items,
|
||||
LinkedHashSet<Long> orderIds) {
|
||||
private record MealAccumulator(
|
||||
/** 餐段编码。 */ String hourType,
|
||||
/** 餐段显示名称。 */ String mealTitle,
|
||||
/** 当前餐段所有订单的去重餐品汇总。 */ LinkedHashSet<String> items,
|
||||
/** 当前餐段可核销的订单 ID 集合。 */ LinkedHashSet<Long> orderIds,
|
||||
/** 每笔订单与其实际商品、购买数量的对应关系。 */ LinkedHashMap<Long, List<OrderItemAccumulator>> 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) {
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -957,7 +957,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
for (Long dinerUserId : dinerUserIds) {
|
||||
Long reserveCount = isHaveCanteenReserve(dinerUserId);
|
||||
if (reserveCount > 3) {
|
||||
if (reserveCount >= 20) {
|
||||
throw new RuntimeException("用餐人已有" + reserveCount + "笔未核销的预约订单,请先核销");
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
+29
@@ -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.<SysDictItem>lambdaQuery()
|
||||
.eq(SysDictItem::getDictType, dictType)
|
||||
.orderByDesc(SysDictItem::getSortOrder))
|
||||
.stream()
|
||||
.map(SysDictItem::getLabel)
|
||||
.toList();
|
||||
}
|
||||
|
||||
}
|
||||
+2
@@ -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);
|
||||
|
||||
+11
-1
@@ -273,6 +273,17 @@ public class SysUserController {
|
||||
return R.ok(userService.saveUser(userDto));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动生成未被占用的用户登录账号。
|
||||
*
|
||||
* @return 随机登录账号
|
||||
*/
|
||||
@GetMapping("/generate-username")
|
||||
@HasPermission("sys_user_add")
|
||||
public R<String> generateUsername() {
|
||||
return R.ok(userService.generateUniqueSysUserUsername());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询用户
|
||||
*/
|
||||
@@ -395,7 +406,6 @@ public class SysUserController {
|
||||
return userService.importUser(excelVOList, bindingResult);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 导入学生用户
|
||||
*/
|
||||
|
||||
+5
@@ -101,6 +101,11 @@ public interface SysUserService extends IService<SysUser> {
|
||||
*/
|
||||
Long saveUserWithoutAudit(UserDTO userDto);
|
||||
|
||||
/**
|
||||
* 生成未被占用的系统用户登录账号。
|
||||
*/
|
||||
String generateUniqueSysUserUsername();
|
||||
|
||||
/**
|
||||
* 查询全部的用户
|
||||
*/
|
||||
|
||||
+7
-2
@@ -6,7 +6,12 @@ import com.bsd.pjcloud.admin.api.entity.user.SysUserStudentEntity;
|
||||
public interface SysUserStudentService extends IService<SysUserStudentEntity> {
|
||||
|
||||
/**
|
||||
* 判断学号是否已被其他用户使用
|
||||
* 判断同一学校内的学号是否已被其他用户使用。
|
||||
*
|
||||
* @param userId 编辑时排除的当前用户 ID
|
||||
* @param schoolName 学校名称
|
||||
* @param studentNo 学号
|
||||
* @return 是否存在其他相同学校、相同学号的学生
|
||||
*/
|
||||
boolean existsStudentNo(Long userId, String studentNo);
|
||||
boolean existsStudentNo(Long userId, String schoolName, String studentNo);
|
||||
}
|
||||
+55
-26
@@ -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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
|
||||
.stream()
|
||||
.collect(Collectors.toMap(SysUser::getPhone, user -> user, (first, second) -> first));
|
||||
|
||||
// 同时统计 Excel 内重复学号,并批量查询数据库中的学生学号和登录账号占用情况。
|
||||
// 学号只要求在同一学校内唯一,按“学校名称 + 学号”统计 Excel 重复并批量查询已有学生。
|
||||
List<String> studentNoList = studentUserExcelList.stream()
|
||||
.map(StudentUserExcelVO::getStudentNo)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.toList();
|
||||
Map<String, Long> studentNoCountMap = studentNoList.stream()
|
||||
.collect(Collectors.groupingBy(studentNo -> studentNo, Collectors.counting()));
|
||||
Map<String, Map<String, Long>> 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<String> distinctStudentNoList = studentNoList.stream().distinct().toList();
|
||||
Set<String> existingStudentNoSet = distinctStudentNoList.isEmpty() ? Set.of() : sysUserStudentMapper.selectList(
|
||||
Wrappers.<SysUserStudentEntity>lambdaQuery().in(SysUserStudentEntity::getStudentNo, distinctStudentNoList))
|
||||
List<String> distinctSchoolNameList = studentUserExcelList.stream()
|
||||
.map(StudentUserExcelVO::getSchoolName)
|
||||
.filter(StrUtil::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, Set<String>> existingSchoolStudentNoMap = distinctSchoolNameList.isEmpty() || distinctStudentNoList.isEmpty()
|
||||
? Map.of() : sysUserStudentMapper.selectList(Wrappers.<SysUserStudentEntity>lambdaQuery()
|
||||
.in(SysUserStudentEntity::getSchoolName, distinctSchoolNameList)
|
||||
.in(SysUserStudentEntity::getStudentNo, distinctStudentNoList))
|
||||
.stream()
|
||||
.map(SysUserStudentEntity::getStudentNo)
|
||||
.collect(Collectors.toSet());
|
||||
Set<String> existingUsernameSet = distinctStudentNoList.isEmpty() ? Set.of() : this.list(
|
||||
Wrappers.<SysUser>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<SysUserMapper, SysUser> 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<String, Long> currentSchoolStudentNoCountMap = schoolStudentNoCountMap.getOrDefault(excel.getSchoolName(), Map.of());
|
||||
if (currentSchoolStudentNoCountMap.getOrDefault(excel.getStudentNo(), 0L) > 1) {
|
||||
errorMsg.add("学生,学校名称:" + excel.getSchoolName() + ",学号:" + excel.getStudentNo() + ",在excel中重复");
|
||||
}
|
||||
Set<String> 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<SysUserMapper, SysUser> 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<SysUserMapper, SysUser> impl
|
||||
sysUserRelationService.createUserAndRelation(relationUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成数据库中未占用的 sys_user 登录账号。
|
||||
* <p>随机账号满足现有用户名长度和字符约束,并在返回前查询数据库规避已有账号冲突。</p>
|
||||
*
|
||||
* @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.<SysUser>lambdaQuery().eq(SysUser::getUsername, username))) {
|
||||
return username;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("自动生成用户账号失败,请重试");
|
||||
}
|
||||
|
||||
@Override
|
||||
public R importUserFace(MultipartFile file, List<UserExcelFaceVo> excelList, BindingResult bindingResult) {
|
||||
try {
|
||||
|
||||
+8
-2
@@ -128,14 +128,20 @@ public class SysUserIdentityServiceImpl extends ServiceImpl<SysUserIdentityMappe
|
||||
if (studentInfo == null || StrUtil.isBlank(studentInfo.getStudentNo())) {
|
||||
throw new IllegalArgumentException("学号不能为空");
|
||||
}
|
||||
if (StrUtil.isBlank(studentInfo.getSchoolName())) {
|
||||
throw new IllegalArgumentException("学校名称不能为空");
|
||||
}
|
||||
String studentNo = studentInfo.getStudentNo().trim();
|
||||
String schoolName = studentInfo.getSchoolName().trim();
|
||||
if (studentNo.length() > 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) {
|
||||
|
||||
+3
-2
@@ -15,11 +15,12 @@ import org.springframework.stereotype.Service;
|
||||
public class SysUserStudentServiceImpl extends ServiceImpl<SysUserStudentMapper, SysUserStudentEntity> 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.<SysUserStudentEntity>lambdaQuery()
|
||||
.eq(SysUserStudentEntity::getSchoolName, schoolName.trim())
|
||||
.eq(SysUserStudentEntity::getStudentNo, studentNo.trim())
|
||||
.ne(userId != null, SysUserStudentEntity::getUserId, userId));
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in new issue
Block a user