每日菜谱导入前增加预览效果
This commit is contained in:
1 parent
1271a2d743
commit
da07e0f454
29 files changed
+2607
-21
No files matched your search
+21
@@ -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;
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+62
@@ -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;
|
||||
}
|
||||
+63
@@ -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;
|
||||
}
|
||||
+95
@@ -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;
|
||||
}
|
||||
+101
@@ -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;
|
||||
}
|
||||
}
|
||||
+102
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+9
@@ -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> {
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
+564
-17
@@ -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;
|
||||
|
||||
+233
@@ -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";
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -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>()
|
||||
|
||||
+46
@@ -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);
|
||||
|
||||
}
|
||||
+31
@@ -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";
|
||||
|
||||
}
|
||||
+69
@@ -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<>();
|
||||
|
||||
}
|
||||
+40
@@ -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;
|
||||
|
||||
}
|
||||
+121
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+52
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+225
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+176
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+26
@@ -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);
|
||||
|
||||
}
|
||||
+43
@@ -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"
|
||||
+56
@@ -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=&timestamp=&nonce=&echostr=
|
||||
* POST https://{域名}/third/wx-open/auth-event?msg_signature=&timestamp=&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"));
|
||||
}
|
||||
|
||||
}
|
||||
+83
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+198
@@ -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<String> ticket = remoteWxOpenMessagePushService.getComponentVerifyTicket();
|
||||
*
|
||||
* 业务扩展示例(同 JVM 注册 Handler):
|
||||
* @Component
|
||||
* @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);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user