From 4a6515c803c6b4704b88883f0853b7b4946cdd1e Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Fri, 13 Feb 2026 18:08:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E5=A6=82=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../enums/spu/PurchaseLimitTypeEnum.java | 47 ++++ .../spu/FixProductSpuDeviceIdScript.java | 222 +++++++++++++++++ .../app/device/vo/AppDeviceListRespVO.java | 20 ++ .../ProductSpuDataPermissionRule.java | 235 ++++++++++++++++++ .../TradePurchaseLimitOrderHandler.java | 156 ++++++++++++ 5 files changed, 680 insertions(+) create mode 100644 yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/spu/PurchaseLimitTypeEnum.java create mode 100644 yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/spu/FixProductSpuDeviceIdScript.java create mode 100644 yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/device/vo/AppDeviceListRespVO.java create mode 100644 yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/datapermission/ProductSpuDataPermissionRule.java create mode 100644 yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradePurchaseLimitOrderHandler.java diff --git a/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/spu/PurchaseLimitTypeEnum.java b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/spu/PurchaseLimitTypeEnum.java new file mode 100644 index 0000000..659ebfd --- /dev/null +++ b/yudao-module-mall/yudao-module-product-api/src/main/java/cn/iocoder/yudao/module/product/enums/spu/PurchaseLimitTypeEnum.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.product.enums.spu; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 商品限购类型枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum PurchaseLimitTypeEnum implements ArrayValuable { + + NONE(0, "不限购"), + DAILY(1, "每天限购"), + WEEKLY(2, "每周限购"), + LIFETIME(3, "终身限购(任意时间只能买一次)"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(PurchaseLimitTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 类型名 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static PurchaseLimitTypeEnum valueOf(Integer type) { + for (PurchaseLimitTypeEnum value : values()) { + if (value.getType().equals(type)) { + return value; + } + } + return null; + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/spu/FixProductSpuDeviceIdScript.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/spu/FixProductSpuDeviceIdScript.java new file mode 100644 index 0000000..8aeadbc --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/admin/spu/FixProductSpuDeviceIdScript.java @@ -0,0 +1,222 @@ +package cn.iocoder.yudao.module.product.controller.admin.spu; + +import java.sql.*; +import java.util.*; + +/** + * 修复 product_spu 表的 device_id 字段脚本 + * + * 功能: + * 1. 检测 device_id 字段是否错误地存储了设备主键ID(数字) + * 2. 通过关联 product_device 表,将主键ID转换为设备IMEI + * 3. 批量更新所有需要修复的记录 + * + * 背景: + * - product_spu.device_id 字段应该存储设备IMEI(字符串,如"869667070283722") + * - 但如果历史数据中错误地存储了设备表主键ID(数字,如"123"),需要转换为IMEI + * + * 使用方式: + * 1. 配置数据库连接信息 + * 2. 设置 EXECUTE_UPDATE = false 预览要修复的数据 + * 3. 确认无误后设置 EXECUTE_UPDATE = true 执行修复 + * 4. 运行 main 方法 + * + * @author AI Assistant + * @date 2025-01-27 + */ +public class FixProductSpuDeviceIdScript { + + // ========== 配置信息 ========== + private static final String DB_URL = "jdbc:mysql://localhost:3306/xr_v2?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; + private static final String DB_USER = "root"; + private static final String DB_PASSWORD = "root"; + + // 是否执行更新(false 时只预览,不实际执行) + private static final boolean EXECUTE_UPDATE = false; + + public static void main(String[] args) { + FixProductSpuDeviceIdScript script = new FixProductSpuDeviceIdScript(); + try { + script.run(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public void run() throws SQLException { + System.out.println("=========================================="); + System.out.println("product_spu 表 device_id 字段修复脚本"); + System.out.println("=========================================="); + System.out.println("说明:将错误存储的设备主键ID转换为设备IMEI"); + System.out.println("==========================================\n"); + + try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD)) { + // 1. 检测需要修复的记录 + List needFixList = detectNeedFixRecords(conn); + + if (needFixList.isEmpty()) { + System.out.println("✅ 未发现需要修复的记录,所有 device_id 都已正确存储为IMEI格式"); + return; + } + + System.out.println("发现 " + needFixList.size() + " 条需要修复的记录:\n"); + + // 2. 显示修复计划 + displayFixPlan(needFixList); + + if (!EXECUTE_UPDATE) { + System.out.println("\n⚠️ 当前为预览模式,未实际执行修复"); + System.out.println("如需执行修复,请将 EXECUTE_UPDATE 设置为 true"); + return; + } + + // 3. 执行修复 + System.out.println("\n开始执行修复..."); + executeUpdate(conn, needFixList); + + System.out.println("\n=========================================="); + System.out.println("修复完成!"); + System.out.println("=========================================="); + } + } + + /** + * 检测需要修复的记录 + */ + private List detectNeedFixRecords(Connection conn) throws SQLException { + List needFixList = new ArrayList<>(); + + // 查询可能需要修复的记录: + // 1. device_id 不为空 + // 2. device_id 是纯数字(可能是主键ID) + // 3. device_id 长度较短(主键ID通常比IMEI短,IMEI一般15位) + String sql = "SELECT spu.id, spu.name, spu.device_id, spu.creator, spu.create_time, " + + " device.device_id AS device_imei " + + "FROM product_spu spu " + + "LEFT JOIN product_device device ON CAST(spu.device_id AS UNSIGNED) = device.id " + + "WHERE spu.device_id IS NOT NULL " + + " AND spu.device_id != '' " + + " AND spu.device_id REGEXP '^[0-9]+$' " + // 纯数字 + " AND LENGTH(spu.device_id) < 10 " + // 长度小于10(IMEI通常15位) + " AND spu.deleted = 0 " + + "ORDER BY spu.id"; + + try (PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + SpuDeviceInfo info = new SpuDeviceInfo(); + info.spuId = rs.getLong("id"); + info.spuName = rs.getString("name"); + info.currentDeviceId = rs.getString("device_id"); + info.creator = rs.getString("creator"); + info.createTime = rs.getTimestamp("create_time"); + info.correctDeviceImei = rs.getString("device_imei"); + + // 只添加能找到对应设备的记录 + if (info.correctDeviceImei != null && !info.correctDeviceImei.isEmpty()) { + needFixList.add(info); + } else { + System.out.println("⚠️ 警告:SPU ID=" + info.spuId + " 的 device_id=" + info.currentDeviceId + + " 无法找到对应的设备,请手动检查"); + } + } + } + + return needFixList; + } + + /** + * 显示修复计划 + */ + private void displayFixPlan(List needFixList) { + System.out.println("修复计划预览:"); + System.out.println("------------------------------------------------------------------------------"); + System.out.printf("%-8s %-30s %-15s %-20s %-10s%n", + "SPU ID", "商品名称", "当前device_id", "正确IMEI", "创建人"); + System.out.println("------------------------------------------------------------------------------"); + + for (SpuDeviceInfo info : needFixList) { + System.out.printf("%-8d %-30s %-15s %-20s %-10s%n", + info.spuId, + truncate(info.spuName, 30), + info.currentDeviceId, + info.correctDeviceImei, + info.creator != null ? info.creator : "无"); + } + + System.out.println("------------------------------------------------------------------------------"); + System.out.println("说明:"); + System.out.println("- '当前device_id' 列显示的是错误的设备主键ID(数字)"); + System.out.println("- '正确IMEI' 列显示的是对应设备的IMEI(将被更新到 device_id 字段)"); + } + + /** + * 执行更新 + */ + private void executeUpdate(Connection conn, List needFixList) throws SQLException { + String sql = "UPDATE product_spu SET device_id = ?, update_time = NOW() WHERE id = ?"; + + conn.setAutoCommit(false); + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int successCount = 0; + int failCount = 0; + + for (SpuDeviceInfo info : needFixList) { + try { + stmt.setString(1, info.correctDeviceImei); + stmt.setLong(2, info.spuId); + int count = stmt.executeUpdate(); + + if (count > 0) { + successCount++; + System.out.println("✓ SPU ID=" + info.spuId + " 已更新:" + + info.currentDeviceId + " → " + info.correctDeviceImei); + } else { + failCount++; + System.out.println("✗ SPU ID=" + info.spuId + " 更新失败"); + } + } catch (SQLException e) { + failCount++; + System.out.println("✗ SPU ID=" + info.spuId + " 更新异常:" + e.getMessage()); + } + } + + conn.commit(); + System.out.println("\n更新统计:"); + System.out.println("- 成功:" + successCount + " 条"); + System.out.println("- 失败:" + failCount + " 条"); + + } catch (Exception e) { + conn.rollback(); + System.err.println("更新操作失败,已回滚: " + e.getMessage()); + throw e; + } finally { + conn.setAutoCommit(true); + } + } + + /** + * 截断字符串 + */ + private String truncate(String str, int maxLength) { + if (str == null) { + return ""; + } + if (str.length() <= maxLength) { + return str; + } + return str.substring(0, maxLength - 3) + "..."; + } + + /** + * SPU 设备信息类 + */ + static class SpuDeviceInfo { + Long spuId; + String spuName; + String currentDeviceId; // 当前存储的device_id(可能是错误的主键ID) + String correctDeviceImei; // 正确的设备IMEI + String creator; + Timestamp createTime; + } +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/device/vo/AppDeviceListRespVO.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/device/vo/AppDeviceListRespVO.java new file mode 100644 index 0000000..3e62d90 --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/controller/app/device/vo/AppDeviceListRespVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.product.controller.app.device.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 用户 APP - 售货机列表 Response VO + * + * 用于移动端展示售货机选择列表(只返回启用的设备) + */ +@Schema(description = "用户 APP - 售货机列表 Response VO") +@Data +public class AppDeviceListRespVO { + + @Schema(description = "设备 IMEI", requiredMode = Schema.RequiredMode.REQUIRED, example = "869667070287061") + private String deviceId; + + @Schema(description = "售货机名称(显示在顶部地址栏)", requiredMode = Schema.RequiredMode.REQUIRED, example = "一号楼售货机") + private String deviceName; +} diff --git a/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/datapermission/ProductSpuDataPermissionRule.java b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/datapermission/ProductSpuDataPermissionRule.java new file mode 100644 index 0000000..625cc1d --- /dev/null +++ b/yudao-module-mall/yudao-module-product-biz/src/main/java/cn/iocoder/yudao/module/product/framework/datapermission/ProductSpuDataPermissionRule.java @@ -0,0 +1,235 @@ +package cn.iocoder.yudao.module.product.framework.datapermission; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.datapermission.core.rule.DataPermissionRule; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.framework.security.core.LoginUser; +import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; +import cn.iocoder.yudao.module.product.dal.dataobject.device.DeviceDO; +import cn.iocoder.yudao.module.product.dal.dataobject.spu.ProductSpuDO; +import cn.iocoder.yudao.module.product.enums.spu.ProductSpuTypesEnum; +import cn.iocoder.yudao.module.system.api.permission.PermissionApi; +import cn.iocoder.yudao.module.system.api.permission.dto.DeptDataPermissionRespDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.google.common.collect.Sets; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.conditional.AndExpression; +import net.sf.jsqlparser.expression.operators.conditional.OrExpression; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.InExpression; +import net.sf.jsqlparser.expression.Parenthesis; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 自定义商品 SPU 数据权限规则: + * - types = 0(商城商品):按部门数据权限(dept_id + creator)过滤 + * - types = 1(售货机商品):按当前用户绑定的设备过滤(product_spu.device_id 在用户绑定设备的 IMEI 列表中) + * - 超级管理员(super_admin 角色):不过滤,返回全部 + * + * 注意:ProductSpuDO 已在 ProductWebConfiguration 中移除通用的 DeptDataPermissionRule 绑定, + * 避免与本规则叠加冲突。 + */ +@Component +public class ProductSpuDataPermissionRule implements DataPermissionRule { + + private static final String PRODUCT_SPU_TABLE = + TableInfoHelper.getTableInfo(ProductSpuDO.class).getTableName(); + + @Resource + private PermissionApi permissionApi; + @Resource + private AdminUserApi adminUserApi; + @Resource + private cn.iocoder.yudao.module.product.dal.mysql.device.DeviceMapper deviceMapper; + + @Override + public Set getTableNames() { + return Sets.newHashSet(PRODUCT_SPU_TABLE); + } + + @Override + public Expression getExpression(String tableName, Alias tableAlias) { + LoginUser loginUser = SecurityFrameworkUtils.getLoginUser(); + if (loginUser == null) { + return null; + } + // 只对管理端用户生效 + if (!ObjectUtil.equal(loginUser.getUserType(), UserTypeEnum.ADMIN.getValue())) { + return null; + } + + Long userId = loginUser.getId(); + + // 超级管理员:不过滤任何数据 + if (permissionApi.hasAnyRoles(userId, "super_admin")) { + return null; + } + + // 1. 构造「商城商品」(types = 0) 的部门数据权限表达式 + Expression mallExpr = buildMallExpression(tableName, tableAlias, loginUser); + + // 2. 构造「售货机商品」(types = 1) 的设备绑定数据权限表达式 + Expression deviceExpr = buildDeviceExpression(tableName, tableAlias, userId); + + // 3. 最终条件: (types = 0 AND 部门权限) OR (types = 1 AND 设备绑定权限) + if (mallExpr == null && deviceExpr == null) { + // 没有任何权限,返回一个恒为假的条件,保证查不到数据 + return new EqualsTo(null, null); + } + if (mallExpr == null) { + return deviceExpr; + } + if (deviceExpr == null) { + return mallExpr; + } + return new Parenthesis(new OrExpression(mallExpr, deviceExpr)); + } + + /** + * 构建商城商品(types = 0)的部门数据权限表达式 + */ + private Expression buildMallExpression(String tableName, Alias tableAlias, LoginUser loginUser) { + DeptDataPermissionRespDTO deptDataPermission = permissionApi.getDeptDataPermission(loginUser.getId()); + if (deptDataPermission == null) { + // 没有任何数据权限配置,直接返回空 + return null; + } + // 情况一,如果是 ALL 可查看全部,则无需 Dept/User 条件,但仍然需要 types = 0(商城商品) + if (Boolean.TRUE.equals(deptDataPermission.getAll())) { + return buildTypesEqualsExpression(tableName, tableAlias, ProductSpuTypesEnum.DISABLE.getTypes()); + } + + // 情况二,即不能查看部门,又不能查看自己,则说明 100% 无权限 + if (CollUtil.isEmpty(deptDataPermission.getDeptIds()) + && Boolean.FALSE.equals(deptDataPermission.getSelf())) { + // 仍然返回恒假,保证查不到商城商品 + Expression alwaysFalse = new EqualsTo(null, null); + return new AndExpression( + buildTypesEqualsExpression(tableName, tableAlias, ProductSpuTypesEnum.DISABLE.getTypes()), + alwaysFalse + ); + } + + // 情况三,拼接 Dept 和 User 的条件,最后组合 + Expression deptExpression = buildDeptExpression(tableName, tableAlias, deptDataPermission.getDeptIds()); + Expression userExpression = buildUserExpression(tableName, tableAlias, + deptDataPermission.getSelf(), loginUser.getId()); + + Expression deptUserExpr; + if (deptExpression == null && userExpression == null) { + // 理论上不会出现,如果出现则不做部门过滤 + deptUserExpr = null; + } else if (deptExpression == null) { + deptUserExpr = userExpression; + } else if (userExpression == null) { + deptUserExpr = deptExpression; + } else { + // WHERE (dept_id IN ? OR creator = ?) + deptUserExpr = new Parenthesis(new OrExpression(deptExpression, userExpression)); + } + + if (deptUserExpr == null) { + // 仅按照 types = 0 过滤(商城商品) + return buildTypesEqualsExpression(tableName, tableAlias, ProductSpuTypesEnum.DISABLE.getTypes()); + } + + // 最终:types = 0 AND (dept/user 条件) + return new AndExpression( + buildTypesEqualsExpression(tableName, tableAlias, ProductSpuTypesEnum.DISABLE.getTypes()), + deptUserExpr + ); + } + + /** + * 构建售货机商品(types = 1)的设备绑定数据权限表达式 + */ + private Expression buildDeviceExpression(String tableName, Alias tableAlias, Long userId) { + // 当前用户绑定的设备主键 ID 列表 + List userDeviceIds = adminUserApi.getUserDeviceIds(userId); + if (CollUtil.isEmpty(userDeviceIds)) { + return null; + } + + // 查询对应的设备,拿到 IMEI 列表 + List devices = deviceMapper.selectBatchIds(userDeviceIds); + if (CollUtil.isEmpty(devices)) { + return null; + } + List deviceImeis = devices.stream() + .map(DeviceDO::getDeviceId) + .filter(StrUtil::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + if (CollUtil.isEmpty(deviceImeis)) { + return null; + } + + // device_id IN (imei1, imei2, ...) + ExpressionList exprList = new ExpressionList( + deviceImeis.stream().map(StringValue::new).collect(Collectors.toList())); + InExpression inDeviceExpr = new InExpression( + MyBatisUtils.buildColumn(tableName, tableAlias, "device_id"), + new Parenthesis(exprList) + ); + + // 最终:types = 1 AND device_id IN (绑定设备 IMEI) + return new AndExpression( + buildTypesEqualsExpression(tableName, tableAlias, ProductSpuTypesEnum.ENABLE.getTypes()), + inDeviceExpr + ); + } + + /** + * 构建 dept_id IN (...) 条件 + */ + private Expression buildDeptExpression(String tableName, Alias tableAlias, Set deptIds) { + if (CollUtil.isEmpty(deptIds)) { + return null; + } + ExpressionList exprList = new ExpressionList( + deptIds.stream().map(LongValue::new).collect(Collectors.toList())); + return new InExpression( + MyBatisUtils.buildColumn(tableName, tableAlias, "dept_id"), + new Parenthesis(exprList) + ); + } + + /** + * 构建 creator = 当前用户 条件 + */ + private Expression buildUserExpression(String tableName, Alias tableAlias, + Boolean self, Long userId) { + if (Boolean.FALSE.equals(self)) { + return null; + } + return new EqualsTo( + MyBatisUtils.buildColumn(tableName, tableAlias, "creator"), + new LongValue(userId) + ); + } + + /** + * 构建 types = ? 条件 + */ + private Expression buildTypesEqualsExpression(String tableName, Alias tableAlias, Integer types) { + return new EqualsTo( + MyBatisUtils.buildColumn(tableName, tableAlias, "types"), + new LongValue(types.longValue()) + ); + } +} + diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradePurchaseLimitOrderHandler.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradePurchaseLimitOrderHandler.java new file mode 100644 index 0000000..1442cad --- /dev/null +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/order/handler/TradePurchaseLimitOrderHandler.java @@ -0,0 +1,156 @@ +package cn.iocoder.yudao.module.trade.service.order.handler; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi; +import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO; +import cn.iocoder.yudao.module.product.enums.spu.PurchaseLimitTypeEnum; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderItemMapper; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderTypeEnum; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.Resource; +import java.time.LocalDateTime; +import java.time.temporal.TemporalAdjusters; +import java.util.*; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*; + +/** + * 限购校验订单处理器 + *

+ * 在订单创建前(beforeOrderCreate),校验商品是否设置了限购规则,如果设置了则检查用户历史购买记录是否超限 + *

+ * 限购规则仅对售货机订单(type = MACHINE)生效 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class TradePurchaseLimitOrderHandler implements TradeOrderHandler { + + @Resource + private ProductSpuApi productSpuApi; + + @Resource + private TradeOrderItemMapper tradeOrderItemMapper; + + @Override + public void beforeOrderCreate(TradeOrderDO order, List orderItems) { + // 限购校验仅对售货机订单生效 + if (!Objects.equals(order.getType(), TradeOrderTypeEnum.MACHINE.getType())) { + return; + } + + // 获取订单中所有的 SPU ID + Set spuIds = orderItems.stream() + .map(TradeOrderItemDO::getSpuId) + .collect(Collectors.toSet()); + if (CollUtil.isEmpty(spuIds)) { + return; + } + + // 批量查询 SPU 信息 + List spuList = productSpuApi.getSpuList(new ArrayList<>(spuIds)); + if (CollUtil.isEmpty(spuList)) { + return; + } + + // 过滤出有限购规则的商品 + List limitedSpus = spuList.stream() + .filter(spu -> spu.getPurchaseLimitType() != null + && !Objects.equals(spu.getPurchaseLimitType(), PurchaseLimitTypeEnum.NONE.getType()) + && spu.getPurchaseLimitCount() != null + && spu.getPurchaseLimitCount() > 0) + .collect(Collectors.toList()); + + if (CollUtil.isEmpty(limitedSpus)) { + return; + } + + // 逐个校验限购规则 + for (ProductSpuRespDTO spu : limitedSpus) { + // 查找本次订单中该 SPU 的购买数量 + int currentPurchaseCount = orderItems.stream() + .filter(item -> Objects.equals(item.getSpuId(), spu.getId())) + .mapToInt(TradeOrderItemDO::getCount) + .sum(); + + // 查询用户历史购买记录(根据限购类型决定查询时间范围) + int historyPurchaseCount = getHistoryPurchaseCount(order.getUserId(), spu.getId(), spu.getPurchaseLimitType()); + + // 判断是否超限 + int totalCount = currentPurchaseCount + historyPurchaseCount; + if (totalCount > spu.getPurchaseLimitCount()) { + String limitDesc = getLimitDescription(spu.getPurchaseLimitType()); + throw exception(ORDER_CREATE_PURCHASE_LIMIT_EXCEED, spu.getName(), limitDesc, spu.getPurchaseLimitCount()); + } + } + } + + /** + * 查询用户历史购买数量(只统计已支付的订单) + * + * @param userId 用户 ID + * @param spuId 商品 SPU ID + * @param limitType 限购类型 + * @return 历史购买数量 + */ + private int getHistoryPurchaseCount(Long userId, Long spuId, Integer limitType) { + PurchaseLimitTypeEnum limitTypeEnum = PurchaseLimitTypeEnum.valueOf(limitType); + if (limitTypeEnum == null || limitTypeEnum == PurchaseLimitTypeEnum.NONE) { + return 0; + } + + LocalDateTime startTime = getStartTimeByLimitType(limitTypeEnum); + if (startTime == null) { + // 终身限购:不限制开始时间 + startTime = LocalDateTime.of(2000, 1, 1, 0, 0); + } + + // 查询该时间范围内,用户已支付的订单项(订单项表 trade_order_item) + // 注意:需要关联订单表(trade_order)判断订单是否已支付(pay_status = true) + return tradeOrderItemMapper.selectPurchaseCountByUserAndSpu(userId, spuId, startTime); + } + + /** + * 根据限购类型获取查询起始时间 + * + * @param limitType 限购类型 + * @return 起始时间 + */ + private LocalDateTime getStartTimeByLimitType(PurchaseLimitTypeEnum limitType) { + LocalDateTime now = LocalDateTime.now(); + switch (limitType) { + case DAILY: + // 今天 00:00:00 + return now.toLocalDate().atStartOfDay(); + case WEEKLY: + // 本周一 00:00:00 + return now.with(TemporalAdjusters.previousOrSame(java.time.DayOfWeek.MONDAY)) + .toLocalDate().atStartOfDay(); + case LIFETIME: + // 终身限购:不限制开始时间(从很早的时间开始统计) + return null; + default: + return null; + } + } + + /** + * 获取限购类型的可读描述 + */ + private String getLimitDescription(Integer limitType) { + PurchaseLimitTypeEnum limitTypeEnum = PurchaseLimitTypeEnum.valueOf(limitType); + if (limitTypeEnum == null) { + return "限购"; + } + return limitTypeEnum.getName(); + } +}