Quellcode durchsuchen

积分校验,有效期相关代码提交

yjwang vor 20 Stunden
Ursprung
Commit
a51bc0b8a8

+ 51 - 0
fs-admin/src/main/java/com/fs/his/controller/FsIntegralGoodsController.java

@@ -20,6 +20,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -134,6 +135,56 @@ public class FsIntegralGoodsController extends BaseController
         return toAjax(fsIntegralGoodsService.deleteFsIntegralGoodsByGoodsIds(goodsIds));
     }
 
+    /**
+     * 批量修改有效期
+     * <p>支持长期有效和非长期有效两种模式,非长期有效需指定开始/结束时间。</p>
+     */
+    @PreAuthorize("@ss.hasPermi('his:integralGoods:edit')")
+    @Log(title = "积分商品", businessType = BusinessType.UPDATE)
+    @PutMapping("/batchUpdateValidPeriod")
+    public AjaxResult batchUpdateValidPeriod(@RequestBody Map<String, Object> params)
+    {
+        Object goodsIdsObj = params.get("goodsIds");
+        if (goodsIdsObj == null) {
+            return AjaxResult.error("请选择需要修改的商品");
+        }
+        List<Integer> idList = (List<Integer>) goodsIdsObj;
+        if (idList.isEmpty()) {
+            return AjaxResult.error("请选择需要修改的商品");
+        }
+        Long[] goodsIds = idList.stream().map(Long::valueOf).toArray(Long[]::new);
+
+        Integer isLongTerm = params.get("isLongTerm") == null ? null : Integer.valueOf(params.get("isLongTerm").toString());
+        Date startTime = parseDate(params.get("validStartTime"));
+        Date endTime = parseDate(params.get("validEndTime"));
+
+        redisCacheUtil.delRedisKey("getIntegralGoodsList");
+        redisCacheUtil.delRedisKey("getIntegralGoodsById");
+        return toAjax(fsIntegralGoodsService.batchUpdateValidPeriod(goodsIds, isLongTerm, startTime, endTime));
+    }
+
+    /**
+     * 解析前端传入的时间参数(支持时间戳和 yyyy-MM-dd HH:mm:ss 格式字符串)
+     */
+    private Date parseDate(Object value) {
+        if (value == null) {
+            return null;
+        }
+        String str = value.toString();
+        if (str.isEmpty()) {
+            return null;
+        }
+        // 纯数字按时间戳处理
+        if (str.matches("\\d+")) {
+            return new Date(Long.parseLong(str));
+        }
+        try {
+            return new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(str);
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     @GetMapping("/getChooseIntegralGoodsList")
     public R getChooseIntegralGoodsList(@RequestParam(required = false) String goodsName,
                                         @RequestParam(required = false) Integer goodsType,

+ 16 - 0
fs-service/src/main/java/com/fs/his/domain/FsIntegralGoods.java

@@ -1,6 +1,8 @@
 package com.fs.his.domain;
 
 import java.math.BigDecimal;
+import java.util.Date;
+import com.fasterxml.jackson.annotation.JsonFormat;
 import com.fs.common.annotation.Excel;
 import com.fs.common.core.domain.BaseEntity;
 import lombok.Data;
@@ -74,6 +76,20 @@ public class FsIntegralGoods extends BaseEntity
     @Excel(name = "是否药品", dictType = "sys_yes_no")
     private Integer isDrug;
 
+    /** 是否长期有效 0是 1否(默认0长期有效) */
+    @Excel(name = "是否长期有效")
+    private Integer isLongTerm;
+
+    /** 有效期开始时间(is_long_term=1时生效) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "有效期开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date validStartTime;
+
+    /** 有效期结束时间(is_long_term=1时生效) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "有效期结束时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date validEndTime;
+
     /**
      * 非数据库字段 仅用于订单信息存储购买数量
      */

+ 20 - 2
fs-service/src/main/java/com/fs/his/mapper/FsIntegralGoodsMapper.java

@@ -11,6 +11,7 @@ import org.apache.ibatis.annotations.Select;
 import org.apache.ibatis.annotations.Update;
 
 import java.util.Collection;
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
 
@@ -70,7 +71,7 @@ public interface FsIntegralGoodsMapper
      */
     public int deleteFsIntegralGoodsByGoodsIds(Long[] goodsIds);
 
-    @Select({"<script> select goods_id,bar_code, img_url, images, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, descs, create_time from fs_integral_goods" +
+    @Select({"<script> select goods_id,bar_code, img_url, images, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, limit_num, is_long_term, valid_start_time, valid_end_time, descs, create_time from fs_integral_goods" +
             "<where>  \n" +
             "            <if test=\"goodsName != null  and goodsName != ''\"> and goods_name like concat('%', #{goodsName}, '%')</if>\n" +
             "            <if test=\"goodsType != null \"> and goods_type = #{goodsType}</if>\n" +
@@ -80,8 +81,11 @@ public interface FsIntegralGoodsMapper
             "</script>"})
     List<FsIntegralGoodsListVO> selectFsIntegralGoodsListVO(FsIntegralGoods fsIntegralGoods);
     @Select({"<script> " +
-            "select g.goods_id,g.img_url,g.images,g.goods_name,g.ot_price,g.goods_type,g.integral,g.cash,g.sort  from fs_integral_goods g  " +
+            "select g.goods_id,g.img_url,g.images,g.goods_name,g.ot_price,g.goods_type,g.integral,g.cash,g.sort," +
+            "g.is_long_term,g.valid_start_time,g.valid_end_time  from fs_integral_goods g  " +
             "where g.status=1  " +
+            "and (g.is_long_term = 0 " +
+            "     or (g.is_long_term = 1 and g.valid_start_time &lt;= NOW() and g.valid_end_time &gt;= NOW())) " +
             "<if test = 'maps.goodsType != null and maps.goodsType != 0     '> " +
             "and g.goods_type = #{maps.goodsType}  " +
             "</if>" +
@@ -111,4 +115,18 @@ public interface FsIntegralGoodsMapper
      * 根据id集合查询积分商品列表
      */
     List<FsGoodsVO> getFsGoodsVOListByIds(@Param("goodsIds") List<Long> goodsIds);
+
+    /**
+     * 批量修改有效期
+     *
+     * @param goodsIds   商品ID集合
+     * @param isLongTerm 是否长期有效 0是 1否
+     * @param startTime  活动开始时间(isLongTerm=1时生效,为空则置null)
+     * @param endTime    活动结束时间(isLongTerm=1时生效,为空则置null)
+     * @return 影响行数
+     */
+    int batchUpdateValidPeriod(@Param("goodsIds") Long[] goodsIds,
+                               @Param("isLongTerm") Integer isLongTerm,
+                               @Param("startTime") Date startTime,
+                               @Param("endTime") Date endTime);
 }

+ 12 - 3
fs-service/src/main/java/com/fs/his/mapper/FsIntegralOrderMapper.java

@@ -169,7 +169,15 @@ public interface FsIntegralOrderMapper
 
     /**
      * 统计用户已购买指定积分商品的有效订单数(限购校验用)
-     * <p>排除无效订单:5已取消、-1退款中、-2已退款、-3已取消;通过 JSON 函数精确匹配 item_json 中的 goodsId。</p>
+     * <p>仅排除以下两种终态无效订单:5已取消、-2已退款;
+     * 其余状态(含 -1退款中、-3已取消、1待发货、2待收货、3已完成、4待支付)均计入限购数量,
+     * 避免退款流程进行中或异常取消时被用户绕过限购。</p>
+     * <p>通过 JSON 函数精确匹配 item_json 中的 goodsId,兼容两种格式:
+     * <ul>
+     *   <li>数组格式(新):[{"goodsId":123,...}],使用 '$[*].goodsId' 路径</li>
+     *   <li>单对象格式(旧):{"goodsId":123,...},使用 '$.goodsId' 路径</li>
+     * </ul>
+     * 任一匹配即计入有效订单数。</p>
      *
      * @param userId  用户ID
      * @param goodsId 积分商品ID
@@ -177,7 +185,8 @@ public interface FsIntegralOrderMapper
      */
     @Select("SELECT COUNT(1) FROM fs_integral_order " +
             "WHERE user_id = #{userId} " +
-            "AND status NOT IN (5, -1, -2, -3) " +
-            "AND JSON_CONTAINS(JSON_EXTRACT(item_json, '$[*].goodsId'), CAST(#{goodsId} AS JSON))")
+            "AND status NOT IN (5, -2) " +
+            "AND (JSON_CONTAINS(JSON_EXTRACT(item_json, '$[*].goodsId'), CAST(#{goodsId} AS JSON)) " +
+            "     OR JSON_EXTRACT(item_json, '$.goodsId') = #{goodsId})")
     int selectUserPurchasedCount(@Param("userId") Long userId, @Param("goodsId") Long goodsId);
 }

+ 12 - 0
fs-service/src/main/java/com/fs/his/service/IFsIntegralGoodsService.java

@@ -7,6 +7,7 @@ import com.fs.his.vo.FsIntegralGoodsChooseVO;
 import com.fs.his.vo.FsIntegralGoodsListUVO;
 import com.fs.his.vo.FsIntegralGoodsListVO;
 
+import java.util.Date;
 import java.util.List;
 import java.util.Map;
 
@@ -78,4 +79,15 @@ public interface IFsIntegralGoodsService
      * 获取选择积分商品列表
      */
     List<FsIntegralGoodsChooseVO> getChooseIntegralGoodsListByMap(Map<String, Object> params);
+
+    /**
+     * 批量修改有效期
+     *
+     * @param goodsIds   商品ID集合
+     * @param isLongTerm 是否长期有效 0是 1否
+     * @param startTime  活动开始时间(isLongTerm=1时生效)
+     * @param endTime    活动结束时间(isLongTerm=1时生效)
+     * @return 影响行数
+     */
+    int batchUpdateValidPeriod(Long[] goodsIds, Integer isLongTerm, Date startTime, Date endTime);
 }

+ 83 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsIntegralCartServiceImpl.java

@@ -9,16 +9,19 @@ import com.fs.his.domain.FsIntegralGoods;
 import com.fs.his.domain.FsUser;
 import com.fs.his.mapper.FsIntegralCartMapper;
 import com.fs.his.mapper.FsIntegralGoodsMapper;
+import com.fs.his.mapper.FsIntegralOrderMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsIntegralCartService;
 import com.fs.his.vo.FsIntegralCartVO;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import javax.validation.constraints.NotNull;
 import java.time.LocalDateTime;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -28,10 +31,22 @@ import java.util.Objects;
 @Service
 public class FsIntegralCartServiceImpl extends ServiceImpl<FsIntegralCartMapper, FsIntegralCart> implements IFsIntegralCartService {
 
+    /**
+     * 限购计数器 Redis Hash Key 前缀(与 FsIntegralOrderServiceImpl 保持一致)
+     */
+    private static final String REDIS_KEY_INTEGRAL_PURCHASE_COUNT = "integral:purchase:count:";
+
     @Resource
     private FsIntegralGoodsMapper goodsMapper;
     @Resource
     private FsUserMapper userMapper;
+    @Resource
+    private FsIntegralOrderMapper orderMapper;
+    /**
+     * 限购计数器专用 RedisTemplate(Hash 结构,只读不写,写操作在 FsIntegralOrderServiceImpl)
+     */
+    @Resource(name = "redisTemplateForInteger")
+    private RedisTemplate<String, Integer> redisTemplateForInteger;
 
     /**
      * 添加或修改购物车
@@ -62,6 +77,29 @@ public class FsIntegralCartServiceImpl extends ServiceImpl<FsIntegralCartMapper,
             throw new CustomException("库存不足");
         }
 
+        // 有效期校验:isLongTerm=1 非长期有效时,校验当前时间是否在有效期区间内
+        if (fsIntegralGoods.getIsLongTerm() != null && fsIntegralGoods.getIsLongTerm() == 1) {
+            Date now = new Date();
+            if (fsIntegralGoods.getValidStartTime() == null || fsIntegralGoods.getValidEndTime() == null
+                    || now.before(fsIntegralGoods.getValidStartTime()) || now.after(fsIntegralGoods.getValidEndTime())) {
+                throw new CustomException("积分商品「" + fsIntegralGoods.getGoodsName() + "」已过兑换有效期限");
+            }
+        }
+
+        // 限购校验:limitNum > 0 表示开启限购,校验"已购订单数 + 购物车已有数量 + 本次加入数量"是否超出限购
+        if (fsIntegralGoods.getLimitNum() != null && fsIntegralGoods.getLimitNum() > 0) {
+            // 已下单的有效订单数(仅排除5已取消、-2已退款),读 Redis 计数器(懒加载+降级查库)
+            int purchasedCount = getPurchasedCount(userId, goodsId);
+            // 购物车中已存在的数量(同用户同商品)
+            Integer existCartNum = getCartCount(userId, goodsId);
+            int existNum = existCartNum == null ? 0 : existCartNum;
+            // isCart=0 表示累加到现有购物车,此时本次 cartNum 为新增量;isCart=1 表示覆盖现有购物车,此时 cartNum 为最终数量
+            int totalNum = isCart != null && isCart == 1 ? cartNum : existNum + cartNum;
+            if (purchasedCount + totalNum > fsIntegralGoods.getLimitNum()) {
+                throw new CustomException("超过限购数量,每人限购" + fsIntegralGoods.getLimitNum() + "件");
+            }
+        }
+
         FsIntegralCart cart = new FsIntegralCart();
         cart.setUserId(userId);
         cart.setGoodsId(goodsId);
@@ -88,6 +126,51 @@ public class FsIntegralCartServiceImpl extends ServiceImpl<FsIntegralCartMapper,
         baseMapper.delete(wrapper);
     }
 
+    /**
+     * 查询用户购物车中指定商品的已有数量(限购校验用)
+     * @param userId    用户ID
+     * @param goodsId   商品ID
+     * @return 数量(不存在返回 null)
+     */
+    private Integer getCartCount(Long userId, Long goodsId) {
+        FsIntegralCart cart = baseMapper.selectOne(Wrappers.<FsIntegralCart>lambdaQuery()
+                .eq(FsIntegralCart::getUserId, userId)
+                .eq(FsIntegralCart::getGoodsId, goodsId));
+        return cart == null ? null : cart.getCartNum();
+    }
+
+    /**
+     * 获取用户已购买指定积分商品的有效订单数(限购校验用,Redis 计数器方案)
+     * <p>读取 Redis Hash(key=integral:purchase:count:{userId}, hashKey={goodsId}),
+     * 未命中时从数据库懒加载并回填,Redis 不可用时降级查库。
+     * 与 FsIntegralOrderServiceImpl.getPurchasedCount 逻辑保持一致,仅读不写。</p>
+     *
+     * @param userId  用户ID
+     * @param goodsId 积分商品ID
+     * @return 已购买有效订单数(非负)
+     */
+    private int getPurchasedCount(Long userId, Long goodsId) {
+        if (userId == null || goodsId == null) {
+            return 0;
+        }
+        String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId;
+        String hashKey = goodsId.toString();
+        try {
+            Integer count = (Integer) redisTemplateForInteger.opsForHash().get(redisKey, hashKey);
+            if (count != null) {
+                return count;
+            }
+            // 懒加载:Redis 无记录时从 DB 初始化
+            int dbCount = orderMapper.selectUserPurchasedCount(userId, goodsId);
+            redisTemplateForInteger.opsForHash().put(redisKey, hashKey, dbCount);
+            return dbCount;
+        } catch (Exception e) {
+            // Redis 异常降级查库,保证限购功能可用
+            log.warn("限购计数器读取Redis失败,降级查DB userId={}, goodsId={}", userId, goodsId, e);
+            return orderMapper.selectUserPurchasedCount(userId, goodsId);
+        }
+    }
+
     /**
      * 获取购物车商品数量
      * @param params    参数

+ 37 - 0
fs-service/src/main/java/com/fs/his/service/impl/FsIntegralGoodsServiceImpl.java

@@ -75,6 +75,10 @@ public class FsIntegralGoodsServiceImpl implements IFsIntegralGoodsService
     public int insertFsIntegralGoods(FsIntegralGoods fsIntegralGoods)
     {
         fsIntegralGoods.setCreateTime(DateUtils.getNowDate());
+        // 默认长期有效
+        if (fsIntegralGoods.getIsLongTerm() == null) {
+            fsIntegralGoods.setIsLongTerm(0);
+        }
         return fsIntegralGoodsMapper.insertFsIntegralGoods(fsIntegralGoods);
     }
 
@@ -256,4 +260,37 @@ public class FsIntegralGoodsServiceImpl implements IFsIntegralGoodsService
     public List<FsIntegralGoodsChooseVO> getChooseIntegralGoodsListByMap(Map<String, Object> params) {
         return fsIntegralGoodsMapper.getChooseIntegralGoodsListByMap(params);
     }
+
+    /**
+     * 批量修改有效期
+     * <p>长期有效时清空开始/结束时间,非长期有效时校验时间区间。</p>
+     *
+     * @param goodsIds   商品ID集合
+     * @param isLongTerm 是否长期有效 0是 1否
+     * @param startTime  活动开始时间
+     * @param endTime    活动结束时间
+     * @return 影响行数
+     */
+    @Override
+    public int batchUpdateValidPeriod(Long[] goodsIds, Integer isLongTerm, Date startTime, Date endTime) {
+        if (goodsIds == null || goodsIds.length == 0) {
+            throw new ServiceException("请选择需要修改的商品");
+        }
+        if (isLongTerm == null) {
+            throw new ServiceException("请选择是否长期有效");
+        }
+        // 非长期有效时,校验时间
+        if (isLongTerm == 1) {
+            if (startTime == null || endTime == null) {
+                throw new ServiceException("非长期有效时,开始时间和结束时间不能为空");
+            }
+            if (startTime.after(endTime)) {
+                throw new ServiceException("开始时间不能晚于结束时间");
+            }
+        }
+        // 长期有效时,开始/结束时间置空
+        Date effectiveStart = isLongTerm == 0 ? null : startTime;
+        Date effectiveEnd = isLongTerm == 0 ? null : endTime;
+        return fsIntegralGoodsMapper.batchUpdateValidPeriod(goodsIds, isLongTerm, effectiveStart, effectiveEnd);
+    }
 }

+ 153 - 34
fs-service/src/main/java/com/fs/his/service/impl/FsIntegralOrderServiceImpl.java

@@ -86,6 +86,8 @@ import java.time.format.DateTimeFormatter;
 import java.util.*;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
+import javax.annotation.Resource;
+import org.springframework.data.redis.core.RedisTemplate;
 
 /**
  * 积分商品订单Service业务层处理
@@ -98,14 +100,13 @@ import java.util.stream.Collectors;
 public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
 {
     /**
-     * 限购缓存 Redis Key 前缀
-     * <p>完整格式:integral:purchase:count:{userId}:{goodsId},缓存用户已购某商品的有效订单数</p>
+     * 限购计数器 Redis Hash Key 前缀
+     * <p>完整格式:integral:purchase:count:{userId}(Hash 结构)
+     * HashKey 为 goodsId,Value 为已购有效订单数(仅排除 status=5已取消、-2已退款)。
+     * 通过 HINCRBY 原子增减保证一致性:下单成功 +N,取消/退款 -N。</p>
      */
     private static final String REDIS_KEY_INTEGRAL_PURCHASE_COUNT = "integral:purchase:count:";
 
-    /** 限购缓存过期时间(1小时),过期后回源数据库重建 */
-    private static final Integer PURCHASE_COUNT_CACHE_TTL = 1;
-
     @Autowired
     private FsCouponMapper fsCouponMapper;
 
@@ -150,6 +151,13 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
     private IFsUserIntegralLogsService userIntegralLogsService;
     @Autowired
     private RedisCache redisCache;
+    /**
+     * 限购计数器专用 RedisTemplate(Hash 结构)
+     * <p>Key: integral:purchase:count:{userId},HashKey: {goodsId},Value: 已购有效订单数
+     * 使用 GenericToStringSerializer 序列化 Integer,支持 HINCRBY 原子增减操作</p>
+     */
+    @Resource(name = "redisTemplateForInteger")
+    private RedisTemplate<String, Integer> redisTemplateForInteger;
     @Autowired
     private IPayService ybPayService;
     /**
@@ -328,6 +336,14 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         if (Objects.isNull(integralGoods)) {
             return R.error("商品不存在");
         }
+        // 有效期校验:isLongTerm=1 非长期有效时,校验当前时间是否在有效期区间内
+        if (integralGoods.getIsLongTerm() != null && integralGoods.getIsLongTerm() == 1) {
+            Date now = new Date();
+            if (integralGoods.getValidStartTime() == null || integralGoods.getValidEndTime() == null
+                    || now.before(integralGoods.getValidStartTime()) || now.after(integralGoods.getValidEndTime())) {
+                return R.error("积分商品已过兑换有效期限");
+            }
+        }
         if(integralGoods.getStock()<=0L){
             return R.error("库存不足");
         }
@@ -335,9 +351,9 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
             return R.error("积分不足");
         }
 
-        // 限购校验:limitNum > 0 表示开启限购,校验用户已购数量是否已达上限
+        // 限购校验:limitNum > 0 表示开启限购,读 Redis 计数器(懒加载+降级查库)
         if (integralGoods.getLimitNum() != null && integralGoods.getLimitNum() > 0) {
-            int purchasedCount = getUserPurchasedCount(param.getUserId(), param.getGoodsId());
+            int purchasedCount = getPurchasedCount(param.getUserId(), param.getGoodsId());
             if (purchasedCount + 1 > integralGoods.getLimitNum()) {
                 return R.error("超过限购数量,每人限购" + integralGoods.getLimitNum() + "件");
             }
@@ -438,8 +454,10 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
                 userIntegralLogsService.insertLog(logs);
             }
 
-            // 失效限购缓存,下次查询时从数据库重建(保证数据一致性)
-            invalidatePurchaseCountCache(order.getUserId(), goodsItem.get(0).getGoodsId());
+            // 限购计数器 +N(遍历所有商品,购物车下单时 goodsItem 可能为多个)
+            for (FsIntegralGoods g : goodsItem) {
+                incrPurchasedCount(order.getUserId(), g.getGoodsId(), g.getNum() == null ? 1 : g.getNum());
+            }
 
             // 积分支付
             if (order.getPayType() == 1) {
@@ -495,44 +513,113 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
     }
 
     /**
-     * 获取用户已购买指定积分商品的有效订单数(限购校验用)
-     * <p>缓存策略:优先查 Redis,未命中则回源数据库并回填缓存(1小时过期)。
-     * 缓存过期后会重新从数据库查询,保证用户取消订单后限购数量能及时更新。</p>
+     * 获取用户已购买指定积分商品的有效订单数(限购校验用,Redis 计数器方案
+     * <p>读取 Redis Hash(key=integral:purchase:count:{userId}, hashKey={goodsId}),
+     * 未命中时从数据库懒加载并回填,Redis 不可用时降级查库。</p>
      *
      * @param userId  用户ID
      * @param goodsId 积分商品ID
      * @return 已购买有效订单数(非负)
      */
-    private int getUserPurchasedCount(Long userId, Long goodsId) {
-        String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId + ":" + goodsId;
-        Integer count = redisCache.getCacheObject(redisKey);
-        if (count != null) {
-            return count;
-        }
-        // 回源数据库查询有效订单数(排除已取消和已删除)
-        count = fsIntegralOrderMapper.selectUserPurchasedCount(userId, goodsId);
-        if (count < 0) {
-            count = 0;
-        }
-        // 回填 Redis 缓存,1小时过期
-        redisCache.setCacheObject(redisKey, count, PURCHASE_COUNT_CACHE_TTL, TimeUnit.HOURS);
-        return count;
+    private int getPurchasedCount(Long userId, Long goodsId) {
+        if (userId == null || goodsId == null) {
+            return 0;
+        }
+        String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId;
+        String hashKey = goodsId.toString();
+        try {
+            Integer count = (Integer) redisTemplateForInteger.opsForHash().get(redisKey, hashKey);
+            if (count != null) {
+                return count;
+            }
+            // 懒加载:Redis 无记录时从 DB 初始化(并发下多次 put 只是覆盖为相同的 DB 值,结果一致)
+            int dbCount = fsIntegralOrderMapper.selectUserPurchasedCount(userId, goodsId);
+            redisTemplateForInteger.opsForHash().put(redisKey, hashKey, dbCount);
+            return dbCount;
+        } catch (Exception e) {
+            // Redis 异常降级查库,保证限购功能可用
+            log.warn("限购计数器读取Redis失败,降级查DB userId={}, goodsId={}", userId, goodsId, e);
+            return fsIntegralOrderMapper.selectUserPurchasedCount(userId, goodsId);
+        }
+    }
+
+    /**
+     * 增加用户已购商品计数(订单创建成功后调用)
+     * <p>通过 HINCRBY 原子增加,保证高并发下计数准确。</p>
+     *
+     * @param userId  用户ID
+     * @param goodsId 积分商品ID
+     * @param num     本次购买数量
+     */
+    private void incrPurchasedCount(Long userId, Long goodsId, int num) {
+        if (userId == null || goodsId == null || num <= 0) {
+            return;
+        }
+        try {
+            String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId;
+            redisTemplateForInteger.opsForHash().increment(redisKey, goodsId.toString(), num);
+        } catch (Exception e) {
+            // Redis 异常不影响主流程,下次限购校验时懒加载会从 DB 重建最新值
+            log.warn("限购计数器incr失败 userId={}, goodsId={}, num={}", userId, goodsId, num, e);
+        }
     }
 
     /**
-     * 失效用户购买指定商品的限购缓存
-     * <p>订单创建成功后调用,确保下次限购校验时从数据库重新加载最新数据。</p>
+     * 减少用户已购商品计数(取消订单/退款成功后调用)
+     * <p>通过 HINCRBY 原子减少,保证计数与订单终态一致。
+     * 仅在订单变为终态无效(5已取消、-2已退款)时调用。</p>
      *
      * @param userId  用户ID
      * @param goodsId 积分商品ID
+     * @param num     本次退还数量
      */
-    private void invalidatePurchaseCountCache(Long userId, Long goodsId) {
+    private void decrPurchasedCount(Long userId, Long goodsId, int num) {
+        if (userId == null || goodsId == null || num <= 0) {
+            return;
+        }
         try {
-            String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId + ":" + goodsId;
-            redisCache.deleteObject(redisKey);
+            String redisKey = REDIS_KEY_INTEGRAL_PURCHASE_COUNT + userId;
+            // 使用 increment 传负数实现递减;递减后若计数为负,说明 Redis 与 DB 不一致(如懒加载前旧数据),重置为 0
+            Long newVal = redisTemplateForInteger.opsForHash().increment(redisKey, goodsId.toString(), -num);
+            if (newVal != null && newVal < 0) {
+                redisTemplateForInteger.opsForHash().put(redisKey, goodsId.toString(), 0);
+            }
         } catch (Exception e) {
-            // 缓存失效失败不影响主流程,下次限购校验时缓存过期后会自动重建
-            log.warn("失效限购缓存失败, userId={}, goodsId={}", userId, goodsId, e);
+            // Redis 异常不影响主流程,下次限购校验时懒加载会从 DB 重建最新值
+            log.warn("限购计数器decr失败 userId={}, goodsId={}, num={}", userId, goodsId, num, e);
+        }
+    }
+
+    /**
+     * 减少订单中所有商品对应的限购计数(订单变为终态无效时调用)
+     * <p>解析 item_json 获取 goodsId 和购买数量,逐一调用 decr。
+     * 兼容 item_json 单对象(旧数据)和数组(新数据)两种格式。</p>
+     *
+     * @param order 积分订单
+     */
+    private void decrPurchasedCountByOrder(FsIntegralOrder order) {
+        if (order == null || order.getUserId() == null || StringUtils.isEmpty(order.getItemJson())) {
+            return;
+        }
+        String itemJson = order.getItemJson();
+        try {
+            if (itemJson.startsWith("[") && itemJson.endsWith("]")) {
+                List<FsIntegralGoods> goodsItem = JSONUtil.toBean(itemJson, new TypeReference<List<FsIntegralGoods>>(){}, true);
+                for (FsIntegralGoods goods : goodsItem) {
+                    if (goods != null && goods.getGoodsId() != null) {
+                        decrPurchasedCount(order.getUserId(), goods.getGoodsId(),
+                                goods.getNum() == null ? 1 : goods.getNum());
+                    }
+                }
+            } else {
+                FsIntegralGoods goods = JSONUtil.toBean(itemJson, FsIntegralGoods.class);
+                if (goods != null && goods.getGoodsId() != null) {
+                    decrPurchasedCount(order.getUserId(), goods.getGoodsId(),
+                            goods.getNum() == null ? 1 : goods.getNum());
+                }
+            }
+        } catch (Exception e) {
+            log.warn("订单限购计数decr失败, orderId={}, itemJson={}", order.getOrderId(), itemJson, e);
         }
     }
 
@@ -570,10 +657,27 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
             if (Objects.isNull(integralGoods)) {
                 throw new CustomException("商品不存在");
             }
+            // 有效期校验:非长期有效时,校验当前时间是否在有效期区间内
+            if (integralGoods.getIsLongTerm() != null && integralGoods.getIsLongTerm() == 1) {
+                Date now = new Date();
+                if (integralGoods.getValidStartTime() == null || integralGoods.getValidEndTime() == null
+                        || now.before(integralGoods.getValidStartTime()) || now.after(integralGoods.getValidEndTime())) {
+                    throw new CustomException("积分商品「" + integralGoods.getGoodsName() + "」已过兑换有效期限");
+                }
+            }
             if(integralGoods.getStock() < cart.getCartNum()){
                 throw new CustomException("库存不足");
             }
 
+            // 限购校验:limitNum > 0 表示开启限购,读 Redis 计数器(懒加载+降级查库)
+            if (integralGoods.getLimitNum() != null && integralGoods.getLimitNum() > 0) {
+                int purchasedCount = getPurchasedCount(param.getUserId(), cart.getGoodsId());
+                if (purchasedCount + cart.getCartNum() > integralGoods.getLimitNum()) {
+                    throw new CustomException("商品「" + integralGoods.getGoodsName()
+                            + "」超过限购数量,每人限购" + integralGoods.getLimitNum() + "件");
+                }
+            }
+
             // 减库存
             if (fsIntegralGoodsMapper.subStock(integralGoods.getGoodsId(), cart.getCartNum()) <= 0) {
                 throw new CustomException("库存不足");
@@ -586,7 +690,15 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
             goodsItem.add(integralGoods);
         }
 
-        return createOrder(user, address, totalIntegral, totalCash, goodsItem, null);
+        R result = createOrder(user, address, totalIntegral, totalCash, goodsItem, null);
+        // 订单创建成功后,清除购物车中已下单的商品(限购计数器 +N 已在 createOrder 内部完成)
+        Object codeObj = result == null ? null : result.get("code");
+        if (codeObj != null && Integer.valueOf(200).equals(codeObj)) {
+            // 清除购物车中已下单的商品(按购物车ID删除,避免误删同商品的其他购物车记录)
+            List<Long> cartIds = carts.stream().map(FsIntegralCart::getId).collect(Collectors.toList());
+            cartService.delCartByIds(user.getUserId(), cartIds);
+        }
+        return result;
     }
 
     @Override
@@ -706,6 +818,9 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         logs.setCreateTime(new Date());
         userIntegralLogsService.insertLog(logs);
 
+        // 订单取消后,限购计数器 -N(status=5不计入限购),保证计数与订单终态一致
+        decrPurchasedCountByOrder(order);
+
         return R.ok();
     }
 
@@ -996,6 +1111,8 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
         // 5. 无需等待回调的场景(纯积分订单无支付记录、或易宝退款立即成功),直接标记为已退款(-2)
         if (!needCallback) {
             fsIntegralOrderMapper.refundOrder(order.getOrderId());
+            // 订单已变为已退款(-2)终态,限购计数器 -N(status=-2不计入限购),保证计数与订单终态一致
+            decrPurchasedCountByOrder(order);
         }
 
         return 1;
@@ -1258,6 +1375,8 @@ public class FsIntegralOrderServiceImpl implements IFsIntegralOrderService
             int rows = fsIntegralOrderMapper.refundOrder(orderId);
             if (rows > 0) {
                 log.info("积分订单退款回调(Yop) 更新订单状态完成 orderId: {}, status: -1->-2", orderId);
+                // 订单已变为已退款(-2)终态,限购计数器 -N(status=-2不计入限购),保证计数与订单终态一致
+                decrPurchasedCountByOrder(order);
             } else {
                 log.warn("积分订单退款回调(Yop) 更新订单状态失败(可能已被其他流程处理) orderId: {}", orderId);
             }

+ 9 - 0
fs-service/src/main/java/com/fs/his/vo/FsIntegralGoodsListUVO.java

@@ -11,6 +11,7 @@ import java.util.Date;
 @Data
 public class FsIntegralGoodsListUVO implements Serializable {
 
+    private static final long serialVersionUID = 1L;
 
     /** id */
     private Long goodsId;
@@ -41,7 +42,15 @@ public class FsIntegralGoodsListUVO implements Serializable {
     /** 排序 */
     private Long sort;
 
+    /** 是否长期有效 0是 1否 */
+    private Integer isLongTerm;
 
+    /** 有效期开始时间(is_long_term=1时生效) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date validStartTime;
 
+    /** 有效期结束时间(is_long_term=1时生效) */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date validEndTime;
 
 }

+ 16 - 0
fs-service/src/main/java/com/fs/his/vo/FsIntegralGoodsListVO.java

@@ -51,6 +51,22 @@ public class FsIntegralGoodsListVO {
     /** 库存 */
     @Excel(name = "库存")
     private Long stock;
+
+    /** 限购数量(0=不限购,>0表示每个用户限购件数) */
+    @Excel(name = "限购数量")
+    private Integer limitNum;
+
+    /** 是否长期有效 0是 1否 */
+    private Integer isLongTerm;
+
+    /** 有效期开始时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date validStartTime;
+
+    /** 有效期结束时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date validEndTime;
+
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     private Date createTime;
 }

+ 25 - 1
fs-service/src/main/resources/mapper/his/FsIntegralGoodsMapper.xml

@@ -21,10 +21,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <result property="createTime"    column="create_time"    />
         <result property="barCode"    column="bar_code"    />
         <result property="isDrug"    column="is_drug"    />
+        <result property="isLongTerm"    column="is_long_term"    />
+        <result property="validStartTime"    column="valid_start_time"    />
+        <result property="validEndTime"    column="valid_end_time"    />
     </resultMap>
 
     <sql id="selectFsIntegralGoodsVo">
-        select goods_id, img_url, images, bar_code, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, limit_num, descs, create_time, is_drug from fs_integral_goods
+        select goods_id, img_url, images, bar_code, goods_name, ot_price, goods_type, status, integral, cash, sort, stock, limit_num, descs, create_time, is_drug, is_long_term, valid_start_time, valid_end_time from fs_integral_goods
     </sql>
 
     <select id="selectFsIntegralGoodsList" parameterType="FsIntegralGoods" resultMap="FsIntegralGoodsResult">
@@ -64,6 +67,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">create_time,</if>
             <if test="barCode != null">bar_code,</if>
             <if test="isDrug != null">is_drug,</if>
+            <if test="isLongTerm != null">is_long_term,</if>
+            <if test="validStartTime != null">valid_start_time,</if>
+            <if test="validEndTime != null">valid_end_time,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="goodsId != null">#{goodsId},</if>
@@ -82,6 +88,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">#{createTime},</if>
             <if test="barCode != null">#{barCode},</if>
             <if test="isDrug != null">#{isDrug},</if>
+            <if test="isLongTerm != null">#{isLongTerm},</if>
+            <if test="validStartTime != null">#{validStartTime},</if>
+            <if test="validEndTime != null">#{validEndTime},</if>
          </trim>
     </insert>
 
@@ -103,10 +112,25 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="createTime != null">create_time = #{createTime},</if>
             <if test="barCode != null">bar_code = #{barCode},</if>
             <if test="isDrug != null">is_drug = #{isDrug},</if>
+            <if test="isLongTerm != null">is_long_term = #{isLongTerm},</if>
+            <if test="validStartTime != null">valid_start_time = #{validStartTime},</if>
+            <if test="validEndTime != null">valid_end_time = #{validEndTime},</if>
         </trim>
         where goods_id = #{goodsId}
     </update>
 
+    <!-- 批量修改有效期 -->
+    <update id="batchUpdateValidPeriod">
+        update fs_integral_goods
+        set is_long_term = #{isLongTerm},
+            valid_start_time = <choose><when test="startTime != null">#{startTime}</when><otherwise>null</otherwise></choose>,
+            valid_end_time = <choose><when test="endTime != null">#{endTime}</when><otherwise>null</otherwise></choose>
+        where goods_id in
+        <foreach item="goodsId" collection="goodsIds" open="(" separator="," close=")">
+            #{goodsId}
+        </foreach>
+    </update>
+
     <delete id="deleteFsIntegralGoodsByGoodsId" parameterType="Long">
         delete from fs_integral_goods where goods_id = #{goodsId}
     </delete>

+ 11 - 1
fs-user-app/src/main/java/com/fs/app/controller/IntegralController.java

@@ -75,9 +75,19 @@ public class IntegralController extends  AppBaseController {
 
     @ApiOperation("获取积分商品详情")
     @GetMapping("/getIntegralGoodsById")
-    @Cacheable(value = "getIntegralGoodsById", key = "#goodsId")
     public R getIntegralGoodsById(@RequestParam("goodsId")Long goodsId, HttpServletRequest request){
         FsIntegralGoods goods=goodsService.selectFsIntegralGoodsByGoodsId(goodsId);
+        if (goods == null) {
+            return R.error("商品不存在");
+        }
+        // 有效期校验:isLongTerm=1 非长期有效时,校验当前时间是否在有效期区间内
+        if (goods.getIsLongTerm() != null && goods.getIsLongTerm() == 1) {
+            Date now = new Date();
+            if (goods.getValidStartTime() == null || goods.getValidEndTime() == null
+                    || now.before(goods.getValidStartTime()) || now.after(goods.getValidEndTime())) {
+                return R.error("积分商品已过兑换有效期限");
+            }
+        }
         return R.ok().put("data",goods);
     }
 

+ 2 - 2
fs-user-app/src/main/java/com/fs/app/controller/UserController.java

@@ -216,7 +216,7 @@ public class UserController extends  AppBaseController {
     public R editUser(@RequestBody @Valid FsUserEditParam param, HttpServletRequest request){
         param.setUserId(Long.parseLong(getUserId()));
         log.info("【修改用户头像昵称】:{}",param);
-        if (param.getNickname().length()>50){
+        if (param.getNickName().length()>50){
             return R.error("请授权正确的昵称!");
         }
         if (StringUtils.isEmpty(param.getAvatar())){
@@ -225,7 +225,7 @@ public class UserController extends  AppBaseController {
         FsUser user=new FsUser();
         user.setUserId(Long.parseLong(getUserId()));
         user.setAvatar(param.getAvatar());
-        user.setNickName(param.getNickname());
+        user.setNickName(param.getNickName());
         if(param.getSex() != null){
             user.setSex(param.getSex());
         }

+ 6 - 2
fs-user-app/src/main/java/com/fs/app/param/FsUserEditParam.java

@@ -13,9 +13,13 @@ import java.io.Serializable;
 @JsonIgnoreProperties(ignoreUnknown = true)
 public class FsUserEditParam implements Serializable {
 
+//    @NotNull(message = "用户昵称不能为空!")
+//    @JsonAlias({"nickname", "nickName"})
+//    private String nickname;
+
     @NotNull(message = "用户昵称不能为空!")
-    @JsonAlias({"nickname", "nickName"})
-    private String nickname;
+    @JsonAlias({"nickName", "nickname"})
+    private String nickName;
 
     //    @NotBlank(message = "用户头像不能为空!")
     private String avatar;