每日菜谱导入前增加预览效果

This commit is contained in:
zjf committed 2026-09-04 13:46:24 +08:00
1 parent 1271a2d743
commit da07e0f454
29 files changed
+2607 -21

No files matched your search

@@ -0,0 +1,21 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 每日菜谱按日锁定切换
*/
@Data
@Schema(description = "每日菜谱按日锁定")
public class CanteenDailyMenuDayLockDTO {
@Schema(description = "商户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long merchantId;
@Schema(description = "业务日期 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED)
private String bizDate;
@Schema(description = "是否锁定", requiredMode = Schema.RequiredMode.REQUIRED)
private Boolean locked;
}
@@ -0,0 +1,29 @@
package com.bsd.pjcloud.canteen.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/**
* 每日菜谱导入确认请求(前端预览编辑后提交)
*/
@Data
@Schema(description = "每日菜谱导入确认")
public class CanteenDailyMenuImportConfirmDTO {
@Schema(description = "商户ID", requiredMode = Schema.RequiredMode.REQUIRED)
private Long merchantId;
@Schema(description = "兜底分类ID")
private Long categoryId;
@Schema(description = "是否覆盖当日餐段,默认 true")
private Boolean replaceDay = true;
@Schema(description = "原始文件名")
private String fileName;
@Schema(description = "确认导入的行(可含前端编辑后的字段)", requiredMode = Schema.RequiredMode.REQUIRED)
private List<CanteenDailyMenuImportExcel> rows;
}
@@ -0,0 +1,62 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 每日菜谱按日锁定
*/
@Data
@TenantTable
@TableName("canteen_daily_menu_day_lock")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "每日菜谱按日锁定")
public class CanteenDailyMenuDayLockEntity extends Model<CanteenDailyMenuDayLockEntity> {
@TableId(type = IdType.ASSIGN_ID)
@Schema(description = "主键")
private Long id;
@Schema(description = "商户ID")
private Long merchantId;
@Schema(description = "部门ID")
private Long deptId;
@Schema(description = "业务日期")
private LocalDate bizDate;
@Schema(description = "1=已锁定 0=未锁定")
private String locked;
@Schema(description = "锁定人")
private String lockBy;
@Schema(description = "锁定时间")
private LocalDateTime lockTime;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
}
@@ -0,0 +1,63 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
/**
* 每日菜谱导入日志按日明细
*/
@Data
@TenantTable
@TableName("canteen_daily_menu_import_log_day")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "每日菜谱导入日志按日明细")
public class CanteenDailyMenuImportLogDayEntity extends Model<CanteenDailyMenuImportLogDayEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@Schema(description = "导入日志ID")
private Long logId;
@Schema(description = "商户ID")
private Long merchantId;
@Schema(description = "业务日期 yyyy-MM-dd")
private String bizDate;
@Schema(description = "该日新增组数")
private Integer insertCount;
@Schema(description = "该日更新组数")
private Integer updateCount;
@Schema(description = "该日跳过行数")
private Integer skipCount;
@Schema(description = "菜品摘要")
private String dishSummary;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
}
@@ -0,0 +1,95 @@
package com.bsd.pjcloud.canteen.entity;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.bsd.pjcloud.common.core.util.TenantTable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDateTime;
import java.util.List;
/**
* 每日菜谱导入日志
*/
@Data
@TenantTable
@TableName("canteen_daily_menu_import_log")
@EqualsAndHashCode(callSuper = true)
@Schema(description = "每日菜谱导入日志")
public class CanteenDailyMenuImportLogEntity extends Model<CanteenDailyMenuImportLogEntity> {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
@Schema(description = "商户ID")
private Long merchantId;
@Schema(description = "部门ID")
private Long deptId;
@Schema(description = "原始文件名")
private String fileName;
@Schema(description = "是否覆盖当日餐段 1是0否")
private String replaceDay;
@Schema(description = "确认导入有效行数")
private Integer totalRows;
@Schema(description = "新增排菜组数")
private Integer insertCount;
@Schema(description = "更新组数")
private Integer updateCount;
@Schema(description = "因锁定跳过行数")
private Integer skipLockedCount;
@Schema(description = "新建单品数")
private Integer createdDish;
@Schema(description = "新建套餐数")
private Integer createdCombo;
@Schema(description = "涉及起日")
private String dateStart;
@Schema(description = "涉及止日")
private String dateEnd;
@Schema(description = "SUCCESS/FAILED")
private String status;
@Schema(description = "失败原因")
private String errorMsg;
@Schema(description = "操作人账号")
private String operator;
@Schema(description = "操作人姓名")
private String operatorName;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
@TableField(fill = FieldFill.INSERT)
private String delFlag;
private Long tenantId;
@TableField(exist = false)
@Schema(description = "按日明细")
private List<CanteenDailyMenuImportLogDayEntity> dayDetails;
}
@@ -0,0 +1,101 @@
package com.bsd.pjcloud.canteen.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
/**
* 每日菜谱导入预览结果
*/
@Data
@Schema(description = "每日菜谱导入预览")
public class CanteenDailyMenuImportPreviewVO {
@Schema(description = "预览行")
private List<Row> rows = new ArrayList<>();
@Schema(description = "按日汇总")
private List<DaySummary> daySummaries = new ArrayList<>();
@Schema(description = "预计新增组数")
private int totalInsert;
@Schema(description = "预计更新组数")
private int totalUpdate;
@Schema(description = "因锁定跳过行数")
private int totalSkipLocked;
@Schema(description = "原始文件名")
private String fileName;
@Data
@Schema(description = "导入预览行")
public static class Row {
@Schema(description = "行号(解析后)")
private Integer rowNo;
@Schema(description = "日期")
private String bizDate;
@Schema(description = "餐段")
private String mealName;
@Schema(description = "套餐名")
private String comboName;
@Schema(description = "菜品名")
private String dishName;
@Schema(description = "分类")
private String categoryName;
@Schema(description = "单价(元)")
private BigDecimal dishPriceYuan;
@Schema(description = "套餐价(元)")
private BigDecimal priceYuan;
@Schema(description = "库存")
private Integer stock;
@Schema(description = "配料")
private String ingredients;
@Schema(description = "菜谱ID")
private String id;
@Schema(description = "菜品SPU ID")
private String spuId;
/** INSERT / UPDATE / SKIP_LOCKED */
@Schema(description = "动作")
private String action;
@Schema(description = "动作说明")
private String actionLabel;
@Schema(description = "是否已锁定该日")
private boolean locked;
@Schema(description = "默认是否勾选导入")
private boolean selected;
@Schema(description = "提示")
private String tip;
}
@Data
@Schema(description = "按日汇总")
public static class DaySummary {
private String bizDate;
private boolean locked;
private int insertCount;
private int updateCount;
private int skipLockedCount;
private int rowCount;
}
}
@@ -34,8 +34,11 @@ import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuDayLockDTO;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportConfirmDTO;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportExcel;
import com.bsd.pjcloud.canteen.excel.CanteenDailyMenuTemplateMergeHandler;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuLockLogService;
import com.bsd.pjcloud.canteen.service.impl.CanteenDailyMenuImportHelper;
import org.springframework.web.multipart.MultipartFile;
@@ -62,6 +65,8 @@ public class CanteenDailyMenuController {
private final CanteenMerchantScopeHelper merchantScopeHelper;
private final CanteenDailyMenuLockLogService dailyMenuLockLogService;
/**
* 供 Hardware 下单事务批量复核每日菜谱当前可售状态。
*
@@ -167,7 +172,22 @@ public class CanteenDailyMenuController {
@PutMapping
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_edit')")
public R updateById(@RequestBody CanteenDailyMenuEntity canteenDailyMenu) {
return R.ok(canteenDailyMenuService.updateById(canteenDailyMenu));
try {
if (canteenDailyMenu != null && canteenDailyMenu.getId() != null) {
CanteenDailyMenuEntity exist = canteenDailyMenuService.getById(canteenDailyMenu.getId());
if (exist != null) {
dailyMenuLockLogService.assertDayWritable(exist.getMerchantId(), exist.getStartTime());
if (canteenDailyMenu.getStartTime() != null
&& !canteenDailyMenu.getStartTime().equals(exist.getStartTime())) {
dailyMenuLockLogService.assertDayWritable(exist.getMerchantId(), canteenDailyMenu.getStartTime());
}
}
}
return R.ok(canteenDailyMenuService.updateById(canteenDailyMenu));
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
@@ -176,7 +196,12 @@ public class CanteenDailyMenuController {
@PostMapping("/cv")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_add')")
public R cv(@RequestBody CanteenDailyMenuCVDTO creenDailyMenuCVDTO) {
return canteenDailyMenuService.cv(creenDailyMenuCVDTO);
try {
return canteenDailyMenuService.cv(creenDailyMenuCVDTO);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
@Operation(summary = "Excel导入每日菜谱(规范模板)")
@@ -204,6 +229,75 @@ public class CanteenDailyMenuController {
}
}
@Operation(summary = "Excel导入预览(不落库)")
@PostMapping("/import/preview")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_add')")
public R previewImport(@RequestParam("file") MultipartFile file,
@RequestParam("merchantId") Long merchantId,
@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "replaceDay", defaultValue = "true") boolean replaceDay) throws Exception {
try {
return dailyMenuImportHelper.previewExcel(file, merchantId, categoryId, replaceDay);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
@Operation(summary = "确认导入每日菜谱(预览编辑后提交)")
@SysLog("确认导入每日菜谱")
@PostMapping("/import/confirm")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_add')")
public R confirmImport(@RequestBody CanteenDailyMenuImportConfirmDTO dto) {
try {
return dailyMenuImportHelper.confirmImport(dto);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
catch (org.springframework.transaction.UnexpectedRollbackException ex) {
Throwable root = ex.getMostSpecificCause() != null ? ex.getMostSpecificCause() : ex;
String tip = root.getMessage();
if (tip != null && tip.contains("rollback-only")) {
tip = "导入失败并已回滚,请检查套餐组成、分类、菜名是否完整后重试";
}
return R.failed(tip);
}
}
@Operation(summary = "查询日期范围内已锁定日期")
@GetMapping("/dayLock")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')")
public R listDayLocks(@RequestParam("merchantId") Long merchantId,
@RequestParam(value = "startDate", required = false) String startDate,
@RequestParam(value = "endDate", required = false) String endDate) {
return R.ok(dailyMenuLockLogService.listLocks(merchantId, startDate, endDate));
}
@Operation(summary = "锁定/解锁某日菜谱")
@SysLog("锁定/解锁每日菜谱")
@PutMapping("/dayLock")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_edit')")
public R setDayLock(@RequestBody CanteenDailyMenuDayLockDTO dto) {
return dailyMenuLockLogService.setDayLock(dto);
}
@Operation(summary = "分页查询每日菜谱导入日志")
@GetMapping("/import/log/page")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')")
public R pageImportLog(@ParameterObject Page page,
@RequestParam(value = "merchantId", required = false) Long merchantId,
@RequestParam(value = "bizDate", required = false) String bizDate) {
return R.ok(dailyMenuLockLogService.pageImportLogs(page, merchantId, bizDate));
}
@Operation(summary = "每日菜谱导入日志详情")
@GetMapping("/import/log/{id}")
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')")
public R getImportLog(@PathVariable("id") Long id) {
return R.ok(dailyMenuLockLogService.getImportLogDetail(id));
}
/**
* 通过id删除每日菜谱
*1
@@ -215,7 +309,12 @@ public class CanteenDailyMenuController {
@DeleteMapping
@PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_del')")
public R removeById(@RequestBody Long[] ids, @RequestParam Long merchantId) {
return R.ok(canteenDailyMenuService.delByIds(CollUtil.toList(ids), merchantId));
try {
return R.ok(canteenDailyMenuService.delByIds(CollUtil.toList(ids), merchantId));
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuDayLockEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenDailyMenuDayLockMapper extends PjcloudBaseMapper<CanteenDailyMenuDayLockEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogDayEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenDailyMenuImportLogDayMapper extends PjcloudBaseMapper<CanteenDailyMenuImportLogDayEntity> {
}
@@ -0,0 +1,9 @@
package com.bsd.pjcloud.canteen.mapper;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogEntity;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface CanteenDailyMenuImportLogMapper extends PjcloudBaseMapper<CanteenDailyMenuImportLogEntity> {
}
@@ -0,0 +1,58 @@
package com.bsd.pjcloud.canteen.service;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuDayLockDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuDayLockEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogEntity;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/**
* 每日菜谱按日锁定与导入日志
*/
public interface CanteenDailyMenuLockLogService {
/**
* 查询日期范围内已锁定的日期(yyyy-MM-dd)
*/
Set<String> listLockedDates(Long merchantId, LocalDate start, LocalDate end);
/**
* 判断某日是否锁定
*/
boolean isDayLocked(Long merchantId, LocalDate bizDate);
/**
* 断言未锁定,否则抛 IllegalArgumentException
*/
void assertDayWritable(Long merchantId, LocalDate bizDate);
/**
* 批量断言未锁定
*/
void assertDaysWritable(Long merchantId, Collection<LocalDate> dates);
/**
* 设置锁定/解锁
*/
R setDayLock(CanteenDailyMenuDayLockDTO dto);
/**
* 列出日期范围内的锁定记录
*/
List<CanteenDailyMenuDayLockEntity> listLocks(Long merchantId, String startDate, String endDate);
/**
* 分页查询导入日志
*/
Page<CanteenDailyMenuImportLogEntity> pageImportLogs(Page page, Long merchantId, String bizDate);
/**
* 导入日志详情(含按日明细)
*/
CanteenDailyMenuImportLogEntity getImportLogDetail(Long id);
}
@@ -6,19 +6,28 @@ import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportConfirmDTO;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportExcel;
import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogDayEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuComboItemEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogDayMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuComboItemMapper;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuLockLogService;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuService;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.canteen.vo.CanteenDailyMenuImportPreviewVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,6 +40,7 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -39,6 +49,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 每日菜谱 Excel 导入(规范模板)
@@ -63,6 +74,9 @@ public class CanteenDailyMenuImportHelper {
private final CanteenMerchantScopeHelper merchantScopeHelper;
private final CanteenCategoryMapper canteenCategoryMapper;
private final CanteenSpuComboItemMapper canteenSpuComboItemMapper;
private final CanteenDailyMenuLockLogService lockLogService;
private final CanteenDailyMenuImportLogMapper importLogMapper;
private final CanteenDailyMenuImportLogDayMapper importLogDayMapper;
public List<CanteenDailyMenuImportExcel> templateRows() {
List<CanteenDailyMenuImportExcel> list = new ArrayList<>();
@@ -78,6 +92,8 @@ public class CanteenDailyMenuImportHelper {
list.add(row(null, "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30",
"单品-xxx / 单点-xxx 一律按单品,套餐价可留空"));
list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", null));
list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80",
"同一道菜多餐:餐段可写「午餐,晚餐」或「三餐/全天」,系统拆成多条排菜且菜品只建一份"));
return list;
}
@@ -276,14 +292,83 @@ public class CanteenDailyMenuImportHelper {
@Transactional(rollbackFor = Exception.class)
public R importExcel(MultipartFile file, Long merchantId, Long categoryId, boolean replaceDay)
throws IOException {
if (merchantId == null) {
PreparedImport prepared = prepareFromFile(file, merchantId);
if (prepared.failed != null) {
return prepared.failed;
}
String fileName = file != null ? file.getOriginalFilename() : null;
return executeImport(prepared.dataRows, merchantId, categoryId, replaceDay, fileName, true);
}
/**
* 解析 Excel 并返回预览(不落库);已锁定日期标为 SKIP_LOCKED 且默认不勾选。
*/
public R previewExcel(MultipartFile file, Long merchantId, Long categoryId, boolean replaceDay)
throws IOException {
PreparedImport prepared = prepareFromFile(file, merchantId);
if (prepared.failed != null) {
return prepared.failed;
}
try {
CanteenDailyMenuImportPreviewVO vo = buildPreview(prepared.dataRows, merchantId);
vo.setFileName(file != null ? file.getOriginalFilename() : null);
return R.ok(vo);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
}
}
/**
* 前端确认预览后提交最终行数据并落库。
*/
@Transactional(rollbackFor = Exception.class)
public R confirmImport(CanteenDailyMenuImportConfirmDTO dto) {
if (dto == null || dto.getMerchantId() == null) {
return R.failed("请选择商户");
}
if (CollUtil.isEmpty(dto.getRows())) {
return R.failed("请至少确认一行导入数据");
}
boolean replaceDay = dto.getReplaceDay() == null || dto.getReplaceDay();
normalizeMergedCells(dto.getRows());
List<String> errors = new ArrayList<>();
List<CanteenDailyMenuImportExcel> dataRows = new ArrayList<>();
for (CanteenDailyMenuImportExcel row : dto.getRows()) {
if (isBlankRow(row)) {
continue;
}
dataRows.add(row);
}
if (CollUtil.isEmpty(dataRows)) {
return R.failed("请至少确认一行导入数据");
}
if (dataRows.size() > MAX_IMPORT_ROWS) {
return R.failed("有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行");
}
dataRows = expandMultiMealRows(dataRows, errors);
if (CollUtil.isNotEmpty(errors)) {
return R.failed(formatImportErrors(errors));
}
return executeImport(dataRows, dto.getMerchantId(), dto.getCategoryId(), replaceDay, dto.getFileName(), true);
}
/**
* 从上传文件解析出有效数据行(含多餐拆分);失败时 failed 非空。
*/
private PreparedImport prepareFromFile(MultipartFile file, Long merchantId) throws IOException {
PreparedImport out = new PreparedImport();
if (merchantId == null) {
out.failed = R.failed("请选择商户");
return out;
}
if (file == null || file.isEmpty()) {
return R.failed("请上传 Excel 文件");
out.failed = R.failed("请上传 Excel 文件");
return out;
}
if (file.getSize() > MAX_FILE_BYTES) {
return R.failed("文件过大,请控制在 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB 以内");
out.failed = R.failed("文件过大,请控制在 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB 以内");
return out;
}
List<CanteenDailyMenuImportExcel> rows;
@@ -291,16 +376,15 @@ public class CanteenDailyMenuImportHelper {
rows = readRowsStreaming(file);
}
catch (IllegalArgumentException ex) {
return R.failed(ex.getMessage());
out.failed = R.failed(ex.getMessage());
return out;
}
if (CollUtil.isEmpty(rows)) {
return R.failed("导入数据为空");
out.failed = R.failed("导入数据为空");
return out;
}
normalizeMergedCells(rows);
Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId);
Map<String, Long> categoryMap = loadCategoryMap(merchantId);
List<String> errors = new ArrayList<>();
List<CanteenDailyMenuImportExcel> dataRows = new ArrayList<>();
for (CanteenDailyMenuImportExcel row : rows) {
@@ -309,15 +393,199 @@ public class CanteenDailyMenuImportHelper {
}
dataRows.add(row);
}
rows = null;
if (CollUtil.isEmpty(dataRows)) {
return R.failed("导入数据为空");
out.failed = R.failed("导入数据为空");
return out;
}
if (dataRows.size() > MAX_IMPORT_ROWS) {
return R.failed("有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行");
out.failed = R.failed("有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行");
return out;
}
// groupKey = date|hourType|comboOrEmpty|menuIdOrNew
dataRows = expandMultiMealRows(dataRows, errors);
if (CollUtil.isNotEmpty(errors)) {
out.failed = R.failed(formatImportErrors(errors));
return out;
}
if (dataRows.size() > MAX_IMPORT_ROWS) {
out.failed = R.failed("拆分多餐后有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行,请减少一次导入量");
return out;
}
out.dataRows = dataRows;
return out;
}
/**
* 构建导入预览:按组判定新增/更新,锁定日跳过。
*/
private CanteenDailyMenuImportPreviewVO buildPreview(List<CanteenDailyMenuImportExcel> dataRows, Long merchantId) {
CanteenDailyMenuImportPreviewVO vo = new CanteenDailyMenuImportPreviewVO();
List<String> errors = new ArrayList<>();
Map<String, List<CanteenDailyMenuImportExcel>> groups = new LinkedHashMap<>();
Map<CanteenDailyMenuImportExcel, Integer> rowNos = new HashMap<>();
Set<LocalDate> involvedDates = new HashSet<>();
Set<Long> excelMenuIds = new HashSet<>();
int line = 1;
for (CanteenDailyMenuImportExcel row : dataRows) {
line++;
rowNos.put(row, line);
Integer hourType = parseMeal(row.getMealName());
LocalDate date = parseDate(row.getBizDate());
String dish = StrUtil.trim(row.getDishName());
if (date == null) {
errors.add("第" + line + "行:日期格式应为 yyyy-MM-dd");
continue;
}
if (hourType == null) {
errors.add("第" + line + "行:餐段应为 早餐/午餐/晚餐,或多餐如「午餐,晚餐」「三餐」");
continue;
}
if (StrUtil.isBlank(dish)) {
errors.add("第" + line + "行:菜品名不能为空");
continue;
}
involvedDates.add(date);
String combo = StrUtil.trim(row.getComboName());
String comboKey = StrUtil.blankToDefault(combo, "");
Long menuId = parseExcelLong(row.getId());
if (menuId != null) {
excelMenuIds.add(menuId);
}
String groupKey;
if (menuId != null) {
groupKey = "id:" + menuId + "|" + date + "|" + hourType;
}
else if (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey)) {
groupKey = "new:" + date + "|" + hourType + "|" + comboKey;
}
else {
groupKey = "new:" + date + "|" + hourType + "|" + dish;
}
groups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(row);
}
if (CollUtil.isNotEmpty(errors)) {
throw new IllegalArgumentException(formatImportErrors(errors));
}
LocalDate min = involvedDates.stream().min(LocalDate::compareTo).orElse(null);
LocalDate max = involvedDates.stream().max(LocalDate::compareTo).orElse(null);
Set<String> lockedDates = (min != null && max != null)
? lockLogService.listLockedDates(merchantId, min, max)
: Set.of();
ImportCache cache = buildImportCache(merchantId, involvedDates, excelMenuIds);
Map<String, CanteenDailyMenuImportPreviewVO.DaySummary> dayMap = new LinkedHashMap<>();
int totalInsert = 0;
int totalUpdate = 0;
int totalSkip = 0;
for (Map.Entry<String, List<CanteenDailyMenuImportExcel>> e : groups.entrySet()) {
List<CanteenDailyMenuImportExcel> items = e.getValue();
CanteenDailyMenuImportExcel firstRow = items.get(0);
LocalDate groupDate = parseDate(firstRow.getBizDate());
Integer groupHour = parseMeal(firstRow.getMealName());
String dateStr = groupDate != null ? groupDate.format(DATE_FMT) : StrUtil.trim(firstRow.getBizDate());
boolean dayLocked = dateStr != null && lockedDates.contains(dateStr);
Long menuId = items.stream().map(CanteenDailyMenuImportExcel::getId).map(CanteenDailyMenuImportHelper::parseExcelLong)
.filter(Objects::nonNull).findFirst().orElse(null);
boolean isUpdate = false;
if (!dayLocked && menuId != null) {
CanteenDailyMenuEntity existMenu = resolveMenu(menuId, cache);
boolean matched = existMenu != null
&& merchantId.equals(existMenu.getMerchantId())
&& (groupDate == null || existMenu.getStartTime() == null
|| groupDate.equals(existMenu.getStartTime()))
&& (groupHour == null || existMenu.getHourType() == null
|| groupHour.equals(existMenu.getHourType()));
isUpdate = matched;
}
String action;
String actionLabel;
String tip;
boolean selected;
if (dayLocked) {
action = "SKIP_LOCKED";
actionLabel = "已锁定跳过";
tip = dateStr + " 已锁定,确认导入时不会写入";
selected = false;
totalSkip += items.size();
}
else if (isUpdate) {
action = "UPDATE";
actionLabel = "更新";
tip = "按菜谱ID更新已有排菜";
selected = true;
totalUpdate++;
}
else {
action = "INSERT";
actionLabel = "新增";
tip = menuId != null ? "菜谱ID未匹配,将按新增排菜" : "新增排菜(菜品不存在时自动创建)";
selected = true;
totalInsert++;
}
CanteenDailyMenuImportPreviewVO.DaySummary day = dayMap.computeIfAbsent(dateStr, d -> {
CanteenDailyMenuImportPreviewVO.DaySummary s = new CanteenDailyMenuImportPreviewVO.DaySummary();
s.setBizDate(d);
s.setLocked(lockedDates.contains(d));
return s;
});
day.setRowCount(day.getRowCount() + items.size());
if (dayLocked) {
day.setSkipLockedCount(day.getSkipLockedCount() + items.size());
}
else if (isUpdate) {
day.setUpdateCount(day.getUpdateCount() + 1);
}
else {
day.setInsertCount(day.getInsertCount() + 1);
}
for (CanteenDailyMenuImportExcel item : items) {
CanteenDailyMenuImportPreviewVO.Row previewRow = new CanteenDailyMenuImportPreviewVO.Row();
previewRow.setRowNo(rowNos.get(item));
previewRow.setBizDate(StrUtil.trim(item.getBizDate()));
previewRow.setMealName(StrUtil.trim(item.getMealName()));
previewRow.setComboName(StrUtil.trim(item.getComboName()));
previewRow.setDishName(StrUtil.trim(item.getDishName()));
previewRow.setCategoryName(StrUtil.trim(item.getCategoryName()));
previewRow.setDishPriceYuan(item.getDishPriceYuan());
previewRow.setPriceYuan(item.getPriceYuan());
previewRow.setStock(item.getStock());
previewRow.setIngredients(StrUtil.trim(item.getIngredients()));
previewRow.setId(StrUtil.trim(item.getId()));
previewRow.setSpuId(StrUtil.trim(item.getSpuId()));
previewRow.setAction(action);
previewRow.setActionLabel(actionLabel);
previewRow.setLocked(dayLocked);
previewRow.setSelected(selected);
previewRow.setTip(tip);
vo.getRows().add(previewRow);
}
}
vo.setDaySummaries(dayMap.values().stream()
.sorted(Comparator.comparing(CanteenDailyMenuImportPreviewVO.DaySummary::getBizDate,
Comparator.nullsLast(String::compareTo)))
.collect(Collectors.toList()));
vo.setTotalInsert(totalInsert);
vo.setTotalUpdate(totalUpdate);
vo.setTotalSkipLocked(totalSkip);
return vo;
}
/**
* 执行导入落库;skipLocked=true 时自动跳过已锁定日期的组并记入日志。
*/
private R executeImport(List<CanteenDailyMenuImportExcel> dataRows, Long merchantId, Long categoryId,
boolean replaceDay, String fileName, boolean skipLocked) {
Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId);
Map<String, Long> categoryMap = loadCategoryMap(merchantId);
List<String> errors = new ArrayList<>();
Map<String, List<CanteenDailyMenuImportExcel>> groups = new LinkedHashMap<>();
Set<LocalDate> involvedDates = new HashSet<>();
Set<Long> excelMenuIds = new HashSet<>();
@@ -332,7 +600,7 @@ public class CanteenDailyMenuImportHelper {
continue;
}
if (hourType == null) {
errors.add("第" + line + "行:餐段应为 早餐/午餐/晚餐");
errors.add("第" + line + "行:餐段应为 早餐/午餐/晚餐,或多餐如「午餐,晚餐」「三餐」");
continue;
}
if (StrUtil.isBlank(dish)) {
@@ -346,7 +614,6 @@ public class CanteenDailyMenuImportHelper {
if (menuId != null) {
excelMenuIds.add(menuId);
}
// 带上日期+餐段,避免同 ID 跨天被并成一组
String groupKey;
if (menuId != null) {
groupKey = "id:" + menuId + "|" + date + "|" + hourType;
@@ -363,6 +630,12 @@ public class CanteenDailyMenuImportHelper {
return R.failed(formatImportErrors(errors));
}
LocalDate min = involvedDates.stream().min(LocalDate::compareTo).orElse(null);
LocalDate max = involvedDates.stream().max(LocalDate::compareTo).orElse(null);
Set<String> lockedDates = (min != null && max != null)
? lockLogService.listLockedDates(merchantId, min, max)
: Set.of();
ImportCache cache = buildImportCache(merchantId, involvedDates, excelMenuIds);
Set<String> clearedDayHour = new LinkedHashSet<>();
List<CanteenDailyMenuEntity> pendingMenus = new ArrayList<>();
@@ -371,12 +644,30 @@ public class CanteenDailyMenuImportHelper {
int createdCombo = 0;
int scheduled = 0;
int idAsNew = 0;
int skipLockedRows = 0;
Map<String, DayImportStat> dayStats = new LinkedHashMap<>();
for (Map.Entry<String, List<CanteenDailyMenuImportExcel>> e : groups.entrySet()) {
List<CanteenDailyMenuImportExcel> items = e.getValue();
CanteenDailyMenuImportExcel firstRow = items.get(0);
LocalDate groupDate = parseDate(firstRow.getBizDate());
Integer groupHour = parseMeal(firstRow.getMealName());
String dateStr = groupDate != null ? groupDate.format(DATE_FMT) : StrUtil.trim(firstRow.getBizDate());
if (skipLocked && dateStr != null && lockedDates.contains(dateStr)) {
skipLockedRows += items.size();
DayImportStat st = dayStats.computeIfAbsent(dateStr, DayImportStat::new);
st.skipCount += items.size();
for (CanteenDailyMenuImportExcel item : items) {
appendDish(st, item.getDishName(), groupHour);
}
continue;
}
if (!skipLocked && groupDate != null) {
lockLogService.assertDayWritable(merchantId, groupDate);
}
Long menuId = items.stream().map(CanteenDailyMenuImportExcel::getId).map(CanteenDailyMenuImportHelper::parseExcelLong)
.filter(Objects::nonNull).findFirst().orElse(null);
if (menuId != null) {
@@ -388,19 +679,22 @@ public class CanteenDailyMenuImportHelper {
&& (groupHour == null || existMenu.getHourType() == null
|| groupHour.equals(existMenu.getHourType()));
if (!matched) {
// 空已走新增;ID 不存在 / 商户或日期餐段对不上 → 当新增排菜
idAsNew++;
items.forEach(r -> r.setId(null));
menuId = null;
}
}
DayImportStat st = dayStats.computeIfAbsent(dateStr, DayImportStat::new);
if (menuId != null) {
String err = upsertGroupById(menuId, items, merchantId, deptId, categoryId, categoryMap, cache);
if (err != null) {
// 事务内勿 return R.failed:内层已写库/抛错会标 rollback-only,提交会报 UnexpectedRollback
throw new IllegalArgumentException(err);
}
updated++;
st.updateCount++;
for (CanteenDailyMenuImportExcel item : items) {
appendDish(st, item.getDishName(), groupHour);
}
continue;
}
@@ -422,6 +716,7 @@ public class CanteenDailyMenuImportHelper {
cache, pendingMenus)) {
scheduled++;
}
appendDish(st, item.getDishName(), hourType);
}
}
else {
@@ -439,7 +734,6 @@ public class CanteenDailyMenuImportHelper {
}
String dishKey = cleanDishName(item.getDishName());
if (StrUtil.isBlank(dishKey) || !seenDishKeys.add(dishKey)) {
// 同套餐块内同名组成跳过(常见于跨日串组或导出重复)
continue;
}
FindOrCreate r = findOrCreateDish(merchantId, deptId,
@@ -449,6 +743,7 @@ public class CanteenDailyMenuImportHelper {
createdDish++;
}
children.add(r.spu);
appendDish(st, item.getDishName(), hourType);
}
children = dedupeComboChildren(children);
if (children.isEmpty()) {
@@ -472,20 +767,171 @@ public class CanteenDailyMenuImportHelper {
scheduled++;
}
}
st.insertCount++;
if (pendingMenus.size() >= MENU_BATCH_SIZE) {
flushPendingMenus(pendingMenus);
}
}
flushPendingMenus(pendingMenus);
saveImportLog(merchantId, deptId, fileName, replaceDay, dataRows.size(), scheduled, updated,
skipLockedRows, createdDish, createdCombo, min, max, dayStats);
String msg = String.format("导入成功:更新 %d,新建单品 %d,新建套餐 %d,新增排菜 %d",
updated, createdDish, createdCombo, scheduled);
if (idAsNew > 0) {
msg += String.format("(其中 %d 组菜谱ID未匹配,已按新增排菜)", idAsNew);
}
if (skipLockedRows > 0) {
msg += String.format(";已锁定跳过 %d 行", skipLockedRows);
}
return R.ok(null, msg);
}
private void saveImportLog(Long merchantId, Long deptId, String fileName, boolean replaceDay, int totalRows,
int insertCount, int updateCount, int skipLockedCount, int createdDish, int createdCombo,
LocalDate min, LocalDate max, Map<String, DayImportStat> dayStats) {
String[] operator = currentOperatorPair();
CanteenDailyMenuImportLogEntity log = new CanteenDailyMenuImportLogEntity();
log.setMerchantId(merchantId);
log.setDeptId(deptId);
log.setFileName(fileName);
log.setReplaceDay(replaceDay ? "1" : "0");
log.setTotalRows(totalRows);
log.setInsertCount(insertCount);
log.setUpdateCount(updateCount);
log.setSkipLockedCount(skipLockedCount);
log.setCreatedDish(createdDish);
log.setCreatedCombo(createdCombo);
log.setDateStart(min != null ? min.format(DATE_FMT) : null);
log.setDateEnd(max != null ? max.format(DATE_FMT) : null);
log.setStatus("SUCCESS");
log.setOperator(operator[0]);
log.setOperatorName(operator[1]);
importLogMapper.insert(log);
if (CollUtil.isEmpty(dayStats)) {
return;
}
for (DayImportStat st : dayStats.values()) {
CanteenDailyMenuImportLogDayEntity day = new CanteenDailyMenuImportLogDayEntity();
day.setLogId(log.getId());
day.setMerchantId(merchantId);
day.setBizDate(st.bizDate);
day.setInsertCount(st.insertCount);
day.setUpdateCount(st.updateCount);
day.setSkipCount(st.skipCount);
day.setDishSummary(formatDayDishSummary(st));
importLogDayMapper.insert(day);
}
}
/**
* 按餐段结构化摘要,前端可解析 JSON;旧数据仍为顿号拼接。
*/
private static String formatDayDishSummary(DayImportStat st) {
StringBuilder sb = new StringBuilder(256);
sb.append('{');
appendMealJson(sb, "breakfast", st.breakfast);
sb.append(',');
appendMealJson(sb, "lunch", st.lunch);
sb.append(',');
appendMealJson(sb, "dinner", st.dinner);
sb.append(',');
appendMealJson(sb, "other", st.other);
sb.append('}');
return truncate(sb.toString(), 1900);
}
private static void appendMealJson(StringBuilder sb, String key, LinkedHashSet<String> dishes) {
sb.append('"').append(key).append("\":[");
boolean first = true;
for (String dish : dishes) {
if (!first) {
sb.append(',');
}
first = false;
sb.append('"').append(escapeJson(dish)).append('"');
}
sb.append(']');
}
private static String escapeJson(String raw) {
if (raw == null) {
return "";
}
return raw.replace("\\", "\\\\").replace("\"", "\\\"");
}
private static void appendDish(DayImportStat st, String dishName, Integer hourType) {
String name = StrUtil.trim(dishName);
if (StrUtil.isBlank(name)) {
return;
}
LinkedHashSet<String> bucket;
if (hourType != null && hourType == 1) {
bucket = st.breakfast;
}
else if (hourType != null && hourType == 2) {
bucket = st.lunch;
}
else if (hourType != null && hourType == 3) {
bucket = st.dinner;
}
else {
bucket = st.other;
}
if (bucket.size() < 80) {
bucket.add(name);
}
if (st.dishes.size() < 40) {
st.dishes.add(name);
}
}
private static String truncate(String s, int max) {
if (s == null || s.length() <= max) {
return s;
}
return s.substring(0, max) + "…";
}
private static String[] currentOperatorPair() {
try {
PjcloudUser user = SecurityUtils.getUser();
if (user != null) {
String username = StrUtil.blankToDefault(user.getUsername(), String.valueOf(user.getId()));
String name = StrUtil.blankToDefault(user.getName(), username);
return new String[] { username, name };
}
}
catch (Exception ignored) {
// ignore
}
return new String[] { "system", "system" };
}
private static class PreparedImport {
R failed;
List<CanteenDailyMenuImportExcel> dataRows;
}
private static class DayImportStat {
final String bizDate;
int insertCount;
int updateCount;
int skipCount;
final LinkedHashSet<String> dishes = new LinkedHashSet<>();
final LinkedHashSet<String> breakfast = new LinkedHashSet<>();
final LinkedHashSet<String> lunch = new LinkedHashSet<>();
final LinkedHashSet<String> dinner = new LinkedHashSet<>();
final LinkedHashSet<String> other = new LinkedHashSet<>();
DayImportStat(String bizDate) {
this.bizDate = bizDate;
}
}
/**
* 解析 Excel 中的雪花 ID。必须用字符串/BigDecimal,禁止经 Double,否则超过 15 位会失真。
*/
@@ -1218,6 +1664,54 @@ public class CanteenDailyMenuImportHelper {
}
private static Integer parseMeal(String meal) {
List<Integer> meals = parseMeals(meal);
return meals.size() == 1 ? meals.get(0) : null;
}
/**
* 解析餐段,支持:
* <ul>
* <li>单餐:早餐 / 午餐(中餐) / 晚餐</li>
* <li>多餐:午餐,晚餐 / 午餐、晚餐 / 午餐/晚餐</li>
* <li>快捷:三餐、全天、早中晚 → 早+午+晚;中晚餐 → 午+晚</li>
* </ul>
* 同一道菜多餐时菜品库仍按菜名复用一份 SPU,排菜按餐段各挂一条。
*/
private static List<Integer> parseMeals(String meal) {
if (StrUtil.isBlank(meal)) {
return List.of();
}
String raw = meal.trim();
String compact = raw.replace(" ", "").replace(" ", "");
if ("三餐".equals(compact) || "全天".equals(compact) || "全天餐".equals(compact)
|| "早中晚".equals(compact) || "早午晚".equals(compact)) {
return List.of(1, 2, 3);
}
if ("中晚餐".equals(compact) || "午晚餐".equals(compact) || "中晚".equals(compact)
|| "午晚".equals(compact)) {
return List.of(2, 3);
}
if ("早晚餐".equals(compact) || "早晚".equals(compact)) {
return List.of(1, 3);
}
if ("早中餐".equals(compact) || "早午餐".equals(compact) || "早中".equals(compact)
|| "早午".equals(compact)) {
return List.of(1, 2);
}
LinkedHashSet<Integer> ordered = new LinkedHashSet<>();
String[] parts = raw.split("[,,、/;;|/\\\\]+");
for (String part : parts) {
Integer hour = parseSingleMealToken(part);
if (hour == null) {
return List.of();
}
ordered.add(hour);
}
return new ArrayList<>(ordered);
}
private static Integer parseSingleMealToken(String meal) {
if (StrUtil.isBlank(meal)) {
return null;
}
@@ -1234,6 +1728,59 @@ public class CanteenDailyMenuImportHelper {
return null;
}
/**
* 将多餐段行拆成单餐段行。带菜谱ID时禁止一格多餐,避免一条排菜记录对应多个餐段。
*/
private static List<CanteenDailyMenuImportExcel> expandMultiMealRows(List<CanteenDailyMenuImportExcel> rows,
List<String> errors) {
List<CanteenDailyMenuImportExcel> expanded = new ArrayList<>(rows.size());
int line = 1;
for (CanteenDailyMenuImportExcel row : rows) {
line++;
List<Integer> meals = parseMeals(row.getMealName());
if (meals.isEmpty()) {
errors.add("第" + line + "行:餐段应为 早餐/午餐/晚餐,或多餐如「午餐,晚餐」「三餐」");
continue;
}
Long menuId = parseExcelLong(row.getId());
if (menuId != null && meals.size() > 1) {
errors.add("第" + line + "行:带菜谱ID时餐段只能填单一餐段;多餐请分行填写,或去掉菜谱ID按新增排菜");
continue;
}
if (meals.size() == 1) {
row.setMealName(formatMeal(meals.get(0)));
expanded.add(row);
continue;
}
for (Integer hour : meals) {
CanteenDailyMenuImportExcel copy = copyImportRow(row);
copy.setMealName(formatMeal(hour));
// 多餐按新增排菜处理,避免同一菜谱ID落到多个餐段
copy.setId(null);
copy.setSpuId(null);
expanded.add(copy);
}
}
return expanded;
}
private static CanteenDailyMenuImportExcel copyImportRow(CanteenDailyMenuImportExcel src) {
CanteenDailyMenuImportExcel copy = new CanteenDailyMenuImportExcel();
copy.setBizDate(src.getBizDate());
copy.setMealName(src.getMealName());
copy.setComboName(src.getComboName());
copy.setDishName(src.getDishName());
copy.setCategoryName(src.getCategoryName());
copy.setDishPriceYuan(src.getDishPriceYuan());
copy.setPriceYuan(src.getPriceYuan());
copy.setStock(src.getStock());
copy.setIngredients(src.getIngredients());
copy.setId(src.getId());
copy.setSpuId(src.getSpuId());
copy.setTip(src.getTip());
return copy;
}
private static LocalDate parseDate(String s) {
if (StrUtil.isBlank(s)) {
return null;
@@ -0,0 +1,233 @@
package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuDayLockDTO;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuDayLockEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogDayEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuDayLockMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogDayMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogMapper;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuLockLogService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 每日菜谱按日锁定与导入日志
*/
@Service
@RequiredArgsConstructor
public class CanteenDailyMenuLockLogServiceImpl implements CanteenDailyMenuLockLogService {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final CanteenDailyMenuDayLockMapper dayLockMapper;
private final CanteenDailyMenuImportLogMapper importLogMapper;
private final CanteenDailyMenuImportLogDayMapper importLogDayMapper;
private final CanteenMerchantScopeHelper merchantScopeHelper;
@Override
public Set<String> listLockedDates(Long merchantId, LocalDate start, LocalDate end) {
if (merchantId == null || start == null || end == null) {
return Set.of();
}
List<CanteenDailyMenuDayLockEntity> list = dayLockMapper.selectList(Wrappers.<CanteenDailyMenuDayLockEntity>lambdaQuery()
.eq(CanteenDailyMenuDayLockEntity::getMerchantId, merchantId)
.eq(CanteenDailyMenuDayLockEntity::getLocked, "1")
.ge(CanteenDailyMenuDayLockEntity::getBizDate, start)
.le(CanteenDailyMenuDayLockEntity::getBizDate, end));
return list.stream()
.map(CanteenDailyMenuDayLockEntity::getBizDate)
.filter(Objects::nonNull)
.map(d -> d.format(DATE_FMT))
.collect(Collectors.toCollection(HashSet::new));
}
@Override
public boolean isDayLocked(Long merchantId, LocalDate bizDate) {
if (merchantId == null || bizDate == null) {
return false;
}
Long cnt = dayLockMapper.selectCount(Wrappers.<CanteenDailyMenuDayLockEntity>lambdaQuery()
.eq(CanteenDailyMenuDayLockEntity::getMerchantId, merchantId)
.eq(CanteenDailyMenuDayLockEntity::getBizDate, bizDate)
.eq(CanteenDailyMenuDayLockEntity::getLocked, "1"));
return cnt != null && cnt > 0;
}
@Override
public void assertDayWritable(Long merchantId, LocalDate bizDate) {
if (isDayLocked(merchantId, bizDate)) {
throw new IllegalArgumentException(bizDate.format(DATE_FMT) + " 已锁定,不可修改菜谱");
}
}
@Override
public void assertDaysWritable(Long merchantId, Collection<LocalDate> dates) {
if (CollUtil.isEmpty(dates) || merchantId == null) {
return;
}
LocalDate min = dates.stream().filter(Objects::nonNull).min(LocalDate::compareTo).orElse(null);
LocalDate max = dates.stream().filter(Objects::nonNull).max(LocalDate::compareTo).orElse(null);
if (min == null || max == null) {
return;
}
Set<String> locked = listLockedDates(merchantId, min, max);
if (locked.isEmpty()) {
return;
}
List<String> hit = dates.stream()
.filter(Objects::nonNull)
.map(d -> d.format(DATE_FMT))
.filter(locked::contains)
.distinct()
.sorted()
.toList();
if (!hit.isEmpty()) {
throw new IllegalArgumentException("以下日期已锁定,不可修改:" + String.join("、", hit));
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public R setDayLock(CanteenDailyMenuDayLockDTO dto) {
if (dto == null || dto.getMerchantId() == null || StrUtil.isBlank(dto.getBizDate()) || dto.getLocked() == null) {
return R.failed("参数不完整");
}
LocalDate bizDate;
try {
bizDate = LocalDate.parse(dto.getBizDate().trim(), DATE_FMT);
}
catch (Exception ex) {
return R.failed("日期格式应为 yyyy-MM-dd");
}
Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(dto.getMerchantId());
String operator = currentOperator();
CanteenDailyMenuDayLockEntity exist = dayLockMapper.selectOne(Wrappers.<CanteenDailyMenuDayLockEntity>lambdaQuery()
.eq(CanteenDailyMenuDayLockEntity::getMerchantId, dto.getMerchantId())
.eq(CanteenDailyMenuDayLockEntity::getBizDate, bizDate)
.last("LIMIT 1"));
if (Boolean.TRUE.equals(dto.getLocked())) {
if (exist == null) {
CanteenDailyMenuDayLockEntity entity = new CanteenDailyMenuDayLockEntity();
entity.setMerchantId(dto.getMerchantId());
entity.setDeptId(deptId);
entity.setBizDate(bizDate);
entity.setLocked("1");
entity.setLockBy(operator);
entity.setLockTime(LocalDateTime.now());
dayLockMapper.insert(entity);
}
else {
exist.setLocked("1");
exist.setLockBy(operator);
exist.setLockTime(LocalDateTime.now());
exist.setDeptId(deptId);
dayLockMapper.updateById(exist);
}
return R.ok(null, bizDate.format(DATE_FMT) + " 已锁定");
}
if (exist != null) {
exist.setLocked("0");
dayLockMapper.updateById(exist);
}
return R.ok(null, bizDate.format(DATE_FMT) + " 已解锁");
}
@Override
public List<CanteenDailyMenuDayLockEntity> listLocks(Long merchantId, String startDate, String endDate) {
if (merchantId == null) {
return List.of();
}
LocalDate start = parseDateOrNull(startDate);
LocalDate end = parseDateOrNull(endDate);
return dayLockMapper.selectList(Wrappers.<CanteenDailyMenuDayLockEntity>lambdaQuery()
.eq(CanteenDailyMenuDayLockEntity::getMerchantId, merchantId)
.eq(CanteenDailyMenuDayLockEntity::getLocked, "1")
.ge(start != null, CanteenDailyMenuDayLockEntity::getBizDate, start)
.le(end != null, CanteenDailyMenuDayLockEntity::getBizDate, end)
.orderByAsc(CanteenDailyMenuDayLockEntity::getBizDate));
}
@Override
public Page<CanteenDailyMenuImportLogEntity> pageImportLogs(Page page, Long merchantId, String bizDate) {
if (StrUtil.isNotBlank(bizDate)) {
List<CanteenDailyMenuImportLogDayEntity> dayRows = importLogDayMapper.selectList(
Wrappers.<CanteenDailyMenuImportLogDayEntity>lambdaQuery()
.eq(merchantId != null, CanteenDailyMenuImportLogDayEntity::getMerchantId, merchantId)
.eq(CanteenDailyMenuImportLogDayEntity::getBizDate, bizDate.trim())
.select(CanteenDailyMenuImportLogDayEntity::getLogId));
Set<Long> logIds = dayRows.stream()
.map(CanteenDailyMenuImportLogDayEntity::getLogId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (logIds.isEmpty()) {
return page;
}
return importLogMapper.selectPage(page, Wrappers.<CanteenDailyMenuImportLogEntity>lambdaQuery()
.eq(merchantId != null, CanteenDailyMenuImportLogEntity::getMerchantId, merchantId)
.in(CanteenDailyMenuImportLogEntity::getId, logIds)
.orderByDesc(CanteenDailyMenuImportLogEntity::getCreateTime));
}
return importLogMapper.selectPage(page, Wrappers.<CanteenDailyMenuImportLogEntity>lambdaQuery()
.eq(merchantId != null, CanteenDailyMenuImportLogEntity::getMerchantId, merchantId)
.orderByDesc(CanteenDailyMenuImportLogEntity::getCreateTime));
}
@Override
public CanteenDailyMenuImportLogEntity getImportLogDetail(Long id) {
CanteenDailyMenuImportLogEntity log = importLogMapper.selectById(id);
if (log == null) {
return null;
}
List<CanteenDailyMenuImportLogDayEntity> days = importLogDayMapper.selectList(
Wrappers.<CanteenDailyMenuImportLogDayEntity>lambdaQuery()
.eq(CanteenDailyMenuImportLogDayEntity::getLogId, id)
.orderByAsc(CanteenDailyMenuImportLogDayEntity::getBizDate));
log.setDayDetails(days);
return log;
}
private static LocalDate parseDateOrNull(String raw) {
if (StrUtil.isBlank(raw)) {
return null;
}
try {
return LocalDate.parse(raw.trim(), DATE_FMT);
}
catch (Exception ex) {
return null;
}
}
private static String currentOperator() {
try {
PjcloudUser user = SecurityUtils.getUser();
if (user != null) {
return StrUtil.blankToDefault(user.getUsername(), String.valueOf(user.getId()));
}
}
catch (Exception ignored) {
// ignore
}
return "system";
}
}
@@ -1,5 +1,6 @@
package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.nacos.common.utils.CollectionUtils;
import com.baomidou.lock.LockInfo;
@@ -15,6 +16,7 @@ import com.bsd.pjcloud.canteen.constant.DailyMenuAvailabilityLock;
import com.bsd.pjcloud.canteen.dto.app.DailyMenuDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenDailyMenuLockLogService;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuVO;
@@ -64,6 +66,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
/** 每日菜谱日期餐段分布式锁,用于串行化后台下架与 Hardware 最终下单校验。 */
private final LockTemplate lockTemplate;
private final CanteenDailyMenuLockLogService dailyMenuLockLogService;
/**
* 按逻辑删除后的当前数据批量校验日期、餐段和 SPU 组合,避免 App 页面缓存仍包含已下架菜品。
* 基础 SPU 的全局上下架、归属和库存由 Hardware 下单服务继续按现有逻辑独立复核。
@@ -172,6 +176,9 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
@Override
@Transactional
public void saveToday(CanteenDailyMenuEntity canteenDailyMenu) {
if (canteenDailyMenu != null) {
dailyMenuLockLogService.assertDayWritable(canteenDailyMenu.getMerchantId(), canteenDailyMenu.getStartTime());
}
List<Long> spuIds = Optional.ofNullable(canteenDailyMenu.getSpuIds()).orElse(Collections.emptyList());
if (spuIds.isEmpty()) {
return;
@@ -228,6 +235,15 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
List<CanteenDailyMenuEntity> menus = CollectionUtils.isEmpty(list)
? Collections.emptyList()
: baseMapper.selectBatchIds(list);
Set<LocalDate> dates = menus.stream()
.map(CanteenDailyMenuEntity::getStartTime)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Long lockMerchantId = merchantId;
if (lockMerchantId == null && !menus.isEmpty()) {
lockMerchantId = menus.get(0).getMerchantId();
}
dailyMenuLockLogService.assertDaysWritable(lockMerchantId, dates);
List<LockInfo> availabilityLocks = acquireAvailabilityLocks(menus);
try {
registerAvailabilityLockRelease(availabilityLocks);
@@ -492,6 +508,13 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
LocalDate startTime = creenDailyMenuCVDTO.getStartTime();
//取出复制源
Long sourceMerchantId = creenDailyMenuCVDTO.getSourceMerchantId();
// 目标日期锁定校验(目标商户)
List<Long> targetMerchantIds = creenDailyMenuCVDTO.getTargetMerchantId();
if (CollUtil.isNotEmpty(targetMerchantIds) && startTime != null) {
for (Long targetMerchantId : targetMerchantIds) {
dailyMenuLockLogService.assertDayWritable(targetMerchantId, startTime);
}
}
LambdaQueryWrapper<CanteenDailyMenuEntity> canteenDailyMenuEntityLambdaQueryWrapper = new LambdaQueryWrapper<>();
canteenDailyMenuEntityLambdaQueryWrapper
.eq(CanteenDailyMenuEntity::getMerchantId, sourceMerchantId);
@@ -501,7 +524,6 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
return R.failed("复制源数据为空");
}
// 目标源处理
List<Long> targetMerchantIds = creenDailyMenuCVDTO.getTargetMerchantId();
for (Long targetMerchantId : targetMerchantIds) {
// 查询目标商户已存在的spuId
Set<String> existKeys = baseMapper.selectList(new LambdaQueryWrapper<CanteenDailyMenuEntity>()
@@ -0,0 +1,46 @@
package com.bsd.pjcloud.thirdparty.api.feign;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.feign.annotation.NoToken;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 微信开放平台消息推送内部能力,业务服务按需调用。
*/
@FeignClient(contextId = "remoteWxOpenMessagePushService", value = ServiceNameConstants.THIRDPARTY_SERVICE)
public interface RemoteWxOpenMessagePushService {
/**
* 读取最新缓存的 component_verify_ticket。
*/
@NoToken
@GetMapping("/wx-open-remoter/component-verify-ticket")
R<String> getComponentVerifyTicket();
/**
* 加密业务回包为微信安全模式 XML(需自行作为 HTTP 响应体返回给微信)。
*
* @param plainXml 回包明文 XML
* @param timestamp 时间戳,可空则服务端生成
* @param nonce 随机串,可空则服务端生成
*/
@NoToken
@PostMapping("/wx-open-remoter/encrypt-reply")
R<String> encryptReply(@RequestBody String plainXml,
@RequestParam(value = "timestamp", required = false) String timestamp,
@RequestParam(value = "nonce", required = false) String nonce);
/**
* 供联调或内部重放:按已解密消息结构手动派发给本地 Handler(不落微信回包协议)。
*/
@NoToken
@PostMapping("/wx-open-remoter/dispatch")
R<String> dispatch(@RequestBody WxOpenPushMessageVO message);
}
@@ -0,0 +1,31 @@
package com.bsd.pjcloud.thirdparty.constant.wxopen;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
/**
* 微信开放平台第三方消息推送常量。
*/
public interface WxOpenMessagePushConstants {
/** Redis 中缓存的 component_verify_ticket,全局共享。 */
String COMPONENT_VERIFY_TICKET_KEY = CacheConstants.GLOBALLY + "wx:open:component_verify_ticket";
/** ticket 缓存时长(秒);微信约每 10 分钟刷新,有效期 12 小时,这里按 11 小时保存。 */
long COMPONENT_VERIFY_TICKET_TTL_SECONDS = 11 * 60 * 60L;
/** 授权事件:推送 component_verify_ticket。 */
String INFO_TYPE_COMPONENT_VERIFY_TICKET = "component_verify_ticket";
/** 授权事件:授权成功。 */
String INFO_TYPE_AUTHORIZED = "authorized";
/** 授权事件:更新授权。 */
String INFO_TYPE_UPDATE_AUTHORIZED = "updateauthorized";
/** 授权事件:取消授权。 */
String INFO_TYPE_UNAUTHORIZED = "unauthorized";
/** 微信要求的成功回包。 */
String SUCCESS = "success";
}
@@ -0,0 +1,69 @@
package com.bsd.pjcloud.thirdparty.vo.wxopen;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 微信开放平台消息推送解密后的业务视图,供业务侧消费。
*/
@Data
public class WxOpenPushMessageVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/**
* 推送通道:auth=授权事件接收;message=消息与事件接收。
*/
private String channel;
/**
* 代接收消息的授权方 AppId;授权事件通道可能为空。
*/
private String authorizerAppId;
/**
* 第三方平台 AppId(解密校验后的值)。
*/
private String componentAppId;
/**
* 授权事件 InfoType,如 component_verify_ticket / authorized。
*/
private String infoType;
/**
* 普通消息 MsgType,如 event / text。
*/
private String msgType;
/**
* 事件类型 Event。
*/
private String event;
/**
* component_verify_ticket 原文。
*/
private String componentVerifyTicket;
/**
* 授权码,授权成功/更新授权时存在。
*/
private String authorizationCode;
/**
* 解密后的完整明文 XML。
*/
private String plainXml;
/**
* 明文 XML 解析出的扁平字段,便于业务按需取值。
*/
private Map<String, String> fields = new LinkedHashMap<>();
}
@@ -0,0 +1,40 @@
package com.bsd.pjcloud.thirdparty.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 微信开放平台第三方「消息推送」配置,对应开放平台开发资料中的 Token / EncodingAESKey。
*/
@Data
@Component
@ConfigurationProperties(prefix = "wx.open-platform.message-push")
public class WxOpenMessagePushProperties {
/**
* 是否启用消息推送接收;关闭时回调接口直接拒绝。
*/
private boolean enabled = false;
/**
* 第三方平台 AppId。
*/
private String componentAppId;
/**
* 第三方平台 AppSecret;当前仅预留,获取 component_access_token 时可继续使用。
*/
private String componentAppSecret;
/**
* 消息校验 Token,3-32 位英文或数字。
*/
private String token;
/**
* 消息加解密 EncodingAESKey,43 位。
*/
private String aesKey;
}
@@ -0,0 +1,121 @@
package com.bsd.pjcloud.thirdparty.controller.wxopen;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.common.xss.core.XssCleanIgnore;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 微信开放平台第三方消息推送回调入口。
* <p>
* 网关 StripPrefix=1 后,开放平台可配置:
* <ul>
* <li>授权事件接收:https://{域名}/third/wx-open/auth-event</li>
* <li>消息与事件接收:https://{域名}/third/wx-open/msg-event/$APPID$</li>
* </ul>
*
* @see <a href="https://developers.weixin.qq.com/doc/oplatform/Third-party_Platforms/2.0/api/Before_Develop/message_push.html">消息推送</a>
*/
@Slf4j
@Inner(value = false)
@RestController
@RequiredArgsConstructor
@RequestMapping("/wx-open")
@Tag(name = "微信开放平台消息推送")
public class WxOpenMessagePushController {
private final WxOpenMessagePushService wxOpenMessagePushService;
/**
* 授权事件接收 URL 接入校验。
*/
@XssCleanIgnore
@GetMapping(value = "/auth-event", produces = MediaType.TEXT_PLAIN_VALUE)
@Operation(summary = "授权事件 URL 校验")
public String authEventGet(@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {
log.info("微信授权事件 URL 校验 signature={}, timestamp={}, nonce={}", signature, timestamp, nonce);
try {
return wxOpenMessagePushService.verifyUrl(signature, timestamp, nonce, echostr);
}
catch (Exception ex) {
log.warn("微信授权事件 URL 校验失败: {}", ex.getMessage());
return "非法请求";
}
}
/**
* 授权事件推送(component_verify_ticket / 授权变更)。
*/
@XssCleanIgnore
@PostMapping(value = "/auth-event", produces = MediaType.TEXT_PLAIN_VALUE)
@Operation(summary = "授权事件推送")
public String authEventPost(@RequestBody String requestBody,
@RequestParam(name = "msg_signature", required = false) String msgSignature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce) {
log.info("微信授权事件推送 timestamp={}, nonce={}", timestamp, nonce);
try {
return wxOpenMessagePushService.receiveAuthEvent(msgSignature, timestamp, nonce, requestBody);
}
catch (Exception ex) {
log.error("微信授权事件处理失败", ex);
return "success";
}
}
/**
* 消息与事件接收 URL 接入校验;路径中的 appId 对应开放平台配置的 $APPID$。
*/
@XssCleanIgnore
@GetMapping(value = "/msg-event/{appId}", produces = MediaType.TEXT_PLAIN_VALUE)
@Operation(summary = "消息事件 URL 校验")
public String msgEventGet(@PathVariable("appId") String appId,
@RequestParam(name = "signature", required = false) String signature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce,
@RequestParam(name = "echostr", required = false) String echostr) {
log.info("微信消息事件 URL 校验 appId={}, signature={}", appId, signature);
try {
return wxOpenMessagePushService.verifyUrl(signature, timestamp, nonce, echostr);
}
catch (Exception ex) {
log.warn("微信消息事件 URL 校验失败 appId={}, err={}", appId, ex.getMessage());
return "非法请求";
}
}
/**
* 代授权方接收消息与事件。
*/
@XssCleanIgnore
@PostMapping(value = "/msg-event/{appId}", produces = { MediaType.APPLICATION_XML_VALUE, MediaType.TEXT_PLAIN_VALUE })
@Operation(summary = "消息事件推送")
public String msgEventPost(@PathVariable("appId") String appId, @RequestBody String requestBody,
@RequestParam(name = "msg_signature", required = false) String msgSignature,
@RequestParam(name = "timestamp", required = false) String timestamp,
@RequestParam(name = "nonce", required = false) String nonce) {
log.info("微信消息事件推送 appId={}, timestamp={}, nonce={}", appId, timestamp, nonce);
try {
return wxOpenMessagePushService.receiveMessageEvent(appId, msgSignature, timestamp, nonce, requestBody);
}
catch (Exception ex) {
log.error("微信消息事件处理失败 appId={}", appId, ex);
return "success";
}
}
}
@@ -0,0 +1,52 @@
package com.bsd.pjcloud.thirdparty.controller.wxopen;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.annotation.Inner;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushService;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 微信开放平台消息推送内部调用入口,供业务服务通过 Feign 获取 ticket 或加密回包。
*/
@Inner
@RestController
@RequiredArgsConstructor
@RequestMapping("/wx-open-remoter")
@Tag(name = "微信开放平台消息推送-内部")
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class WxOpenMessagePushRemoterController {
private final WxOpenMessagePushService wxOpenMessagePushService;
@GetMapping("/component-verify-ticket")
@Operation(summary = "获取 component_verify_ticket")
public R<String> getComponentVerifyTicket() {
return R.ok(wxOpenMessagePushService.getComponentVerifyTicket());
}
@PostMapping("/encrypt-reply")
@Operation(summary = "加密业务回包")
public R<String> encryptReply(@RequestBody String plainXml,
@RequestParam(value = "timestamp", required = false) String timestamp,
@RequestParam(value = "nonce", required = false) String nonce) {
return R.ok(wxOpenMessagePushService.encryptReply(plainXml, timestamp, nonce));
}
@PostMapping("/dispatch")
@Operation(summary = "手动派发已解密消息给本地 Handler")
public R<String> dispatch(@RequestBody WxOpenPushMessageVO message) {
return R.ok(wxOpenMessagePushService.dispatch(message));
}
}
@@ -0,0 +1,225 @@
package com.bsd.pjcloud.thirdparty.crypto.wxopen;
import cn.hutool.core.util.RandomUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Base64;
/**
* 微信开放平台安全模式消息加解密,协议对齐官方 WXBizMsgCrypt。
*/
public class WxOpenMsgCrypt {
private static final Charset CHARSET = StandardCharsets.UTF_8;
private final byte[] aesKey;
private final String token;
private final String appId;
public WxOpenMsgCrypt(String token, String encodingAesKey, String appId) {
if (StrUtil.hasBlank(token, encodingAesKey, appId)) {
throw new IllegalArgumentException("微信消息推送 token / aesKey / appId 不能为空");
}
if (encodingAesKey.length() != 43) {
throw new IllegalArgumentException("EncodingAESKey 长度必须为 43");
}
this.token = token;
this.appId = appId;
this.aesKey = Base64.getDecoder().decode(encodingAesKey + "=");
}
/**
* URL 接入校验:SHA1(token、timestamp、nonce) 与 signature 比对后回显 echostr。
*/
public String verifyUrl(String signature, String timestamp, String nonce, String echostr) {
if (!checkSignature(token, timestamp, nonce, signature)) {
throw new IllegalArgumentException("签名校验失败");
}
return echostr;
}
/**
* 校验并解密 POST 包体,返回明文 XML。
*/
public String decryptMsg(String msgSignature, String timestamp, String nonce, String postData) {
String encrypt = extractEncrypt(postData);
if (!checkMsgSignature(token, timestamp, nonce, encrypt, msgSignature)) {
throw new IllegalArgumentException("msg_signature 校验失败");
}
return decrypt(encrypt);
}
/**
* 将明文回包加密为微信要求的 XML。
*/
public String encryptMsg(String replyMsg, String timestamp, String nonce) {
String encrypt = encrypt(replyMsg);
String signature = sha1(token, timestamp, nonce, encrypt);
return "<xml>"
+ "<Encrypt><![CDATA[" + encrypt + "]]></Encrypt>"
+ "<MsgSignature><![CDATA[" + signature + "]]></MsgSignature>"
+ "<TimeStamp>" + timestamp + "</TimeStamp>"
+ "<Nonce><![CDATA[" + nonce + "]]></Nonce>"
+ "</xml>";
}
/**
* 明文模式/兼容模式 URL 签名校验。
*/
public static boolean checkSignature(String token, String timestamp, String nonce, String signature) {
if (StrUtil.hasBlank(token, timestamp, nonce, signature)) {
return false;
}
return sha1(token, timestamp, nonce).equals(signature);
}
private static boolean checkMsgSignature(String token, String timestamp, String nonce, String encrypt,
String msgSignature) {
if (StrUtil.hasBlank(token, timestamp, nonce, encrypt, msgSignature)) {
return false;
}
return sha1(token, timestamp, nonce, encrypt).equals(msgSignature);
}
private String decrypt(String text) {
byte[] original;
try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
original = cipher.doFinal(Base64.getDecoder().decode(text));
}
catch (Exception ex) {
throw new IllegalStateException("微信消息解密失败", ex);
}
byte[] bytes = decodePkcs7(original);
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
String xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
String fromAppId = new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), CHARSET);
if (!appId.equals(fromAppId)) {
throw new IllegalArgumentException("解密得到的 AppId 与配置不一致");
}
return xmlContent;
}
private String encrypt(String text) {
byte[] randomBytes = RandomUtil.randomString(16).getBytes(CHARSET);
byte[] textBytes = text.getBytes(CHARSET);
byte[] networkOrder = getNetworkBytesOrder(textBytes.length);
byte[] appIdBytes = appId.getBytes(CHARSET);
ByteBuffer buffer = ByteBuffer.allocate(randomBytes.length + networkOrder.length + textBytes.length + appIdBytes.length);
buffer.put(randomBytes);
buffer.put(networkOrder);
buffer.put(textBytes);
buffer.put(appIdBytes);
byte[] unencrypted = encodePkcs7(buffer.array());
try {
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
return Base64.getEncoder().encodeToString(cipher.doFinal(unencrypted));
}
catch (Exception ex) {
throw new IllegalStateException("微信消息加密失败", ex);
}
}
private static String extractEncrypt(String postData) {
try {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setExpandEntityReferences(false);
DocumentBuilder builder = factory.newDocumentBuilder();
Document document = builder.parse(new InputSource(new StringReader(postData)));
Element root = document.getDocumentElement();
NodeList nodeList = root.getElementsByTagName("Encrypt");
if (nodeList.getLength() == 0 || nodeList.item(0) == null) {
throw new IllegalArgumentException("推送包体缺少 Encrypt 节点");
}
return nodeList.item(0).getTextContent();
}
catch (IllegalArgumentException ex) {
throw ex;
}
catch (Exception ex) {
throw new IllegalArgumentException("解析微信推送 XML 失败", ex);
}
}
private static String sha1(String... values) {
String[] sorted = Arrays.copyOf(values, values.length);
Arrays.sort(sorted);
StringBuilder builder = new StringBuilder();
for (String value : sorted) {
builder.append(value);
}
return DigestUtil.sha1Hex(builder.toString());
}
private static byte[] getNetworkBytesOrder(int sourceNumber) {
return new byte[] {
(byte) (sourceNumber >> 24 & 0xFF),
(byte) (sourceNumber >> 16 & 0xFF),
(byte) (sourceNumber >> 8 & 0xFF),
(byte) (sourceNumber & 0xFF)
};
}
private static int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
private static byte[] encodePkcs7(byte[] src) {
int blockSize = 32;
int amountToPad = blockSize - (src.length % blockSize);
if (amountToPad == 0) {
amountToPad = blockSize;
}
byte padChr = (byte) (amountToPad & 0xFF);
byte[] tmp = new byte[src.length + amountToPad];
System.arraycopy(src, 0, tmp, 0, src.length);
for (int i = 0; i < amountToPad; i++) {
tmp[src.length + i] = padChr;
}
return tmp;
}
private static byte[] decodePkcs7(byte[] decrypted) {
int pad = decrypted[decrypted.length - 1] & 0xFF;
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
}
}
@@ -0,0 +1,44 @@
package com.bsd.pjcloud.thirdparty.service.impl.wxopen;
import cn.hutool.core.util.StrUtil;
import com.bsd.pjcloud.thirdparty.constant.wxopen.WxOpenMessagePushConstants;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushHandler;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 默认兜底 Handler:记录关键授权事件,不阻断微信推送。
* 业务 Handler 请使用更小的 {@link Order} 值优先匹配。
*/
@Slf4j
@Order(Ordered.LOWEST_PRECEDENCE)
@Component
public class DefaultWxOpenMessagePushHandler implements WxOpenMessagePushHandler {
@Override
public boolean supports(WxOpenPushMessageVO message) {
return true;
}
@Override
public String handle(WxOpenPushMessageVO message) {
if (WxOpenMessagePushConstants.INFO_TYPE_COMPONENT_VERIFY_TICKET.equals(message.getInfoType())) {
log.debug("component_verify_ticket 已由服务层缓存,默认 Handler 不再处理");
}
else if (StrUtil.equalsAny(message.getInfoType(), WxOpenMessagePushConstants.INFO_TYPE_AUTHORIZED,
WxOpenMessagePushConstants.INFO_TYPE_UPDATE_AUTHORIZED,
WxOpenMessagePushConstants.INFO_TYPE_UNAUTHORIZED)) {
log.info("收到授权变更通知 infoType={}, authorizerAppId={}, authorizationCode={}", message.getInfoType(),
message.getAuthorizerAppId(), message.getAuthorizationCode());
}
else {
log.info("未匹配业务 Handler,使用默认成功回包 channel={}, msgType={}, event={}", message.getChannel(),
message.getMsgType(), message.getEvent());
}
return WxOpenMessagePushConstants.SUCCESS;
}
}
@@ -0,0 +1,176 @@
package com.bsd.pjcloud.thirdparty.service.impl.wxopen;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.XmlUtil;
import com.bsd.pjcloud.thirdparty.config.WxOpenMessagePushProperties;
import com.bsd.pjcloud.thirdparty.constant.wxopen.WxOpenMessagePushConstants;
import com.bsd.pjcloud.thirdparty.crypto.wxopen.WxOpenMsgCrypt;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushHandler;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushService;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
/**
* 微信开放平台消息推送编排:验签、解密、ticket 缓存与 Handler 分发。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WxOpenMessagePushServiceImpl implements WxOpenMessagePushService {
private final WxOpenMessagePushProperties properties;
private final StringRedisTemplate stringRedisTemplate;
private final List<WxOpenMessagePushHandler> handlers;
@Override
public String verifyUrl(String signature, String timestamp, String nonce, String echostr) {
assertEnabled();
return crypt().verifyUrl(signature, timestamp, nonce, echostr);
}
@Override
public String receiveAuthEvent(String msgSignature, String timestamp, String nonce, String requestBody) {
assertEnabled();
String plainXml = crypt().decryptMsg(msgSignature, timestamp, nonce, requestBody);
WxOpenPushMessageVO message = parseMessage("auth", null, plainXml);
persistTicketIfNeeded(message);
return reply(dispatchInternal(message), timestamp, nonce);
}
@Override
public String receiveMessageEvent(String authorizerAppId, String msgSignature, String timestamp, String nonce,
String requestBody) {
assertEnabled();
String plainXml = crypt().decryptMsg(msgSignature, timestamp, nonce, requestBody);
WxOpenPushMessageVO message = parseMessage("message", authorizerAppId, plainXml);
return reply(dispatchInternal(message), timestamp, nonce);
}
@Override
public String getComponentVerifyTicket() {
assertEnabled();
String ticket = stringRedisTemplate.opsForValue().get(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_KEY);
if (StrUtil.isBlank(ticket)) {
throw new IllegalStateException("component_verify_ticket 尚未推送或已过期");
}
return ticket;
}
@Override
public String encryptReply(String plainXml, String timestamp, String nonce) {
assertEnabled();
String ts = StrUtil.blankToDefault(timestamp, String.valueOf(System.currentTimeMillis() / 1000));
String random = StrUtil.blankToDefault(nonce, String.valueOf(ThreadLocalRandom.current().nextInt(100000, 999999)));
return crypt().encryptMsg(plainXml, ts, random);
}
@Override
public String dispatch(WxOpenPushMessageVO message) {
assertEnabled();
String result = dispatchInternal(message);
return StrUtil.blankToDefault(result, WxOpenMessagePushConstants.SUCCESS);
}
private String dispatchInternal(WxOpenPushMessageVO message) {
log.info("微信开放平台消息推送 channel={}, authorizerAppId={}, infoType={}, msgType={}, event={}",
message.getChannel(), message.getAuthorizerAppId(), message.getInfoType(), message.getMsgType(),
message.getEvent());
for (WxOpenMessagePushHandler handler : handlers) {
if (handler != null && handler.supports(message)) {
return handler.handle(message);
}
}
return WxOpenMessagePushConstants.SUCCESS;
}
private String reply(String plainReply, String timestamp, String nonce) {
if (StrUtil.isBlank(plainReply) || WxOpenMessagePushConstants.SUCCESS.equalsIgnoreCase(plainReply.trim())) {
return WxOpenMessagePushConstants.SUCCESS;
}
return encryptReply(plainReply, timestamp, nonce);
}
private void persistTicketIfNeeded(WxOpenPushMessageVO message) {
if (!WxOpenMessagePushConstants.INFO_TYPE_COMPONENT_VERIFY_TICKET.equals(message.getInfoType())) {
return;
}
if (StrUtil.isBlank(message.getComponentVerifyTicket())) {
log.warn("收到 component_verify_ticket 事件但票据为空");
return;
}
stringRedisTemplate.opsForValue().set(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_KEY,
message.getComponentVerifyTicket(),
Duration.ofSeconds(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_TTL_SECONDS));
log.info("已缓存 component_verify_ticket");
}
private WxOpenPushMessageVO parseMessage(String channel, String authorizerAppId, String plainXml) {
Map<String, String> fields = flattenXml(plainXml);
WxOpenPushMessageVO message = new WxOpenPushMessageVO();
message.setChannel(channel);
message.setAuthorizerAppId(StrUtil.blankToDefault(authorizerAppId, fields.get("AppId")));
message.setComponentAppId(properties.getComponentAppId());
message.setInfoType(fields.get("InfoType"));
message.setMsgType(fields.get("MsgType"));
message.setEvent(fields.get("Event"));
message.setComponentVerifyTicket(fields.get("ComponentVerifyTicket"));
message.setAuthorizationCode(fields.get("AuthorizationCode"));
message.setPlainXml(plainXml);
message.setFields(fields);
// 授权事件包体里的 AppId 是第三方平台 AppId
if ("auth".equals(channel) && StrUtil.isNotBlank(fields.get("AppId"))) {
message.setComponentAppId(fields.get("AppId"));
}
if ("auth".equals(channel) && StrUtil.isNotBlank(fields.get("AuthorizerAppid"))) {
message.setAuthorizerAppId(fields.get("AuthorizerAppid"));
}
return message;
}
private Map<String, String> flattenXml(String plainXml) {
Map<String, String> fields = new LinkedHashMap<>();
try {
Document document = XmlUtil.parseXml(plainXml);
NodeList children = document.getDocumentElement().getChildNodes();
for (int i = 0; i < children.getLength(); i++) {
Node node = children.item(i);
if (node.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
fields.put(node.getNodeName(), StrUtil.trim(node.getTextContent()));
}
}
catch (Exception ex) {
log.warn("解析微信明文 XML 失败,保留原文供业务自行处理", ex);
}
return fields;
}
private WxOpenMsgCrypt crypt() {
return new WxOpenMsgCrypt(properties.getToken(), properties.getAesKey(), properties.getComponentAppId());
}
private void assertEnabled() {
if (!properties.isEnabled()) {
throw new IllegalStateException("微信开放平台消息推送未启用,请配置 wx.open-platform.message-push.enabled=true");
}
if (StrUtil.hasBlank(properties.getComponentAppId(), properties.getToken(), properties.getAesKey())) {
throw new IllegalStateException("请完整配置 component-app-id / token / aes-key");
}
}
}
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.thirdparty.service.wxopen;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
/**
* 微信开放平台消息推送业务扩展点;业务模块可注册多个实现按需处理事件。
*/
public interface WxOpenMessagePushHandler {
/**
* 是否处理当前消息。
*
* @param message 已解密消息
* @return true 表示本 Handler 接手
*/
boolean supports(WxOpenPushMessageVO message);
/**
* 处理消息。
*
* @param message 已解密消息
* @return 明文回包 XML;返回 null / 空串 / success 时统一回复微信 success(不加密)
*/
String handle(WxOpenPushMessageVO message);
}
@@ -0,0 +1,43 @@
package com.bsd.pjcloud.thirdparty.service.wxopen;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
/**
* 微信开放平台消息推送编排能力。
*/
public interface WxOpenMessagePushService {
/**
* URL 接入校验。
*/
String verifyUrl(String signature, String timestamp, String nonce, String echostr);
/**
* 接收授权事件推送(component_verify_ticket / 授权变更)。
*/
String receiveAuthEvent(String msgSignature, String timestamp, String nonce, String requestBody);
/**
* 接收代授权方消息与事件推送。
*
* @param authorizerAppId URL 中的 $APPID$
*/
String receiveMessageEvent(String authorizerAppId, String msgSignature, String timestamp, String nonce,
String requestBody);
/**
* 读取缓存的 component_verify_ticket。
*/
String getComponentVerifyTicket();
/**
* 加密业务明文回包。
*/
String encryptReply(String plainXml, String timestamp, String nonce);
/**
* 手动派发给本地 Handler,返回 Handler 明文回包或 success。
*/
String dispatch(WxOpenPushMessageVO message);
}
@@ -234,3 +234,22 @@ jg:
test-user-ids:
- 2029755089492066305
- 2034824566572941314
# 微信开放平台「第三方平台」消息推送(与控制台「消息推送配置」一一对应,改这里即可,不必再配 Nacos)
# 控制台 URL 填:
# 授权事件接收:https://{你的公网域名}/third/wx-open/auth-event
# 消息与事件接收:https://{你的公网域名}/third/wx-open/msg-event/$APPID$
# 消息加解密方式选「安全模式」,数据格式选「XML」后点确定
wx:
open-platform:
message-push:
# 配好下面三项并对外可访问后改为 true
enabled: false
# 第三方平台 AppId:开放平台 → 管理中心 → 第三方平台 → 详情里的 AppId(一般以 wx 开头)
component-app-id: "wx9f2c53067b4520a4"
# 第三方平台 AppSecret:同上页面的 AppSecret(当前主要预留,拉 component_access_token 时会用到)
component-app-secret: "917d6656285df1d1fecc9626ecaf3aab"
# Token(令牌):控制台里自己起的 3~32 位英文/数字,这里必须和页面填的完全一致
token: "ODM5XK0zvGwl2pxdVSsAiI6E"
# EncodingAESKey:控制台点「随机生成」得到的 43 位密钥,这里必须和页面填的完全一致
aes-key: "omvXubwnYP9DxYWG8MBnMhmIEyiFs08rGVfbdum3TzW"
@@ -0,0 +1,56 @@
package com.bsd.pjcloud.thirdparty.controller.wxopen;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
/**
* Controller 调用形态示例(不启动 Spring 容器,只验证委托关系)。
*
* <pre>
* 对外(经网关):
* GET https://{域名}/third/wx-open/auth-event?signature=&amp;timestamp=&amp;nonce=&amp;echostr=
* POST https://{域名}/third/wx-open/auth-event?msg_signature=&amp;timestamp=&amp;nonce=
*
* 业务 Feign:
* RemoteWxOpenMessagePushService#getComponentVerifyTicket()
* RemoteWxOpenMessagePushService#dispatch(WxOpenPushMessageVO)
* </pre>
*/
@ExtendWith(MockitoExtension.class)
class WxOpenMessagePushControllerTest {
@Mock
private WxOpenMessagePushService wxOpenMessagePushService;
private WxOpenMessagePushController controller;
@BeforeEach
void setUp() {
controller = new WxOpenMessagePushController(wxOpenMessagePushService);
}
@Test
@DisplayName("GET 授权事件 URL 校验应回显 echostr")
void authEventGet_shouldReturnEchostr() {
when(wxOpenMessagePushService.verifyUrl(eq("sig"), eq("ts"), eq("n"), eq("echo"))).thenReturn("echo");
assertEquals("echo", controller.authEventGet("sig", "ts", "n", "echo"));
}
@Test
@DisplayName("POST 授权事件应返回 success")
void authEventPost_shouldReturnSuccess() {
when(wxOpenMessagePushService.receiveAuthEvent(eq("msig"), eq("ts"), eq("n"), eq("<xml/>")))
.thenReturn("success");
assertEquals("success", controller.authEventPost("<xml/>", "msig", "ts", "n"));
}
}
@@ -0,0 +1,83 @@
package com.bsd.pjcloud.thirdparty.crypto.wxopen;
import cn.hutool.crypto.digest.DigestUtil;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
/**
* 微信开放平台消息加解密自测;也可当作协议参考样例。
*/
class WxOpenMsgCryptTest {
/** 测试用 EncodingAESKey,长度必须 43。 */
private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
private static final String TOKEN = "testToken123";
private static final String APP_ID = "wx1234567890abcdef";
private WxOpenMsgCrypt crypt;
@BeforeEach
void setUp() {
crypt = new WxOpenMsgCrypt(TOKEN, AES_KEY, APP_ID);
}
@Test
@DisplayName("URL 接入校验:签名正确时回显 echostr")
void verifyUrl_shouldEchoWhenSignatureValid() {
String timestamp = "1715943329";
String nonce = "1590219412";
String echostr = "helloEcho";
String signature = sha1(TOKEN, timestamp, nonce);
String result = crypt.verifyUrl(signature, timestamp, nonce, echostr);
Assertions.assertEquals(echostr, result);
}
@Test
@DisplayName("URL 接入校验:签名错误时抛异常")
void verifyUrl_shouldFailWhenSignatureInvalid() {
Assertions.assertThrows(IllegalArgumentException.class,
() -> crypt.verifyUrl("bad-signature", "1", "2", "echo"));
}
@Test
@DisplayName("安全模式:加密后再解密应还原明文 XML")
void encryptThenDecrypt_shouldRestorePlainXml() {
String plainXml = "<xml><InfoType><![CDATA[component_verify_ticket]]></InfoType>"
+ "<ComponentVerifyTicket><![CDATA[ticket_demo_001]]></ComponentVerifyTicket></xml>";
String timestamp = "1715943329";
String nonce = "1590219412";
String encryptedBody = crypt.encryptMsg(plainXml, timestamp, nonce);
String msgSignature = extractTag(encryptedBody, "MsgSignature");
String postData = "<xml><Encrypt><![CDATA[" + extractTag(encryptedBody, "Encrypt") + "]]></Encrypt></xml>";
String decrypted = crypt.decryptMsg(msgSignature, timestamp, nonce, postData);
Assertions.assertTrue(decrypted.contains("component_verify_ticket"));
Assertions.assertTrue(decrypted.contains("ticket_demo_001"));
}
private static String sha1(String... values) {
String[] sorted = Arrays.copyOf(values, values.length);
Arrays.sort(sorted);
return DigestUtil.sha1Hex(String.join("", sorted));
}
private static String extractTag(String xml, String tag) {
String start = "<" + tag + "><![CDATA[";
String end = "]]></" + tag + ">";
int from = xml.indexOf(start);
int to = xml.indexOf(end);
Assertions.assertTrue(from >= 0 && to > from, "缺少节点 " + tag);
return xml.substring(from + start.length(), to);
}
}
@@ -0,0 +1,198 @@
package com.bsd.pjcloud.thirdparty.service.impl.wxopen;
import cn.hutool.crypto.digest.DigestUtil;
import com.bsd.pjcloud.thirdparty.config.WxOpenMessagePushProperties;
import com.bsd.pjcloud.thirdparty.constant.wxopen.WxOpenMessagePushConstants;
import com.bsd.pjcloud.thirdparty.crypto.wxopen.WxOpenMsgCrypt;
import com.bsd.pjcloud.thirdparty.service.wxopen.WxOpenMessagePushHandler;
import com.bsd.pjcloud.thirdparty.vo.wxopen.WxOpenPushMessageVO;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.time.Duration;
import java.util.Arrays;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 消息推送服务编排自测,同时示范业务侧如何接入 Handler / 读取 ticket。
*
* <pre>
* 业务调用示例(Feign):
* R&lt;String&gt; ticket = remoteWxOpenMessagePushService.getComponentVerifyTicket();
*
* 业务扩展示例(同 JVM 注册 Handler):
* &#64;Component
* &#64;Order(100)
* public class BizAuthorizedHandler implements WxOpenMessagePushHandler {
* public boolean supports(WxOpenPushMessageVO m) {
* return "authorized".equals(m.getInfoType());
* }
* public String handle(WxOpenPushMessageVO m) {
* // 使用 m.getAuthorizerAppId() / m.getAuthorizationCode()
* return "success";
* }
* }
* </pre>
*/
@ExtendWith(MockitoExtension.class)
class WxOpenMessagePushServiceImplTest {
private static final String AES_KEY = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG";
private static final String TOKEN = "testToken123";
private static final String APP_ID = "wx1234567890abcdef";
@Mock
private StringRedisTemplate stringRedisTemplate;
@Mock
private ValueOperations<String, String> valueOperations;
@Mock
private WxOpenMessagePushHandler bizHandler;
private WxOpenMessagePushProperties properties;
private WxOpenMessagePushServiceImpl service;
private WxOpenMsgCrypt crypt;
@BeforeEach
void setUp() {
properties = new WxOpenMessagePushProperties();
properties.setEnabled(true);
properties.setComponentAppId(APP_ID);
properties.setToken(TOKEN);
properties.setAesKey(AES_KEY);
lenient().when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations);
service = new WxOpenMessagePushServiceImpl(properties, stringRedisTemplate, List.of(bizHandler));
crypt = new WxOpenMsgCrypt(TOKEN, AES_KEY, APP_ID);
}
@Test
@DisplayName("示例:模拟微信 GET URL 校验")
void verifyUrl_example() {
String timestamp = "1715943329";
String nonce = "nonce001";
String echostr = "echo_demo";
String signature = sha1(TOKEN, timestamp, nonce);
Assertions.assertEquals(echostr, service.verifyUrl(signature, timestamp, nonce, echostr));
}
@Test
@DisplayName("示例:接收 component_verify_ticket 并写入 Redis,业务可再 getComponentVerifyTicket")
void receiveAuthEvent_shouldCacheTicket() {
when(bizHandler.supports(any())).thenReturn(false);
String ticket = "ticket_from_wechat_demo";
String plainXml = "<xml>"
+ "<AppId><![CDATA[" + APP_ID + "]]></AppId>"
+ "<CreateTime>1413192605</CreateTime>"
+ "<InfoType><![CDATA[component_verify_ticket]]></InfoType>"
+ "<ComponentVerifyTicket><![CDATA[" + ticket + "]]></ComponentVerifyTicket>"
+ "</xml>";
String timestamp = "1715943329";
String nonce = "nonceTicket";
String encrypted = crypt.encryptMsg(plainXml, timestamp, nonce);
String msgSignature = extractTag(encrypted, "MsgSignature");
String postData = "<xml><Encrypt><![CDATA[" + extractTag(encrypted, "Encrypt") + "]]></Encrypt></xml>";
String reply = service.receiveAuthEvent(msgSignature, timestamp, nonce, postData);
Assertions.assertEquals(WxOpenMessagePushConstants.SUCCESS, reply);
verify(valueOperations).set(eq(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_KEY), eq(ticket),
eq(Duration.ofSeconds(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_TTL_SECONDS)));
when(valueOperations.get(WxOpenMessagePushConstants.COMPONENT_VERIFY_TICKET_KEY)).thenReturn(ticket);
Assertions.assertEquals(ticket, service.getComponentVerifyTicket());
}
@Test
@DisplayName("示例:业务 Handler 优先处理 authorized 事件")
void receiveAuthEvent_shouldDispatchBizHandler() {
when(bizHandler.supports(any())).thenReturn(true);
when(bizHandler.handle(any())).thenReturn(WxOpenMessagePushConstants.SUCCESS);
String plainXml = "<xml>"
+ "<AppId><![CDATA[" + APP_ID + "]]></AppId>"
+ "<CreateTime>1413192605</CreateTime>"
+ "<InfoType><![CDATA[authorized]]></InfoType>"
+ "<AuthorizerAppid><![CDATA[wx_authorizer_demo]]></AuthorizerAppid>"
+ "<AuthorizationCode><![CDATA[auth_code_demo]]></AuthorizationCode>"
+ "</xml>";
String timestamp = "1715943330";
String nonce = "nonceAuth";
String encrypted = crypt.encryptMsg(plainXml, timestamp, nonce);
String msgSignature = extractTag(encrypted, "MsgSignature");
String postData = "<xml><Encrypt><![CDATA[" + extractTag(encrypted, "Encrypt") + "]]></Encrypt></xml>";
String reply = service.receiveAuthEvent(msgSignature, timestamp, nonce, postData);
Assertions.assertEquals(WxOpenMessagePushConstants.SUCCESS, reply);
ArgumentCaptor<WxOpenPushMessageVO> captor = ArgumentCaptor.forClass(WxOpenPushMessageVO.class);
verify(bizHandler).handle(captor.capture());
WxOpenPushMessageVO message = captor.getValue();
Assertions.assertEquals("authorized", message.getInfoType());
Assertions.assertEquals("wx_authorizer_demo", message.getAuthorizerAppId());
Assertions.assertEquals("auth_code_demo", message.getAuthorizationCode());
verify(valueOperations, never()).set(anyString(), anyString(), any(Duration.class));
}
@Test
@DisplayName("示例:手动 dispatch 已解密消息(联调/重放)")
void dispatch_example() {
when(bizHandler.supports(any())).thenReturn(true);
when(bizHandler.handle(any())).thenReturn(WxOpenMessagePushConstants.SUCCESS);
WxOpenPushMessageVO message = new WxOpenPushMessageVO();
message.setChannel("auth");
message.setInfoType("unauthorized");
message.setAuthorizerAppId("wx_unauth_demo");
Assertions.assertEquals(WxOpenMessagePushConstants.SUCCESS, service.dispatch(message));
verify(bizHandler).handle(message);
}
@Test
@DisplayName("未启用时应拒绝调用")
void shouldRejectWhenDisabled() {
properties.setEnabled(false);
Assertions.assertThrows(IllegalStateException.class,
() -> service.verifyUrl("s", "t", "n", "e"));
}
private static String sha1(String... values) {
String[] sorted = Arrays.copyOf(values, values.length);
Arrays.sort(sorted);
return DigestUtil.sha1Hex(String.join("", sorted));
}
private static String extractTag(String xml, String tag) {
String start = "<" + tag + "><![CDATA[";
String end = "]]></" + tag + ">";
int from = xml.indexOf(start);
int to = xml.indexOf(end);
Assertions.assertTrue(from >= 0 && to > from);
return xml.substring(from + start.length(), to);
}
}