From da07e0f4541a7fe845e89bea4fb79bbeffad2b15 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 4 Sep 2026 13:46:24 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=8F=9C=E8=B0=B1=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=89=8D=E5=A2=9E=E5=8A=A0=E9=A2=84=E8=A7=88=E6=95=88?= =?UTF-8?q?=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CanteenDailyMenuDayLockDTO.java | 21 + .../dto/CanteenDailyMenuImportConfirmDTO.java | 29 + .../entity/CanteenDailyMenuDayLockEntity.java | 62 ++ .../CanteenDailyMenuImportLogDayEntity.java | 63 ++ .../CanteenDailyMenuImportLogEntity.java | 95 +++ .../vo/CanteenDailyMenuImportPreviewVO.java | 101 +++ .../CanteenDailyMenuController.java | 105 +++- .../mapper/CanteenDailyMenuDayLockMapper.java | 9 + .../CanteenDailyMenuImportLogDayMapper.java | 9 + .../CanteenDailyMenuImportLogMapper.java | 9 + .../CanteenDailyMenuLockLogService.java | 58 ++ .../impl/CanteenDailyMenuImportHelper.java | 581 +++++++++++++++++- .../CanteenDailyMenuLockLogServiceImpl.java | 233 +++++++ .../impl/CanteenDailyMenuServiceImpl.java | 24 +- .../feign/RemoteWxOpenMessagePushService.java | 46 ++ .../wxopen/WxOpenMessagePushConstants.java | 31 + .../vo/wxopen/WxOpenPushMessageVO.java | 69 +++ .../config/WxOpenMessagePushProperties.java | 40 ++ .../wxopen/WxOpenMessagePushController.java | 121 ++++ .../WxOpenMessagePushRemoterController.java | 52 ++ .../crypto/wxopen/WxOpenMsgCrypt.java | 225 +++++++ .../DefaultWxOpenMessagePushHandler.java | 44 ++ .../wxopen/WxOpenMessagePushServiceImpl.java | 176 ++++++ .../wxopen/WxOpenMessagePushHandler.java | 26 + .../wxopen/WxOpenMessagePushService.java | 43 ++ .../src/main/resources/application.yml | 19 + .../WxOpenMessagePushControllerTest.java | 56 ++ .../crypto/wxopen/WxOpenMsgCryptTest.java | 83 +++ .../WxOpenMessagePushServiceImplTest.java | 198 ++++++ 29 files changed, 2607 insertions(+), 21 deletions(-) create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuDayLockDTO.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportConfirmDTO.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuDayLockEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogDayEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogEntity.java create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuDayLockMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogDayMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogMapper.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuLockLogService.java create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuLockLogServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteWxOpenMessagePushService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/wxopen/WxOpenMessagePushConstants.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/wxopen/WxOpenPushMessageVO.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/WxOpenMessagePushProperties.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushRemoterController.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCrypt.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/DefaultWxOpenMessagePushHandler.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImpl.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushHandler.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushService.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushControllerTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCryptTest.java create mode 100644 pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImplTest.java diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuDayLockDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuDayLockDTO.java new file mode 100644 index 00000000..725e90c2 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuDayLockDTO.java @@ -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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportConfirmDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportConfirmDTO.java new file mode 100644 index 00000000..85fe7578 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportConfirmDTO.java @@ -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 rows; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuDayLockEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuDayLockEntity.java new file mode 100644 index 00000000..b8c2b1c1 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuDayLockEntity.java @@ -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 { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogDayEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogDayEntity.java new file mode 100644 index 00000000..6e069efc --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogDayEntity.java @@ -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 { + + @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; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogEntity.java new file mode 100644 index 00000000..03e3f0f1 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuImportLogEntity.java @@ -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 { + + @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 dayDetails; +} diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java new file mode 100644 index 00000000..17d13aec --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java @@ -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 rows = new ArrayList<>(); + + @Schema(description = "按日汇总") + private List 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; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index 1873b1d8..51a253af 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -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()); + } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuDayLockMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuDayLockMapper.java new file mode 100644 index 00000000..20ed8a34 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuDayLockMapper.java @@ -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 { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogDayMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogDayMapper.java new file mode 100644 index 00000000..8a9d72ec --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogDayMapper.java @@ -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 { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogMapper.java new file mode 100644 index 00000000..229690ee --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuImportLogMapper.java @@ -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 { +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuLockLogService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuLockLogService.java new file mode 100644 index 00000000..01bc8624 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuLockLogService.java @@ -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 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 dates); + + /** + * 设置锁定/解锁 + */ + R setDayLock(CanteenDailyMenuDayLockDTO dto); + + /** + * 列出日期范围内的锁定记录 + */ + List listLocks(Long merchantId, String startDate, String endDate); + + /** + * 分页查询导入日志 + */ + Page pageImportLogs(Page page, Long merchantId, String bizDate); + + /** + * 导入日志详情(含按日明细) + */ + CanteenDailyMenuImportLogEntity getImportLogDetail(Long id); +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java index 4bc7963f..b940fa10 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java @@ -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 templateRows() { List 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 errors = new ArrayList<>(); + List 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 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 categoryMap = loadCategoryMap(merchantId); - List errors = new ArrayList<>(); List 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 dataRows, Long merchantId) { + CanteenDailyMenuImportPreviewVO vo = new CanteenDailyMenuImportPreviewVO(); + List errors = new ArrayList<>(); + Map> groups = new LinkedHashMap<>(); + Map rowNos = new HashMap<>(); + Set involvedDates = new HashSet<>(); + Set 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 lockedDates = (min != null && max != null) + ? lockLogService.listLockedDates(merchantId, min, max) + : Set.of(); + ImportCache cache = buildImportCache(merchantId, involvedDates, excelMenuIds); + + Map dayMap = new LinkedHashMap<>(); + int totalInsert = 0; + int totalUpdate = 0; + int totalSkip = 0; + + for (Map.Entry> e : groups.entrySet()) { + List 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 dataRows, Long merchantId, Long categoryId, + boolean replaceDay, String fileName, boolean skipLocked) { + Long deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId); + Map categoryMap = loadCategoryMap(merchantId); + + List errors = new ArrayList<>(); Map> groups = new LinkedHashMap<>(); Set involvedDates = new HashSet<>(); Set 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 lockedDates = (min != null && max != null) + ? lockLogService.listLockedDates(merchantId, min, max) + : Set.of(); + ImportCache cache = buildImportCache(merchantId, involvedDates, excelMenuIds); Set clearedDayHour = new LinkedHashSet<>(); List pendingMenus = new ArrayList<>(); @@ -371,12 +644,30 @@ public class CanteenDailyMenuImportHelper { int createdCombo = 0; int scheduled = 0; int idAsNew = 0; + int skipLockedRows = 0; + + Map dayStats = new LinkedHashMap<>(); for (Map.Entry> e : groups.entrySet()) { List 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 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 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 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 dataRows; + } + + private static class DayImportStat { + final String bizDate; + int insertCount; + int updateCount; + int skipCount; + final LinkedHashSet dishes = new LinkedHashSet<>(); + final LinkedHashSet breakfast = new LinkedHashSet<>(); + final LinkedHashSet lunch = new LinkedHashSet<>(); + final LinkedHashSet dinner = new LinkedHashSet<>(); + final LinkedHashSet 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 meals = parseMeals(meal); + return meals.size() == 1 ? meals.get(0) : null; + } + + /** + * 解析餐段,支持: + *
    + *
  • 单餐:早餐 / 午餐(中餐) / 晚餐
  • + *
  • 多餐:午餐,晚餐 / 午餐、晚餐 / 午餐/晚餐
  • + *
  • 快捷:三餐、全天、早中晚 → 早+午+晚;中晚餐 → 午+晚
  • + *
+ * 同一道菜多餐时菜品库仍按菜名复用一份 SPU,排菜按餐段各挂一条。 + */ + private static List 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 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 expandMultiMealRows(List rows, + List errors) { + List expanded = new ArrayList<>(rows.size()); + int line = 1; + for (CanteenDailyMenuImportExcel row : rows) { + line++; + List 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; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuLockLogServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuLockLogServiceImpl.java new file mode 100644 index 00000000..a7e98ff1 --- /dev/null +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuLockLogServiceImpl.java @@ -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 listLockedDates(Long merchantId, LocalDate start, LocalDate end) { + if (merchantId == null || start == null || end == null) { + return Set.of(); + } + List list = dayLockMapper.selectList(Wrappers.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.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 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 locked = listLockedDates(merchantId, min, max); + if (locked.isEmpty()) { + return; + } + List 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.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 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.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 pageImportLogs(Page page, Long merchantId, String bizDate) { + if (StrUtil.isNotBlank(bizDate)) { + List dayRows = importLogDayMapper.selectList( + Wrappers.lambdaQuery() + .eq(merchantId != null, CanteenDailyMenuImportLogDayEntity::getMerchantId, merchantId) + .eq(CanteenDailyMenuImportLogDayEntity::getBizDate, bizDate.trim()) + .select(CanteenDailyMenuImportLogDayEntity::getLogId)); + Set logIds = dayRows.stream() + .map(CanteenDailyMenuImportLogDayEntity::getLogId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + if (logIds.isEmpty()) { + return page; + } + return importLogMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(merchantId != null, CanteenDailyMenuImportLogEntity::getMerchantId, merchantId) + .in(CanteenDailyMenuImportLogEntity::getId, logIds) + .orderByDesc(CanteenDailyMenuImportLogEntity::getCreateTime)); + } + return importLogMapper.selectPage(page, Wrappers.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 days = importLogDayMapper.selectList( + Wrappers.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"; + } +} diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java index 19c53d5a..dd494b83 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java @@ -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 spuIds = Optional.ofNullable(canteenDailyMenu.getSpuIds()).orElse(Collections.emptyList()); if (spuIds.isEmpty()) { return; @@ -228,6 +235,15 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl menus = CollectionUtils.isEmpty(list) ? Collections.emptyList() : baseMapper.selectBatchIds(list); + Set 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 availabilityLocks = acquireAvailabilityLocks(menus); try { registerAvailabilityLockRelease(availabilityLocks); @@ -492,6 +508,13 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl targetMerchantIds = creenDailyMenuCVDTO.getTargetMerchantId(); + if (CollUtil.isNotEmpty(targetMerchantIds) && startTime != null) { + for (Long targetMerchantId : targetMerchantIds) { + dailyMenuLockLogService.assertDayWritable(targetMerchantId, startTime); + } + } LambdaQueryWrapper canteenDailyMenuEntityLambdaQueryWrapper = new LambdaQueryWrapper<>(); canteenDailyMenuEntityLambdaQueryWrapper .eq(CanteenDailyMenuEntity::getMerchantId, sourceMerchantId); @@ -501,7 +524,6 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl targetMerchantIds = creenDailyMenuCVDTO.getTargetMerchantId(); for (Long targetMerchantId : targetMerchantIds) { // 查询目标商户已存在的spuId Set existKeys = baseMapper.selectList(new LambdaQueryWrapper() diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteWxOpenMessagePushService.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteWxOpenMessagePushService.java new file mode 100644 index 00000000..11803e62 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/api/feign/RemoteWxOpenMessagePushService.java @@ -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 getComponentVerifyTicket(); + + /** + * 加密业务回包为微信安全模式 XML(需自行作为 HTTP 响应体返回给微信)。 + * + * @param plainXml 回包明文 XML + * @param timestamp 时间戳,可空则服务端生成 + * @param nonce 随机串,可空则服务端生成 + */ + @NoToken + @PostMapping("/wx-open-remoter/encrypt-reply") + R 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 dispatch(@RequestBody WxOpenPushMessageVO message); + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/wxopen/WxOpenMessagePushConstants.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/wxopen/WxOpenMessagePushConstants.java new file mode 100644 index 00000000..13f54af0 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/constant/wxopen/WxOpenMessagePushConstants.java @@ -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"; + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/wxopen/WxOpenPushMessageVO.java b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/wxopen/WxOpenPushMessageVO.java new file mode 100644 index 00000000..7993c312 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-api/src/main/java/com/bsd/pjcloud/thirdparty/vo/wxopen/WxOpenPushMessageVO.java @@ -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 fields = new LinkedHashMap<>(); + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/WxOpenMessagePushProperties.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/WxOpenMessagePushProperties.java new file mode 100644 index 00000000..2dbbb9b5 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/config/WxOpenMessagePushProperties.java @@ -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; + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushController.java new file mode 100644 index 00000000..3244b69b --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushController.java @@ -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; + +/** + * 微信开放平台第三方消息推送回调入口。 + *

+ * 网关 StripPrefix=1 后,开放平台可配置: + *

    + *
  • 授权事件接收:https://{域名}/third/wx-open/auth-event
  • + *
  • 消息与事件接收:https://{域名}/third/wx-open/msg-event/$APPID$
  • + *
+ * + * @see 消息推送 + */ +@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"; + } + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushRemoterController.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushRemoterController.java new file mode 100644 index 00000000..a24fc851 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushRemoterController.java @@ -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 getComponentVerifyTicket() { + return R.ok(wxOpenMessagePushService.getComponentVerifyTicket()); + } + + @PostMapping("/encrypt-reply") + @Operation(summary = "加密业务回包") + public R 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 dispatch(@RequestBody WxOpenPushMessageVO message) { + return R.ok(wxOpenMessagePushService.dispatch(message)); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCrypt.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCrypt.java new file mode 100644 index 00000000..c8b1ea06 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCrypt.java @@ -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 "" + + "" + + "" + + "" + timestamp + "" + + "" + + ""; + } + + /** + * 明文模式/兼容模式 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); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/DefaultWxOpenMessagePushHandler.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/DefaultWxOpenMessagePushHandler.java new file mode 100644 index 00000000..647ec9f1 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/DefaultWxOpenMessagePushHandler.java @@ -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; + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImpl.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImpl.java new file mode 100644 index 00000000..43e5446c --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImpl.java @@ -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 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 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 flattenXml(String plainXml) { + Map 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"); + } + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushHandler.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushHandler.java new file mode 100644 index 00000000..b7685d37 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushHandler.java @@ -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); + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushService.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushService.java new file mode 100644 index 00000000..bd188791 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/java/com/bsd/pjcloud/thirdparty/service/wxopen/WxOpenMessagePushService.java @@ -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); + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml index 9e005146..ae1bf739 100644 --- a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/main/resources/application.yml @@ -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" diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushControllerTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushControllerTest.java new file mode 100644 index 00000000..b5551701 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/controller/wxopen/WxOpenMessagePushControllerTest.java @@ -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 容器,只验证委托关系)。 + * + *
+ * 对外(经网关):
+ *   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)
+ * 
+ */ +@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(""))) + .thenReturn("success"); + assertEquals("success", controller.authEventPost("", "msig", "ts", "n")); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCryptTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCryptTest.java new file mode 100644 index 00000000..e543de96 --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/crypto/wxopen/WxOpenMsgCryptTest.java @@ -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 = "" + + ""; + String timestamp = "1715943329"; + String nonce = "1590219412"; + + String encryptedBody = crypt.encryptMsg(plainXml, timestamp, nonce); + String msgSignature = extractTag(encryptedBody, "MsgSignature"); + String postData = ""; + + 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 + ">"; + int from = xml.indexOf(start); + int to = xml.indexOf(end); + Assertions.assertTrue(from >= 0 && to > from, "缺少节点 " + tag); + return xml.substring(from + start.length(), to); + } + +} diff --git a/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImplTest.java b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImplTest.java new file mode 100644 index 00000000..2e3d8f2f --- /dev/null +++ b/pjcloud-thirdparty/pjcloud-thirdparty-biz/src/test/java/com/bsd/pjcloud/thirdparty/service/impl/wxopen/WxOpenMessagePushServiceImplTest.java @@ -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。 + * + *
+ * 业务调用示例(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";
+ *       }
+ *   }
+ * 
+ */ +@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 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 = "" + + "" + + "1413192605" + + "" + + "" + + ""; + String timestamp = "1715943329"; + String nonce = "nonceTicket"; + String encrypted = crypt.encryptMsg(plainXml, timestamp, nonce); + String msgSignature = extractTag(encrypted, "MsgSignature"); + String postData = ""; + + 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 = "" + + "" + + "1413192605" + + "" + + "" + + "" + + ""; + String timestamp = "1715943330"; + String nonce = "nonceAuth"; + String encrypted = crypt.encryptMsg(plainXml, timestamp, nonce); + String msgSignature = extractTag(encrypted, "MsgSignature"); + String postData = ""; + + String reply = service.receiveAuthEvent(msgSignature, timestamp, nonce, postData); + + Assertions.assertEquals(WxOpenMessagePushConstants.SUCCESS, reply); + ArgumentCaptor 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 + ">"; + int from = xml.indexOf(start); + int to = xml.indexOf(end); + Assertions.assertTrue(from >= 0 && to > from); + return xml.substring(from + start.length(), to); + } + +}