小如重构

This commit is contained in:
zjf committed 2026-02-13 18:08:53 +08:00
1 parent 984b2007a8
commit 4a6515c803
5 files changed
+680

No files matched your search

@@ -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<Integer> {
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;
}
}
@@ -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<SpuDeviceInfo> 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<SpuDeviceInfo> detectNeedFixRecords(Connection conn) throws SQLException {
List<SpuDeviceInfo> 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<SpuDeviceInfo> 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<SpuDeviceInfo> 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;
}
}
@@ -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;
}
@@ -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<String> 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<Long> userDeviceIds = adminUserApi.getUserDeviceIds(userId);
if (CollUtil.isEmpty(userDeviceIds)) {
return null;
}
// 查询对应的设备,拿到 IMEI 列表
List<DeviceDO> devices = deviceMapper.selectBatchIds(userDeviceIds);
if (CollUtil.isEmpty(devices)) {
return null;
}
List<String> 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<Long> 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())
);
}
}
@@ -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.*;
/**
* 限购校验订单处理器
* <p>
* 在订单创建前(beforeOrderCreate),校验商品是否设置了限购规则,如果设置了则检查用户历史购买记录是否超限
* <p>
* 限购规则仅对售货机订单(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<TradeOrderItemDO> orderItems) {
// 限购校验仅对售货机订单生效
if (!Objects.equals(order.getType(), TradeOrderTypeEnum.MACHINE.getType())) {
return;
}
// 获取订单中所有的 SPU ID
Set<Long> spuIds = orderItems.stream()
.map(TradeOrderItemDO::getSpuId)
.collect(Collectors.toSet());
if (CollUtil.isEmpty(spuIds)) {
return;
}
// 批量查询 SPU 信息
List<ProductSpuRespDTO> spuList = productSpuApi.getSpuList(new ArrayList<>(spuIds));
if (CollUtil.isEmpty(spuList)) {
return;
}
// 过滤出有限购规则的商品
List<ProductSpuRespDTO> 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();
}
}