feat:添加了excel的dict注解

feat:优化了学生导入
This commit is contained in:
dzc committed 2026-07-28 11:53:32 +08:00
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>
@@ -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 中
*/
@@ -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();
}
@@ -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 {
}
@@ -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) {
}
}
@@ -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);
}
}
@@ -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();
}
}
@@ -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);
}
@@ -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");
@@ -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;
}
}
@@ -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) {
}
}
@@ -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 + "笔未核销的预约订单,请先核销");
}
}
@@ -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;
}
}
@@ -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;
/**
@@ -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();
}
}
@@ -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);
@@ -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);
}
/**
* 导入学生用户
*/
@@ -101,6 +101,11 @@ public interface SysUserService extends IService<SysUser> {
*/
Long saveUserWithoutAudit(UserDTO userDto);
/**
* 生成未被占用的系统用户登录账号。
*/
String generateUniqueSysUserUsername();
/**
* 查询全部的用户
*/
@@ -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);
}
@@ -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 {
@@ -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) {
@@ -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));
}