yjwang 1 hónapja
szülő
commit
9a7e762514
27 módosított fájl, 2297 hozzáadás és 65 törlés
  1. 95 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductGroupBuyController.java
  2. 174 0
      fs-admin/src/main/java/com/fs/hisStore/task/GroupBuyExpireTask.java
  3. 2 2
      fs-company/src/main/resources/application.yml
  4. 3 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java
  5. 16 1
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductActivity.java
  6. 82 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductGroupBuy.java
  7. 74 0
      fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductGroupBuyItem.java
  8. 20 2
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java
  9. 10 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductActivityMapper.java
  10. 31 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductGroupBuyItemMapper.java
  11. 109 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductGroupBuyMapper.java
  12. 10 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductActivityService.java
  13. 86 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductGroupBuyService.java
  14. 35 3
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java
  15. 177 52
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  16. 31 1
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductActivityServiceImpl.java
  17. 458 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductGroupBuyServiceImpl.java
  18. 75 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreGroupBuyListVO.java
  19. 75 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreGroupBuyMemberVO.java
  20. 13 0
      fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductListVO.java
  21. 7 1
      fs-service/src/main/resources/application-dev.yml
  22. 79 0
      fs-service/src/main/resources/db/20260429-限时团购.sql
  23. 5 1
      fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml
  24. 33 2
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductActivityMapper.xml
  25. 82 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductGroupBuyItemMapper.xml
  26. 306 0
      fs-service/src/main/resources/mapper/hisStore/FsStoreProductGroupBuyMapper.xml
  27. 209 0
      fs-user-app/src/main/java/com/fs/app/controller/store/FsStoreProductGroupBuyScrmController.java

+ 95 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductGroupBuyController.java

@@ -0,0 +1,95 @@
+package com.fs.hisStore.controller;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.hisStore.service.IFsStoreProductGroupBuyService;
+import com.fs.hisStore.vo.FsStoreGroupBuyListVO;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 后台管理 - 限时团购管理
+ * <p>只读查询,不做 CRUD。团购活动的新增/修改沿用 fs_store_product_activity(activity_type=8)。</p>
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Api("后台-限时团购管理")
+@RestController
+@RequestMapping("/store/store/productGroupBuy")
+public class FsStoreProductGroupBuyController extends BaseController {
+
+    @Autowired
+    private IFsStoreProductGroupBuyService groupBuyService;
+
+    /**
+     * 团购列表(分页)
+     * 前端传:groupNo/productId/productName/status/beginTime/endTime 皆可选
+     */
+    @ApiOperation("团购列表")
+    @PreAuthorize("@ss.hasPermi('store:productGroupBuy:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(@RequestParam(value = "groupNo",    required = false) String groupNo,
+                              @RequestParam(value = "productId",  required = false) Long productId,
+                              @RequestParam(value = "productName",required = false) String productName,
+                              @RequestParam(value = "status",     required = false) Integer status,
+                              @RequestParam(value = "beginTime",  required = false) String beginTime,
+                              @RequestParam(value = "endTime",    required = false) String endTime) {
+        startPage();
+        List<FsStoreGroupBuyListVO> list = groupBuyService.selectGroupBuyListForAdmin(
+                groupNo, productId, productName, status, beginTime, endTime);
+        return getDataTable(list);
+    }
+
+    /**
+     * 团购详情(含团员列表及订单状态)
+     */
+    @ApiOperation("团购详情(含团员+订单状态)")
+    @PreAuthorize("@ss.hasPermi('store:productGroupBuy:query')")
+    @GetMapping(value = "/{id}")
+    public R getInfo(@PathVariable("id") Long id) {
+        FsStoreGroupBuyListVO detail = groupBuyService.selectGroupBuyDetailForAdmin(id);
+        if (detail == null) {
+            return R.error("团购不存在或已删除");
+        }
+        return R.ok().put("data", detail);
+    }
+
+    /**
+     * 按商品ID查历史拼团(商品详情页的"历史拼团"入口)
+     */
+    @ApiOperation("按商品ID查历史拼团")
+    @PreAuthorize("@ss.hasPermi('store:productGroupBuy:list')")
+    @GetMapping("/listByProduct/{productId}")
+    public TableDataInfo listByProduct(@PathVariable("productId") Long productId) {
+        startPage();
+        List<FsStoreGroupBuyListVO> list = groupBuyService.selectGroupBuyListByProduct(productId);
+        return getDataTable(list);
+    }
+
+    /**
+     * 团员列表(单独接口,用于详情页弹出/下钻查看)
+     * 也可复用 /{id} 接口,这里保留独立入口方便前端按需调用
+     */
+    @ApiOperation("团员列表")
+    @PreAuthorize("@ss.hasPermi('store:productGroupBuy:query')")
+    @GetMapping("/members/{id}")
+    public AjaxResult members(@PathVariable("id") Long id) {
+        FsStoreGroupBuyListVO detail = groupBuyService.selectGroupBuyDetailForAdmin(id);
+        if (detail == null) {
+            return AjaxResult.error("团购不存在");
+        }
+        return AjaxResult.success(detail.getMembers());
+    }
+}

+ 174 - 0
fs-admin/src/main/java/com/fs/hisStore/task/GroupBuyExpireTask.java

@@ -0,0 +1,174 @@
+package com.fs.hisStore.task;
+
+import com.fs.common.core.domain.R;
+import com.fs.hisStore.domain.FsStoreProductGroupBuy;
+import com.fs.hisStore.mapper.FsStoreOrderScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductGroupBuyMapper;
+import com.fs.hisStore.service.IFsStoreOrderScrmService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 团购超时兜底定时任务
+ * <p>
+ * 职责两件事:
+ * <ol>
+ *   <li><b>主路径</b>:扫“截团时间已过但还没成团”的团,把团判失败,并把团内已付款的订单自动退款</li>
+ *   <li><b>孤儿订单兜底</b>:扫“团已判失败但团内还有未退款的已付款订单”,补上退款;
+ *       对应“支付回调卡在 endTime 后到达”以及“上一轮退款失败”的极端场景</li>
+ * </ol>
+ * <p>
+ * 几个关键点:
+ * <ul>
+ *   <li>未成团的订单根本没推过 ERP,退款只走本地 + 调支付通道打钱,<b>不调 ERP 取消</b>;
+ *       service 层的 refundOrderMoney 里已经加了团购未成团放行,走到那儿自然跳过 ERP 分支</li>
+ *   <li>标团失败用 CAS(status=0 AND end_time<=now()),多实例并发跑也只会有一个任务赢得处置权,
+ *       其他实例拿到 0 影响行数直接跳过,不会重复退款</li>
+ *   <li>查团内订单复用已有 {@code selectOrderIdsByGroupBuyId},
+ *       条件是 status=1 AND extend_order_id is null——刚好就是“已付款没推 ERP”的未成团订单,
+ *       退成功后订单 status 变 -2,下一轮扫描自然漏掉,幂等</li>
+ *   <li>孤儿订单扫描用 {@code selectOrphanOrderIdsInFailedGroups},条件是 group.status=2 +
+ *       order.status=1 + refund_status=0 + extend_order_id is null,退完 status 也变 -2自然漏掉</li>
+ *   <li>未支付的团购订单由原订单超时取消任务负责,那边已经接入 releaseGroupSlot,
+ *       不用这里重复处理</li>
+ * </ul>
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Slf4j
+@Component("groupBuyExpireTask")
+public class GroupBuyExpireTask {
+
+    /** 单轮最多处理多少个过期团,防止偶尔积压时把一轮跑爆 */
+    private static final int BATCH_LIMIT = 50;
+
+    @Autowired
+    private FsStoreProductGroupBuyMapper groupBuyMapper;
+
+    @Autowired
+    private FsStoreOrderScrmMapper fsStoreOrderMapper;
+
+    @Autowired
+    private IFsStoreOrderScrmService orderService;
+
+    /**
+     * 每分钟扫一次。触发频率跟 endTime 的精度匹配即可,不用更密。
+     * 定时注解先留着但注释掉,生产上线前人工启用,免得测试环境误触发。
+     */
+//    @Scheduled(fixedRate = 60000)
+    public void handleExpiredGroups() {
+        // 第一段:处理新过期的团——标失败 + 退款团内订单
+        processExpiredGroups();
+        // 第二段:扫一遍孤儿订单——支付回调卡在 endTime 后到达、或上轮退款失败残留下的订单兜底退款
+        processOrphanOrders();
+    }
+
+    private void processExpiredGroups() {
+        List<FsStoreProductGroupBuy> expiredList;
+        try {
+            expiredList = groupBuyMapper.selectExpiredUnformedGroups(BATCH_LIMIT);
+        } catch (Exception e) {
+            log.error("[GroupBuyExpireTask] 查询过期团失败", e);
+            return;
+        }
+        if (expiredList == null || expiredList.isEmpty()) {
+            return;
+        }
+
+        log.info("[GroupBuyExpireTask] 发现 {} 个过期未成团的团,开始处理", expiredList.size());
+
+        int handled = 0;
+        for (FsStoreProductGroupBuy group : expiredList) {
+            try {
+                if (handleOneGroup(group)) {
+                    handled++;
+                }
+            } catch (Exception e) {
+                // 单团出错不影响其他团,下一轮还会扫到,异常只打日志
+                log.error("[GroupBuyExpireTask] 团 {} 处理异常,下一轮重试", group.getId(), e);
+            }
+        }
+        log.info("[GroupBuyExpireTask] 本轮成功处理 {} 个团", handled);
+    }
+
+    /**
+     * 孤儿订单扫描:扫走“团已失败但订单还没退款”的残渣。
+     * <p>两种典型场景会走到这里:
+     * <ul>
+     *   <li>用户在 endTime-1s 下单,支付回调延迟,到达时团已被上一轮任务判失败;
+     *       finishPaidOrderInGroup 检测到 status=2 直接 return,订单留在这儿等退款</li>
+     *   <li>上一轮 processExpiredGroups 调 refundOrderMoney 时支付通道抛异常,订单还未退款成功</li>
+     * </ul>
+     */
+    private void processOrphanOrders() {
+        List<Long> orphanOrderIds;
+        try {
+            orphanOrderIds = groupBuyMapper.selectOrphanOrderIdsInFailedGroups(BATCH_LIMIT);
+        } catch (Exception e) {
+            log.error("[GroupBuyExpireTask] 查询孤儿订单失败", e);
+            return;
+        }
+        if (orphanOrderIds == null || orphanOrderIds.isEmpty()) {
+            return;
+        }
+
+        log.info("[GroupBuyExpireTask] 发现 {} 个孤儿订单(团已失败但订单未退款),开始补退", orphanOrderIds.size());
+        for (Long orderId : orphanOrderIds) {
+            try {
+                R r = orderService.refundOrderMoney(orderId);
+                if (r != null && "0".equals(String.valueOf(r.get("code")))) {
+                    log.info("[GroupBuyExpireTask] 孤儿订单 {} 补退成功", orderId);
+                } else {
+                    log.warn("[GroupBuyExpireTask] 孤儿订单 {} 补退失败:{}",
+                            orderId, r != null ? r.get("msg") : "null");
+                }
+            } catch (Exception e) {
+                log.error("[GroupBuyExpireTask] 孤儿订单 {} 补退异常,待下一轮重试", orderId, e);
+            }
+        }
+    }
+
+    /**
+     * 处理单个过期团:原子标失败 + 循环退款团内已付款订单
+     *
+     * @return true=本任务确实处理了这个团 false=被别的实例抢走/或根本没订单要退
+     */
+    private boolean handleOneGroup(FsStoreProductGroupBuy group) {
+        // CAS 标失败,抢不到就让给别人,不往下走
+        int affected = groupBuyMapper.markGroupFailed(group.getId());
+        if (affected == 0) {
+            log.info("[GroupBuyExpireTask] 团 {} 已被别的任务处理,跳过", group.getId());
+            return false;
+        }
+        log.info("[GroupBuyExpireTask] 团 {} 已标记为拼团失败(end_time={})", group.getId(), group.getEndTime());
+
+        // 拿团内所有"已付款 + 没推过 ERP"的订单,这批就是要自动退款的
+        List<Long> orderIds = fsStoreOrderMapper.selectOrderIdsByGroupBuyId(group.getId());
+        if (orderIds == null || orderIds.isEmpty()) {
+            log.info("[GroupBuyExpireTask] 团 {} 无需退款的订单,收工", group.getId());
+            return true;
+        }
+
+        for (Long orderId : orderIds) {
+            try {
+                R r = orderService.refundOrderMoney(orderId);
+                if (r != null && "0".equals(String.valueOf(r.get("code")))) {
+                    log.info("[GroupBuyExpireTask] 团 {} 订单 {} 自动退款成功", group.getId(), orderId);
+                } else {
+                    // 退款失败不中断,下一轮还会扫到这个订单重试
+                    log.warn("[GroupBuyExpireTask] 团 {} 订单 {} 自动退款失败:{}",
+                            group.getId(), orderId, r != null ? r.get("msg") : "null");
+                }
+            } catch (Exception e) {
+                log.error("[GroupBuyExpireTask] 团 {} 订单 {} 退款异常,待下一轮重试",
+                        group.getId(), orderId, e);
+            }
+        }
+        return true;
+    }
+}

+ 2 - 2
fs-company/src/main/resources/application.yml

@@ -4,12 +4,12 @@ server:
 spring:
   profiles:
 #    active: druid-ylrz
-    active: dev
+#    active: dev
 #    active: druid-jnsyj-test
 #    active: druid-jnmy-test
 #    active: druid-jzzx-test
 #    active: druid-hdt
-#    active: druid-bjzm-test
+    active: druid-bjzm-test
 #    active: druid-yzt
 #    active: druid-myhk
 #    active: druid-sft

+ 3 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreOrderScrm.java

@@ -396,6 +396,9 @@ public class FsStoreOrderScrm extends BaseEntity
     //关联id根据订单类型+associatedId唯一数据
     private Long associatedId;
 
+    /** 团购ID(fs_store_product_group_buy.id,限时团购订单用于关联拼团) */
+    private Long groupBuyId;
+
     //是否同步库存 0-否 1-是
     private Integer isSyncInventory;
 

+ 16 - 1
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductActivity.java

@@ -33,7 +33,7 @@ public class FsStoreProductActivity implements Serializable
     @Excel(name = "活动类型", readConverterExp = "6=秒杀,7=限时折扣")
     private Integer activityType;
 
-    /** 规格ID(fs_store_product_attr_value.id) */
+    /** 规格ID(fs_store_product_attr_value_scrm.id) */
     @Excel(name = "规格ID")
     private Long specId;
 
@@ -53,6 +53,21 @@ public class FsStoreProductActivity implements Serializable
     @Excel(name = "折扣价")
     private BigDecimal discountPrice;
 
+    /**
+     * 团购人数(仅 activity_type=8 限时团购有效)
+     * <p>范围:2 ≤ groupNum ≤ 5,默认 5。
+     * 当同一团中“参团并已付款”的人数达到该值时,将自动成团并触发发货(推送 ERP)。</p>
+     */
+    @Excel(name = "团购人数")
+    private Integer groupNum;
+
+    /**
+     * 团购价(仅 activity_type=8 限时团购有效,必填)
+     * <p>成团后用户结算的实际价格,必须大于 0 且小于原价(originalPrice)。</p>
+     */
+    @Excel(name = "团购价")
+    private BigDecimal groupPrice;
+
     /** 开始时间 */
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
     @Excel(name = "开始时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")

+ 82 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductGroupBuy.java

@@ -0,0 +1,82 @@
+package com.fs.hisStore.domain;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 商品限时团购主表对象 fs_store_product_group_buy
+ * 对应 activity_type=8(限时团购)的团记录,一个团一条
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Data
+public class FsStoreProductGroupBuy implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 团号(唯一,便于分享/查询) */
+    @Excel(name = "团号")
+    private String groupNo;
+
+    /** 活动ID(fs_store_product_activity.id) */
+    private Long activityId;
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 满团人数(冗余 fs_store_product_activity.group_num) */
+    @Excel(name = "满团人数")
+    private Integer groupNum;
+
+    /** 当前已参团人数(下单预占后+1) */
+    @Excel(name = "已参团人数")
+    private Integer joinNum;
+
+    /** 当前已付款人数(支付回调成功后+1),是成团判定的唯一依据 */
+    @Excel(name = "已付款人数")
+    private Integer paidNum;
+
+    /** 团状态:0=进行中 1=拼团完成 2=拼团失败(超时未满员) */
+    @Excel(name = "团状态", readConverterExp = "0=进行中,1=拼团完成,2=拼团失败")
+    private Integer status;
+
+    /** 开团时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    /** 截团时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date endTime;
+
+    /** 拼团完成时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date completeTime;
+
+    /** 删除标志:0=正常 1=删除 */
+    private Integer delFlag;
+
+    /** 创建者 */
+    private String createBy;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新者 */
+    private String updateBy;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+
+    /** 备注 */
+    private String remark;
+}

+ 74 - 0
fs-service/src/main/java/com/fs/hisStore/domain/FsStoreProductGroupBuyItem.java

@@ -0,0 +1,74 @@
+package com.fs.hisStore.domain;
+
+import java.io.Serializable;
+import java.util.Date;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 商品限时团购参与详情对象 fs_store_product_group_buy_item
+ * 每位成功参团用户一条记录,uk_group_user(group_id,user_id) 保证不重复
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Data
+public class FsStoreProductGroupBuyItem implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 主键ID */
+    private Long id;
+
+    /** 团购主表ID */
+    private Long groupId;
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 商品规格ID */
+    private Long specId;
+
+    /** 活动ID */
+    private Long activityId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 用户昵称 */
+    @Excel(name = "用户昵称")
+    private String nickName;
+
+    /** 用户头像 */
+    private String avatar;
+
+    /** 支付状态:0=未支付 1=已支付 2=已退款 */
+    private Integer payStatus;
+
+    /** 支付时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date payTime;
+
+    /** 参团时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date joinTime;
+
+    /** 删除标志:0=正常 1=删除 */
+    private Integer delFlag;
+
+    /** 创建者 */
+    private String createBy;
+
+    /** 创建时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    /** 更新者 */
+    private String updateBy;
+
+    /** 更新时间 */
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date updateTime;
+}

+ 20 - 2
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreOrderScrmMapper.java

@@ -1101,15 +1101,33 @@ public interface FsStoreOrderScrmMapper
 
     List<FsStoreOrderScrm> selectFsStoreOrderStatisticsByUserId(@Param("ids") List<Long> ids);
 
-    @Select("select id from fs_store_order_scrm WHERE `status`= 1  and  extend_order_id is null ")
+    // 未推ERP订单列表,排除【团购订单(order_type=8)但对应的团尚未成团】,避免未成团的团购订单被提前推送到ERP
+    @Select("select id from fs_store_order_scrm "
+            + "where `status` = 1 and extend_order_id is null "
+            + "and (order_type is null or order_type <> 8 "
+            + "     or exists (select 1 from fs_store_product_group_buy g "
+            + "                where g.id = fs_store_order_scrm.group_buy_id "
+            + "                  and g.status = 1 and g.del_flag = 0))")
     List<Long> selectFsStoreOrderNoCreateOms();
 
     @Select("select * from fs_store_order_scrm where status = 1 and extend_order_id is not null and extend_order_id != '' and delivery_id is null order by update_time")
     List<FsStoreOrderScrm> selectUpdateExpress();
 
-    @Select("select fso.id from fs_store_order_scrm fso inner join fs_store_order_audit_scrm fsoa on fsoa.order_id = fso.id where fso.`status`= 1 and fso.extend_order_id is null and fsoa.audit_status = 4")
+    // 未推ERP且已审核订单列表,排除【团购订单(order_type=8)但对应的团尚未成团】,避免未成团的团购订单被提前推送到ERP
+    @Select("select fso.id from fs_store_order_scrm fso "
+            + "inner join fs_store_order_audit_scrm fsoa on fsoa.order_id = fso.id "
+            + "where fso.`status` = 1 and fso.extend_order_id is null and fsoa.audit_status = 4 "
+            + "and (fso.order_type is null or fso.order_type <> 8 "
+            + "     or exists (select 1 from fs_store_product_group_buy g "
+            + "                where g.id = fso.group_buy_id "
+            + "                  and g.status = 1 and g.del_flag = 0))")
     List<Long> selectFsStoreOrderNoCreateOmsAndReviewed();
 
+    // 按 group_buy_id 查询所有已支付未推ERP的订单ID(成团后主动补推 ERP 用)
+    @Select("select id from fs_store_order_scrm "
+            + "where group_buy_id = #{groupBuyId} and `status` = 1 and extend_order_id is null")
+    List<Long> selectOrderIdsByGroupBuyId(@Param("groupBuyId") Long groupBuyId);
+
     List<ReportScrm> selectOrderByCustomerIds(@Param("map") ReportParam param);
 
     int selectFsStoreOrderCountByParam(FsStoreOrderStatisticsParam param);

+ 10 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductActivityMapper.java

@@ -122,6 +122,16 @@ public interface FsStoreProductActivityMapper {
     List<FsStoreProductActivity> selectActivitySpecsByProductIdAndType(
             @Param("productId") Long productId,
             @Param("activityType") Integer activityType);
+
+    /**
+     * 查询团购活动列表(1小时内即将开抢+未过期,activity_type=8)
+     */
+    List<FsStoreProductActivity> selectUpcomingGroupBuyActivityList();
+
+    /**
+     * 按商品ID查当前进行中的团购活动
+     */
+    List<FsStoreProductActivity> selectGroupBuyActivityByProductId(@Param("productId") Long productId);
     /**
      * 按商品ID和规格ID查询活动记录
      * @param productId 商品id

+ 31 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductGroupBuyItemMapper.java

@@ -0,0 +1,31 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreProductGroupBuyItem;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品限时团购参与详情Mapper接口
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+public interface FsStoreProductGroupBuyItemMapper {
+
+    /**
+     * 新增参与详情
+     * 依靠唯一键 uk_group_user(group_id,user_id) 做回调幂等:重复插入会抛 DuplicateKeyException
+     */
+    int insertFsStoreProductGroupBuyItem(FsStoreProductGroupBuyItem item);
+
+    /**
+     * 按团ID查询所有参与详情
+     */
+    List<FsStoreProductGroupBuyItem> selectByGroupId(@Param("groupId") Long groupId);
+
+    /**
+     * 判断指定用户是否已在该团
+     */
+    Integer existsInGroup(@Param("groupId") Long groupId, @Param("userId") Long userId);
+}

+ 109 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsStoreProductGroupBuyMapper.java

@@ -0,0 +1,109 @@
+package com.fs.hisStore.mapper;
+
+import com.fs.hisStore.domain.FsStoreProductGroupBuy;
+import com.fs.hisStore.vo.FsStoreGroupBuyListVO;
+import com.fs.hisStore.vo.FsStoreGroupBuyMemberVO;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 商品限时团购主表Mapper接口
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+public interface FsStoreProductGroupBuyMapper {
+
+    /**
+     * 根据主键查询
+     */
+    FsStoreProductGroupBuy selectFsStoreProductGroupBuyById(@Param("id") Long id);
+
+    /**
+     * 新增团
+     */
+    int insertFsStoreProductGroupBuy(FsStoreProductGroupBuy groupBuy);
+
+    /**
+     * 更新团
+     */
+    int updateFsStoreProductGroupBuy(FsStoreProductGroupBuy groupBuy);
+
+    /**
+     * 查询一个"可加入"的团(同活动、进行中、未满员、未过期、且用户未加入过)
+     * 按"差的人少、创建早"排序,优先凑满老团
+     */
+    FsStoreProductGroupBuy selectJoinableGroup(@Param("activityId") Long activityId,
+                                               @Param("userId") Long userId);
+
+    /**
+     * 原子抢名额:join_num+1,仅在进行中、未过期、未满员时生效
+     * 成团标记改由 {@link #tryMarkPaidAndComplete} 在支付回调时按实际付款人数判定,
+     * 这里不再检测满员。
+     * 返回影响行数:1=抢到,0=失败(满员/已结束/已关闭)
+     */
+    int tryJoinGroup(@Param("groupId") Long groupId);
+
+    /**
+     * 支付回调推进付款数 + 满员时原子置成团:
+     * paid_num+1,按新 paid_num 判定是否满员 -> 满员时同步置 status=1 + complete_time
+     * 返回行数:1=正常推进(0=团已非进行中跳过)
+     */
+    int tryMarkPaidAndComplete(@Param("groupId") Long groupId);
+
+    /**
+     * 释放名额:超时未支付取消订单时调用,join_num-1
+     * 仅对 status=0 且 join_num>0 的团生效;已成团的不回滚
+     * 返回影响行数:1=释放成功,0=无需释放
+     */
+    int releaseJoin(@Param("groupId") Long groupId);
+
+    /**
+     * 已付款订单退款时释放:同时退 join_num 和 paid_num,避免 paid_num 虚高影响后续成团判定
+     * 仅对 status=0 且 join_num>0 且 paid_num>0 的团生效;已成团的不动
+     */
+    int releasePaidAndJoin(@Param("groupId") Long groupId);
+
+    /**
+     * 扫过期未成团的团:status=0 且 end_time<=now()
+     * 带 limit 保护,避免一次把所有过期团全捞出来堵住任务
+     */
+    List<FsStoreProductGroupBuy> selectExpiredUnformedGroups(@Param("limit") int limit);
+
+    /**
+     * 原子将团标记为“拼团失败”:仅在 status=0 且 end_time<=now() 时置 2
+     * 返回行数:1=标记成功,可以接着处理团内订单;0=已被别的任务抢先处理过,跳过
+     */
+    int markGroupFailed(@Param("groupId") Long groupId);
+
+    /**
+     * 扫孤儿订单:团已判失败(status=2),但团内还有已支付且未退款的订单
+     * <p>这种情况是“支付回调卡在 endTime 之后到达”或“先前任务轮退款失败”造成的,
+     * 是定时任务的兜底切入点。</p>
+     * <p>条件:order.order_type=8 + order.group_buy_id != null + order.status=1
+     * + order.refund_status=0 + order.extend_order_id is null + group.status=2</p>
+     */
+    List<Long> selectOrphanOrderIdsInFailedGroups(@Param("limit") int limit);
+
+    // ==================== 后台管理:列表 / 详情 ====================
+
+    /**
+     * 后台团购列表查询(联 product / activity,拿商品名/图/活动价)。
+     * 所有参数均为可选,null / 空串视为不过滤。当传 id 时只返回这一条,用于详情页头部。
+     */
+    List<FsStoreGroupBuyListVO> selectGroupBuyListForAdmin(
+            @Param("id") Long id,
+            @Param("groupNo") String groupNo,
+            @Param("productId") Long productId,
+            @Param("productName") String productName,
+            @Param("status") Integer status,
+            @Param("beginTime") String beginTime,
+            @Param("endTime") String endTime);
+
+    /**
+     * 查团内成员详情:JOIN item + user + order,把团员与订单状态打平在一起返回。
+     * 用于后台“查看团购详情”。
+     */
+    List<FsStoreGroupBuyMemberVO> selectGroupBuyMembers(@Param("groupId") Long groupId);
+}

+ 10 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductActivityService.java

@@ -96,4 +96,14 @@ public interface IFsStoreProductActivityService {
      * 按商品ID和活动类型查询所有参与活动的规格(用于详情页返回规格数组)
      */
     List<FsStoreProductActivity> selectActivitySpecsByProductIdAndType(Long productId, Integer activityType);
+
+    /**
+     * 查询团购活动列表(1小时内即将开抢+未过期,activity_type=8)
+     */
+    List<FsStoreProductActivity> selectUpcomingGroupBuyActivityList();
+
+    /**
+     * 按商品ID查当前进行中的团购活动
+     */
+    List<FsStoreProductActivity> selectGroupBuyActivityByProductId(Long productId);
 }

+ 86 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreProductGroupBuyService.java

@@ -0,0 +1,86 @@
+package com.fs.hisStore.service;
+
+import com.fs.hisStore.domain.FsStoreOrderScrm;
+import com.fs.hisStore.vo.FsStoreGroupBuyListVO;
+
+import java.util.List;
+
+/**
+ * 限时团购业务Service
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+public interface IFsStoreProductGroupBuyService {
+
+    /**
+     * 下单阶段预占团购名额。
+     * 策略:
+     *  1. 找一个"未满员、未过期、当前用户未加入"的团,原子抢一个名额
+     *  2. 都抢不到(或没有)就新建一个团,自己是第一个人
+     * 并发靠 UPDATE 原子 CAS,循环重试最多 {@link #DEFAULT_RESERVE_RETRY} 次
+     *
+     * @param activityId 活动ID
+     * @param userId     下单用户ID
+     * @return 预占成功返回团ID;返回 null 表示预占失败(调用方应中断下单)
+     */
+    Long reserveGroupSlot(Long activityId, Long userId);
+
+    /** 下单抢名额最大重试次数 */
+    int DEFAULT_RESERVE_RETRY = 5;
+
+    /**
+     * 支付成功后落地团购参与详情。
+     * 走到这里时订单已经在下单阶段抢好了 group_buy_id:
+     *  1. 订单已有 group_buy_id → 写 item(uk_group_user 幂等),按当前 join_num 判断是否成团
+     *  2. 订单没有 group_buy_id(历史单或极端异常)→ 退回到"回调匹配团"兜底逻辑
+     *
+     * @param order 已支付订单
+     */
+    void handleAfterPay(FsStoreOrderScrm order);
+
+    /**
+     * 释放名额:超时未支付取消订单时调用,把 join_num 回滚 1。
+     * 已成团(status=1)的团不回滚,避免把已成团的位置退回导致整团信息不一致。
+     *
+     * @param groupBuyId 团ID
+     * @return 是否释放成功
+     */
+    boolean releaseGroupSlot(Long groupBuyId);
+
+    /**
+     * 已付款订单退款时释放名额:同时回滚 join_num 和 paid_num。
+     * <p>适用场景:申请售后、后台手动退款等已付款订单的退款路径;
+     * 如果不把 paid_num 也回滚,后来的团员付款时会被虚高的 paid_num 误判成团。</p>
+     * <p>SQL 层做了 status=0 保护,已成团(status=1) 或 已失败(status=2) 的团调下来是空操作,
+     * 调用方无脑调即可,不用自己判状态。</p>
+     *
+     * @param groupBuyId 团ID
+     * @return true=实际回滚了一行;false=团已非进行中,跳过
+     */
+    boolean releasePaidGroupSlot(Long groupBuyId);
+
+    // ==================== 后台管理:列表 / 详情 ====================
+
+    /**
+     * 后台团购列表查询(用于 /store/productGroupBuy/list 分页)。
+     * 调用方请先 startPage() 再调这个方法。所有参数均可空。
+     */
+    List<FsStoreGroupBuyListVO> selectGroupBuyListForAdmin(String groupNo,
+                                                            Long productId,
+                                                            String productName,
+                                                            Integer status,
+                                                            String beginTime,
+                                                            String endTime);
+
+    /**
+     * 按商品ID查历史拼团。调用方请先 startPage() 再调这个方法。
+     */
+    List<FsStoreGroupBuyListVO> selectGroupBuyListByProduct(Long productId);
+
+    /**
+     * 后台团购详情:主信息 + 团员列表(含关联订单状态)。
+     * 找不到返回 null。
+     */
+    FsStoreGroupBuyListVO selectGroupBuyDetailForAdmin(Long id);
+}

+ 35 - 3
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreAfterSalesScrmServiceImpl.java

@@ -165,7 +165,8 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
 
     @Autowired
     IFsUserScrmService userService;
-
+    @Autowired
+    IFsStoreProductGroupBuyService groupBuyService;
     @Autowired
     IPayService ybPayService;
     @Autowired
@@ -366,8 +367,14 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         if("1".equals(configUtil.generateConfigByKey(SysConfigEnum.HIS_CONFIG.getKey()).getString("erpOpen"))
                 && StringUtils.isEmpty(order.getExtendOrderId())
                 && !CloudHostUtils.hasCloudHostName("康年堂")){
-            logger.info("erpOpen:{}",configUtil.generateConfigByKey(SysConfigEnum.HIS_CONFIG.getKey()).getString("erpOpen"));
-            return R.error("仓库未生成订单,暂时不能申请退款,请联系客服");
+            // 团购未成团的订单压根没推过 ERP,这种情况该让用户能退 —— 放行,不走下面的 ERP 取消调用
+            boolean isUnformedGroupOrder = order.getOrderType() != null
+                    && order.getOrderType() == 8
+                    && order.getGroupBuyId() != null;
+            if (!isUnformedGroupOrder) {
+                logger.info("erpOpen:{}", configUtil.generateConfigByKey(SysConfigEnum.HIS_CONFIG.getKey()).getString("erpOpen"));
+                return R.error("仓库未生成订单,暂时不能申请退款,请联系客服");
+            }
         }
         if(order.getStatus()== OrderInfoEnum.STATUS_NE3.getValue()){
             return R.error("已取消订单不能申请售后");
@@ -472,6 +479,8 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         request.setRefund_state(1);
         request.setStoreAfterSalesId(storeAfterSales.getId());
         request.setOrderStatus(orderStatus);
+        // 团购订单申请退款时先把占的名额还回去,SQL 内部只对未成团的团生效,已成团的不会动
+        releaseGroupSlotIfNeeded(order);
         if (StringUtils.isNotBlank(order.getExtendOrderId())){
             BaseResponse response=erpOrderService.refundUpdateScrm(request);
             if(response.getSuccess()){
@@ -485,6 +494,29 @@ public class FsStoreAfterSalesScrmServiceImpl implements IFsStoreAfterSalesScrmS
         return R.ok();
     }
 
+    /**
+     * 团购订单申请退款时还回占的名额。
+     * <p>仅对 orderType=8 且已有 groupBuyId 的订单生效;
+     * 走的是 releasePaidGroupSlot,同时回滚 join_num + paid_num(售后前提就是已付款);
+     * Mapper SQL 只改 status=0 的未成团团,已成团的调下来不会误伤其他团友。</p>
+     * <p>异常不中断退款主流程,打个错误日志给运维看就行。</p>
+     */
+    private void releaseGroupSlotIfNeeded(FsStoreOrderScrm order) {
+        if (order == null || order.getOrderType() == null || order.getOrderType() != 8) {
+            return;
+        }
+        Long groupBuyId = order.getGroupBuyId();
+        if (groupBuyId == null) {
+            return;
+        }
+        try {
+            groupBuyService.releasePaidGroupSlot(groupBuyId);
+        } catch (Exception e) {
+            logger.error("团购订单申请退款释放名额失败,orderId={},groupBuyId={},需人工核查",
+                    order.getId(), groupBuyId, e);
+        }
+    }
+
     private IErpOrderService getErpService() {
         FsSysConfig sysConfig = configUtil.getSysConfig();
         Integer erpOpen = sysConfig.getErpOpen();

+ 177 - 52
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -248,6 +248,9 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Autowired
     private RedissonClient redissonClient;
 
+    @Autowired
+    private com.fs.hisStore.service.IFsStoreProductGroupBuyService groupBuyService;
+
     @Autowired
     private com.fs.common.core.redis.service.ActivityStockService activityStockService;
 
@@ -851,7 +854,7 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             }
         }
 
-        if(cartParam.getProductType() != null && (cartParam.getProductType() == 6 || cartParam.getProductType() == 7) ){//更新金额
+        if(cartParam.getProductType() != null && (cartParam.getProductType() == 6 || cartParam.getProductType() == 7 || cartParam.getProductType() == 8) ){//更新金额
             for (FsStoreCartQueryVO c : carts){
                 //获取对应商品金额
                 FsStoreProductActivity activity = activityMapper.selectActivityByProductIdAndSpecId(c.getProductId(), c.getProductAttrValueId());
@@ -861,7 +864,18 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 //更新购物车信息
                 FsStoreCartScrm cartScrm = new FsStoreCartScrm();
                 cartScrm.setId(c.getId());
-                BigDecimal price = cartParam.getProductType() == 7?activity.getDiscountPrice():activity.getFlashPrice();
+                // 按活动类型取价:7=折扣价 / 8=团购价 / 6=秒杀价
+                BigDecimal price;
+                if (cartParam.getProductType() == 7) {
+                    price = activity.getDiscountPrice();
+                } else if (cartParam.getProductType() == 8) {
+                    price = activity.getGroupPrice();
+                    if (price == null) {
+                        return R.error("操作失败,团购价未设置!");
+                    }
+                } else {
+                    price = activity.getFlashPrice();
+                }
                 cartScrm.setChangePrice(price);
                 c.setPrice(price);
                 c.setChangePrice(price);
@@ -1347,61 +1361,86 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     public R createActivityOrder(long userId, FsStoreOrderCreateParam param) {
         Long associatedId = null;
         Integer orderType = param.getOrderType();
+        int activityDeductNum = 1; // Redis库存扣减数量(仅秒杀/限时折扣使用,异常时用于回滚)
+        Long reservedGroupBuyId = null; // 团购下单阶段预占到的团ID,异常时用于释放名额
     
-        // 1. 活动参数校验
-        if (orderType == null || (orderType != 6 && orderType != 7)) {
+        //活动参数校验(6=秒杀 7=限时折扣 8=限时团购)
+        if (orderType == null || (orderType != 6 && orderType != 7 && orderType != 8)) {
             return R.error("无效的活动类型");
         }
         if (param.getAssociatedId() == null || param.getAssociatedId() <= 0) {
             return R.error("活动ID不能为空");
         }
-    
-        // 2. 校验活动时间和状态
-        com.fs.common.core.redis.service.ActivityValidateResult validateResult =
-                activityStockService.validateActivityWithDetail(orderType, param.getAssociatedId());
-        if (!validateResult.isValid()) {
-            return R.error(validateResult.getMessage());
-        }
-    
-        // 3. 确保Redis活动信息和规格库存已初始化(活动无独立库存,用规格库存)
+
+        //取活动信息
         FsStoreProductActivity activityInfo = activityService.selectFsStoreProductActivityById(param.getAssociatedId());
         if (activityInfo == null) {
             return R.error("活动信息不存在");
         }
-        activityStockService.initActivityInfo(
-                param.getAssociatedId(), 1,
-                activityInfo.getStartTime().getTime(), activityInfo.getEndTime().getTime(),
-                activityInfo.getProductId(), activityInfo.getSpecId(), null
-        );
-        if (activityInfo.getSpecId() != null && activityInfo.getSpecStock() != null) {
-            activityStockService.initProductSpecStock(activityInfo.getSpecId(), activityInfo.getSpecStock());
-        }
-
-        // 3.5 提前获取购物车,计算活动商品总件数,确保Lua扣减数量与DB扣减数量一致
-        String preCartIds = redisCache.getCacheObject("orderKey:" + param.getOrderKey());
-        List<FsStoreCartQueryVO> preCarts = null;
-        int activityDeductNum = 1; // 默认扣减1件
-        if (preCartIds != null) {
-            preCarts = redisCache.getCacheObject("orderCarts:" + param.getOrderKey());
-            if (preCarts != null) {
-                activityDeductNum = 0;
-                for (FsStoreCartQueryVO cart : preCarts) {
-                    if (cart.getCartNum() != null) {
-                        activityDeductNum += cart.getCartNum();
+
+        if (orderType == 8) {
+            // 限时团购:不走 Redis/Lua 预扣,库存交由后续 deStockIncSale 走 DB 统一扣减
+            long nowTs = System.currentTimeMillis();
+            if (activityInfo.getStatus() == null || activityInfo.getStatus() != 1) {
+                return R.error("活动已下架");
+            }
+            if (activityInfo.getStartTime() == null || nowTs < activityInfo.getStartTime().getTime()) {
+                return R.error("活动尚未开始");
+            }
+            if (activityInfo.getEndTime() == null || nowTs > activityInfo.getEndTime().getTime()) {
+                return R.error("活动已结束");
+            }
+            associatedId = param.getAssociatedId();
+
+            // 下单时先预占一个团的名额:优先找未满未过期的老团拼团,都没有就自己单独开团
+            reservedGroupBuyId = groupBuyService.reserveGroupSlot(associatedId, userId);
+            if (reservedGroupBuyId == null) {
+                return R.error("团购名额预占失败,请稍后重试");
+            }
+        } else {
+            // 秒杀/限时折扣:保持原有 Redis + Lua 预扣路径
+            //校验活动时间和状态
+            com.fs.common.core.redis.service.ActivityValidateResult validateResult =
+                    activityStockService.validateActivityWithDetail(orderType, param.getAssociatedId());
+            if (!validateResult.isValid()) {
+                return R.error(validateResult.getMessage());
+            }
+
+            //确保Redis活动信息和规格库存已初始化(活动无独立库存,用规格库存)
+            activityStockService.initActivityInfo(
+                    param.getAssociatedId(), 1,
+                    activityInfo.getStartTime().getTime(), activityInfo.getEndTime().getTime(),
+                    activityInfo.getProductId(), activityInfo.getSpecId(), null
+            );
+            if (activityInfo.getSpecId() != null && activityInfo.getSpecStock() != null) {
+                activityStockService.initProductSpecStock(activityInfo.getSpecId(), activityInfo.getSpecStock());
+            }
+
+            //提前获取购物车,计算活动商品总件数,确保Lua扣减数量与DB扣减数量一致
+            String preCartIds = redisCache.getCacheObject("orderKey:" + param.getOrderKey());
+            List<FsStoreCartQueryVO> preCarts = null;
+            if (preCartIds != null) {
+                preCarts = redisCache.getCacheObject("orderCarts:" + param.getOrderKey());
+                if (preCarts != null) {
+                    activityDeductNum = 0;
+                    for (FsStoreCartQueryVO cart : preCarts) {
+                        if (cart.getCartNum() != null) {
+                            activityDeductNum += cart.getCartNum();
+                        }
+                    }
+                    if (activityDeductNum <= 0) {
+                        activityDeductNum = 1;
                     }
-                }
-                if (activityDeductNum <= 0) {
-                    activityDeductNum = 1;
                 }
             }
-        }
 
-        // 4. Lua原子扣减活动库存(已移除 getStock() 预检查——先查再扣存在竞态窗口,Lua脚本本身会原子判断库存是否充足)
-        boolean deductSuccess = activityStockService.deductStock(orderType, param.getAssociatedId(), activityDeductNum);
-        if (!deductSuccess) {
-            return R.error("活动商品已售罄,请稍后重试");
+            //Lua原子扣减活动库存(已移除 getStock() 预检查——先查再扣存在竞态窗口,Lua脚本本身会原子判断库存是否充足)
+            boolean deductSuccess = activityStockService.deductStock(orderType, param.getAssociatedId(), activityDeductNum);
+            if (!deductSuccess) {
+                return R.error("活动商品已售罄,请稍后重试");
+            }
+            associatedId = param.getAssociatedId();
         }
-        associatedId = param.getAssociatedId();
 
         try {
             FsUserCompanyUser fsUserCompanyUser = fsUserCompanyUserMapper.selectFsUserCompanyUserByUserId(userId);
@@ -1618,6 +1657,10 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 //活动商品只扣Redis(已在上文扣减),DB延后扣减
                 // storeOrder.setAssociatedId
                 storeOrder.setAssociatedId(associatedId);
+                // 团购订单:回写下单时预占到的团ID,支付回调就按这个团落详情
+                if (reservedGroupBuyId != null) {
+                    storeOrder.setGroupBuyId(reservedGroupBuyId);
+                }
                 Integer flag = fsStoreOrderMapper.insertFsStoreOrder(storeOrder);
                 if (flag == 0) {
                     return R.error("订单创建失败");
@@ -1741,14 +1784,24 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 return R.error("订单已过期");
             }
         } catch (Exception e) {
-            // Redis已扣减成功,订单创建过程异常,必须回滚Redis库存
-            try {
-                activityStockService.rollbackStock(orderType, param.getAssociatedId(), activityDeductNum);
-                log.info("订单创建异常,Redis库存已回滚,associatedId={}, orderType={}, 回滚数量={}",
-                        param.getAssociatedId(), orderType, activityDeductNum);
-            } catch (Exception rollbackEx) {
-                log.error("订单创建异常后Redis库存回滚失败!associatedId={},orderType={},需要人工处理",
-                        param.getAssociatedId(), orderType, rollbackEx);
+            // 团购未走 Redis 预扣,无需回滚 Redis;秒杀/限时折扣 Redis 已扣减成功,订单创建异常必须回滚
+            if (orderType != null && orderType != 8) {
+                try {
+                    activityStockService.rollbackStock(orderType, param.getAssociatedId(), activityDeductNum);
+                    log.info("订单创建异常,Redis库存已回滚,associatedId={}, orderType={}, 回滚数量={}",
+                            param.getAssociatedId(), orderType, activityDeductNum);
+                } catch (Exception rollbackEx) {
+                    log.error("订单创建异常后Redis库存回滚失败!associatedId={},orderType={},需要人工处理",
+                            param.getAssociatedId(), orderType, rollbackEx);
+                }
+            }
+            // 团购订单:下单预占到的名额因订单异常无效,要把名额还回去,免得团被卡死
+            if (orderType != null && orderType == 8 && reservedGroupBuyId != null) {
+                try {
+                    groupBuyService.releaseGroupSlot(reservedGroupBuyId);
+                } catch (Exception releaseEx) {
+                    log.error("订单创建异常后释放团购名额失败,groupBuyId={},需人工处理", reservedGroupBuyId, releaseEx);
+                }
             }
             log.error("活动订单创建异常,orderType={}, associatedId={}", orderType, param.getAssociatedId(), e);
             throw e;
@@ -1897,6 +1950,8 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             this.refundCoupon(order);
             //退回库存
             this.refundStock(order);
+            // 团购订单取消时把占的位置还回去,SQL 内部只对未成团的团生效,已成团的不会动
+            releaseGroupSlotIfNeeded(order);
             fsStoreOrderMapper.cancelOrder(orderId);
             //添加记录
             orderStatusService.create(order.getId(), OrderLogEnum.CANCEL_ORDER.getValue(),
@@ -2767,6 +2822,15 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         } catch (Exception e) {
             log.error("创建限购商品失败:{}", e.getMessage());
         }
+        // 限时团购(activity_type=8):支付成功后才真正加入/新建团,并回写 group_buy_id
+        // 自身事务独立,异常吃掉不影响支付回调主流程(汇付需 return SUCCESS 避免重试)
+        if (order.getOrderType() != null && order.getOrderType() == 8) {
+            try {
+                groupBuyService.handleAfterPay(order);
+            } catch (Exception e) {
+                log.error("团购订单支付后处理失败,orderId={}", order.getId(), e);
+            }
+        }
         return "SUCCESS";
     }
 
@@ -3133,11 +3197,18 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         if (order.getStatus() != 1 && order.getStatus() != 2) {
             return R.error("非法操作");
         }
+
+        Integer originalRefundStatus = order.getRefundStatus();
         if (erpConfig.getErpOpen() != null
                 && erpConfig.getErpOpen() == 1
                 && order.getExtendOrderId() == null
                 && !CloudHostUtils.hasCloudHostName("康年堂")) {
-            return R.error("暂未推送至erp,请稍后再试!");
+            boolean isUnformedGroupOrder = order.getOrderType() != null
+                    && order.getOrderType() == 8
+                    && order.getGroupBuyId() != null;
+            if (!isUnformedGroupOrder) {
+                return R.error("暂未推送至erp,请稍后再试!");
+            }
         }
         if (StringUtils.isNotEmpty(order.getExtendOrderId())) {
             ErpRefundUpdateRequest request = new ErpRefundUpdateRequest();
@@ -3180,6 +3251,11 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         order.setRefundStatus(OrderInfoEnum.REFUND_STATUS_2.getValue());
         fsStoreOrderMapper.updateFsStoreOrder(order);
 
+        // 团购已付款订单退款:同时回滚 join_num + paid_num。
+        if (originalRefundStatus == null || originalRefundStatus == 0) {
+            releasePaidGroupSlotIfNeeded(order);
+        }
+
         //退库存
         //获取订单下的商品
         List<FsStoreOrderItemVO> orderItemVOS = fsStoreOrderItemMapper.selectFsStoreOrderItemListByOrderId(order.getId());
@@ -3189,8 +3265,12 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
             refundActivityStock(order, orderItemVOS);
         }
 
+        // 团购订单(orderType=8)下单时走的是 deStockIncSale 直接扣 fs_store_product_attr_value_scrm 规格库存(stock-/sales+),
+        // 没走 isAfterSales 的售后流程;超时自动退款 / 直接退款路径下 isAfterSales 还是 0,
+        // 不无条件回的话 fs_store_product_attr_value_scrm 的 stock 就泄漏了。
+        boolean isGroupBuyOrder = order.getOrderType() != null && order.getOrderType() == 8;
         for (FsStoreOrderItemVO vo : orderItemVOS) {
-            if (vo.getIsAfterSales() == 1) {
+            if (vo.getIsAfterSales() == 1 || isGroupBuyOrder) {
                 productService.incProductStock(vo.getNum(), vo.getProductId(), vo.getProductAttrValueId());
             }
 
@@ -4032,6 +4112,51 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
                 && order.getAssociatedId() > 0;
     }
 
+    /**
+     * 团购订单未付款取消时还回占的名额。
+     * <p>仅对 orderType=8 且已有 groupBuyId 的订单生效;
+     * Mapper 层的 SQL 从来只改 status=0 的未成团团,已成团的订单调下来不会误伤。</p>
+     * <p>异常不中断主流程,打个错误日志给运维看就行。</p>
+     */
+    private void releaseGroupSlotIfNeeded(FsStoreOrderScrm order) {
+        if (order == null || order.getOrderType() == null || order.getOrderType() != 8) {
+            return;
+        }
+        Long groupBuyId = order.getGroupBuyId();
+        if (groupBuyId == null) {
+            return;
+        }
+        try {
+            groupBuyService.releaseGroupSlot(groupBuyId);
+        } catch (Exception e) {
+            log.error("团购订单释放名额失败,orderId={},groupBuyId={},需人工核查",
+                    order.getId(), groupBuyId, e);
+        }
+    }
+
+    /**
+     * 已付款订单退款时还名额:同时回滚 join_num + paid_num。
+     * <p>跟 {@link #releaseGroupSlotIfNeeded} 的区别是这个用于"已付款"场景(refundOrderMoney),
+     * 前者用于"未付款取消"场景(cancelOrder)。paid_num 只有在已付款路径才加过 1,
+     * 退款时需要同步扣回,不然 paid_num 会虚高让后续团员被误判成团。</p>
+     * <p>SQL 层做了 status=0 保护,团已成团/已失败时空转不会误改,调用方无需自己判。</p>
+     */
+    private void releasePaidGroupSlotIfNeeded(FsStoreOrderScrm order) {
+        if (order == null || order.getOrderType() == null || order.getOrderType() != 8) {
+            return;
+        }
+        Long groupBuyId = order.getGroupBuyId();
+        if (groupBuyId == null) {
+            return;
+        }
+        try {
+            groupBuyService.releasePaidGroupSlot(groupBuyId);
+        } catch (Exception e) {
+            log.error("团购订单退款释放名额失败,orderId={},groupBuyId={},需人工核查",
+                    order.getId(), groupBuyId, e);
+        }
+    }
+
     /**
      * 活动订单退款时回滚Redis规格库存(Lua原子操作)。
      * 异常不中断退款主流程,但记录错误日志以便人工排查。

+ 31 - 1
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductActivityServiceImpl.java

@@ -68,7 +68,27 @@ public class FsStoreProductActivityServiceImpl implements IFsStoreProductActivit
         if (activityType != null && activityType != 0) {
             FsStoreProductActivity ongoing = activityMapper.selectOngoingActivity(productId);
             if (ongoing != null) {
-                throw new RuntimeException("当前商品正在" + (ongoing.getActivityType() == 6 ? "秒杀" : "限时折扣") + "活动中,活动进行期间无法修改活动设置");
+                String typeName;
+                Integer ongoingType = ongoing.getActivityType();
+                if (ongoingType != null && ongoingType == 6) {
+                    typeName = "秒杀";
+                } else if (ongoingType != null && ongoingType == 8) {
+                    typeName = "限时团购";
+                } else {
+                    typeName = "限时折扣";
+                }
+                throw new RuntimeException("当前商品正在" + typeName + "活动中,活动进行期间无法修改活动设置");
+            }
+        }
+
+        // 限时团购(activity_type=8)专有校验:团购人数必须在 2~5 区间,未传或范围外则报错
+        // (平台规定:单团最多 5 人;已由前端 el-input-number min=2 max=5 约束,后端再做一道兜底防止绕过前端调用)
+        if (activityType != null && activityType == 8 && activityList != null && !activityList.isEmpty()) {
+            for (FsStoreProductActivity item : activityList) {
+                Integer gn = item.getGroupNum();
+                if (gn == null || gn < 2 || gn > 5) {
+                    throw new RuntimeException("团购人数必填且限 2~5 人(默认 5 人)");
+                }
             }
         }
 
@@ -241,4 +261,14 @@ public class FsStoreProductActivityServiceImpl implements IFsStoreProductActivit
     public List<FsStoreProductActivity> selectActivitySpecsByProductIdAndType(Long productId, Integer activityType) {
         return activityMapper.selectActivitySpecsByProductIdAndType(productId, activityType);
     }
+
+    @Override
+    public List<FsStoreProductActivity> selectUpcomingGroupBuyActivityList() {
+        return activityMapper.selectUpcomingGroupBuyActivityList();
+    }
+
+    @Override
+    public List<FsStoreProductActivity> selectGroupBuyActivityByProductId(Long productId) {
+        return activityMapper.selectGroupBuyActivityByProductId(productId);
+    }
 }

+ 458 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreProductGroupBuyServiceImpl.java

@@ -0,0 +1,458 @@
+package com.fs.hisStore.service.impl;
+
+import com.fs.hisStore.domain.FsStoreOrderScrm;
+import com.fs.hisStore.domain.FsStoreProductActivity;
+import com.fs.hisStore.domain.FsStoreProductGroupBuy;
+import com.fs.hisStore.domain.FsStoreProductGroupBuyItem;
+import com.fs.hisStore.domain.FsUserScrm;
+import com.fs.hisStore.mapper.FsStoreOrderScrmMapper;
+import com.fs.hisStore.mapper.FsStoreProductActivityMapper;
+import com.fs.hisStore.mapper.FsStoreProductGroupBuyItemMapper;
+import com.fs.hisStore.mapper.FsStoreProductGroupBuyMapper;
+import com.fs.hisStore.service.IFsStoreOrderScrmService;
+import com.fs.hisStore.service.IFsStoreProductGroupBuyService;
+import com.fs.hisStore.service.IFsUserScrmService;
+import com.fs.hisStore.vo.FsStoreGroupBuyListVO;
+import com.fs.hisStore.vo.FsStoreGroupBuyMemberVO;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.dao.DuplicateKeyException;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
+
+import java.util.Date;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * 限时团购业务Service实现
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Service
+public class FsStoreProductGroupBuyServiceImpl implements IFsStoreProductGroupBuyService {
+
+    private static final Logger log = LoggerFactory.getLogger(FsStoreProductGroupBuyServiceImpl.class);
+
+    @Autowired
+    private FsStoreProductGroupBuyMapper groupBuyMapper;
+
+    @Autowired
+    private FsStoreProductGroupBuyItemMapper groupBuyItemMapper;
+
+    @Autowired
+    private FsStoreProductActivityMapper activityMapper;
+
+    @Autowired
+    private FsStoreOrderScrmMapper fsStoreOrderMapper;
+
+    @Autowired
+    private IFsUserScrmService userService;
+
+    /** 打破与 FsStoreOrderScrmServiceImpl 的循环依赖:对方注入本类,这里反向注入要 @Lazy */
+    @Lazy
+    @Autowired
+    private IFsStoreOrderScrmService fsStoreOrderService;
+
+    // ================================================================
+    // 1. 下单阶段:预占名额
+    // ================================================================
+
+    /**
+     * 用独立事务(REQUIRES_NEW)。订单主事务即便后续因为其他原因回滚,
+     * 这里的名额占用也已提交;但若下单最终失败,调用方应该自行调 {@link #releaseGroupSlot}
+     * 把名额还回去,避免"占座不下单"把团卡死。
+     */
+    @Override
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public Long reserveGroupSlot(Long activityId, Long userId) {
+        if (activityId == null || userId == null) {
+            return null;
+        }
+        FsStoreProductActivity activity = activityMapper.selectFsStoreProductActivityById(activityId);
+        if (activity == null || activity.getGroupNum() == null || activity.getGroupNum() <= 0) {
+            log.warn("[团购预占] 活动信息不全,activityId={}", activityId);
+            return null;
+        }
+        if (activity.getEndTime() != null && activity.getEndTime().before(new Date())) {
+            log.warn("[团购预占] 活动已结束,activityId={}", activityId);
+            return null;
+        }
+
+        // 先尝试加入已有未满团,循环重试应对并发抢
+        for (int i = 0; i < DEFAULT_RESERVE_RETRY; i++) {
+            FsStoreProductGroupBuy candidate = groupBuyMapper.selectJoinableGroup(activityId, userId);
+            if (candidate == null) {
+                break;
+            }
+            int affected = groupBuyMapper.tryJoinGroup(candidate.getId());
+            if (affected == 1) {
+                return candidate.getId();
+            }
+            // 被别人抢走了,重新找
+        }
+
+        // 没有可加入的团 → 自己开一团
+        FsStoreProductGroupBuy created = createNewGroup(activity, userId);
+        return created.getId();
+    }
+
+    @Override
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public boolean releaseGroupSlot(Long groupBuyId) {
+        if (groupBuyId == null) {
+            return false;
+        }
+        int affected = groupBuyMapper.releaseJoin(groupBuyId);
+        if (affected == 0) {
+            log.info("[团购释放名额] 无需释放,groupId={}", groupBuyId);
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * 已付款订单退款路径:同时回滚 join_num 和 paid_num。
+     * 走独立事务,主退款事务即使后续出异常回滚,名额也已经扣进去(跟 releaseGroupSlot 设计一致)。
+     */
+    @Override
+    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
+    public boolean releasePaidGroupSlot(Long groupBuyId) {
+        if (groupBuyId == null) {
+            return false;
+        }
+        int affected = groupBuyMapper.releasePaidAndJoin(groupBuyId);
+        if (affected == 0) {
+            // 团已非进行中(成团或已失败),这时不动 paid_num 正是预期行为:
+            // - status=1 成团的订单退款不影响后续成团判定(团已终态)
+            // - status=2 失败团同理,后续孤儿退款走的就是这条路径
+            log.info("[团购退款释放名额] 团已终态无需回滚,groupId={}", groupBuyId);
+            return false;
+        }
+        return true;
+    }
+
+    // ================================================================
+    // 2. 支付回调阶段:写详情 + 成团判断
+    // ================================================================
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public void handleAfterPay(FsStoreOrderScrm order) {
+        if (order == null || order.getOrderType() == null || order.getOrderType() != 8) {
+            return;
+        }
+        // 主路径:下单已预占到团,回调这里只需落详情 + 判定是否成团
+        if (order.getGroupBuyId() != null) {
+            finishPaidOrderInGroup(order);
+            return;
+        }
+
+        // 兜底:订单没有 group_buy_id(历史单或预占阶段异常),退回到"回调匹配团"
+        log.warn("[团购回调] 订单无 group_buy_id 走兜底匹配,orderId={}", order.getId());
+        fallbackMatchGroupOnPay(order);
+    }
+
+    /**
+     * 已预占好团的订单:写 item + 原子推进 paid_num,满员才真正成团
+     * <p>幂等保护:如果 insertItemSafely 返回 false(uk_group_user 已存在、回调重入),
+     * 说明本笔订单之前已被处理过一次,paid_num 已加入团,这里绝不能再 +1,不然会虚高。</p>
+     */
+    private void finishPaidOrderInGroup(FsStoreOrderScrm order) {
+        Long groupId = order.getGroupBuyId();
+        FsStoreProductGroupBuy group = groupBuyMapper.selectFsStoreProductGroupBuyById(groupId);
+        if (group == null) {
+            log.error("[团购回调] 团已不存在,orderId={}, groupId={}", order.getId(), groupId);
+            return;
+        }
+        // 极端并发兜底:回调到达时团已被定时任务判失败(status=2)——不写 item、不推 ERP,
+        // 订单照常完成支付流程,留给定时任务的孤儿订单扫描兜底退款
+        if (Integer.valueOf(2).equals(group.getStatus())) {
+            log.warn("[团购回调] 订单回调到达时团已判失败,等定时任务兜底退款,orderId={}, groupId={}",
+                    order.getId(), groupId);
+            return;
+        }
+        FsStoreProductActivity activity = activityMapper.selectFsStoreProductActivityById(order.getAssociatedId());
+        boolean freshInsert = insertItemSafely(group, order, activity);
+        if (!freshInsert) {
+            // 回调重入(上游 payConfirm 锁理论上拦的住,这里是额外防御):之前已成功推过 paid_num,
+            // 直接返回不能再 +1,避免 paid_num 虚高误判成团
+            log.info("[团购回调] 重入,item 已存在且 paid_num 已加过,skip,orderId={}", order.getId());
+            return;
+        }
+
+        // 原子 CAS 推进 paid_num + 满员置 status=1,避免多线程同时付款时丢成团
+        int affected = groupBuyMapper.tryMarkPaidAndComplete(groupId);
+        if (affected == 0) {
+            // 走到这里说明团已经不是进行中(被定时任务或其他路径置成成功/失败)——再查一次
+            FsStoreProductGroupBuy latest = groupBuyMapper.selectFsStoreProductGroupBuyById(groupId);
+            if (latest != null && Integer.valueOf(2).equals(latest.getStatus())) {
+                log.warn("[团购回调] 团推进付款数失败,团已失败,等定时任务兜底,orderId={}", order.getId());
+            }
+            return;
+        }
+        // 推进成功:重新查最新状态,若此单是满员的最后一笔,latest.status=1 才触发成团钩子
+        FsStoreProductGroupBuy latest = groupBuyMapper.selectFsStoreProductGroupBuyById(groupId);
+        if (latest != null && Integer.valueOf(1).equals(latest.getStatus())) {
+            onGroupComplete(latest);
+        }
+    }
+
+    /**
+     * 兜底分支:支付回调到了但订单没绑团,按旧逻辑动态匹配一次
+     */
+    private void fallbackMatchGroupOnPay(FsStoreOrderScrm order) {
+        if (order.getAssociatedId() == null) {
+            return;
+        }
+        FsStoreProductActivity activity = activityMapper.selectFsStoreProductActivityById(order.getAssociatedId());
+        if (activity == null || activity.getGroupNum() == null || activity.getGroupNum() <= 0) {
+            return;
+        }
+        if (activity.getEndTime() != null && activity.getEndTime().before(new Date())) {
+            log.warn("[团购回调-兜底] 活动已结束,等待兜底退款,orderId={}", order.getId());
+            return;
+        }
+
+        for (int i = 0; i < DEFAULT_RESERVE_RETRY; i++) {
+            FsStoreProductGroupBuy target = groupBuyMapper.selectJoinableGroup(activity.getId(), order.getUserId());
+            if (target == null) {
+                break;
+            }
+            if (groupBuyMapper.tryJoinGroup(target.getId()) == 1) {
+                attachOrderToGroup(order, target.getId());
+                boolean freshInsert = insertItemSafely(target, order, activity);
+                // 兼容路径也走原子推进 paid_num,满员才触发成团;若 item 已存在(重入)跳过 +1 避免虚高
+                if (freshInsert && groupBuyMapper.tryMarkPaidAndComplete(target.getId()) == 1) {
+                    FsStoreProductGroupBuy after = groupBuyMapper.selectFsStoreProductGroupBuyById(target.getId());
+                    if (after != null && Integer.valueOf(1).equals(after.getStatus())) {
+                        onGroupComplete(after);
+                    }
+                }
+                return;
+            }
+        }
+
+        FsStoreProductGroupBuy created = createNewGroup(activity, order.getUserId());
+        attachOrderToGroup(order, created.getId());
+        boolean freshInsert = insertItemSafely(created, order, activity);
+        // 新开团的第一笔付款也按正常路径推进;重入时 item 已存在不再 +1 paid_num
+        if (freshInsert && groupBuyMapper.tryMarkPaidAndComplete(created.getId()) == 1) {
+            FsStoreProductGroupBuy after = groupBuyMapper.selectFsStoreProductGroupBuyById(created.getId());
+            if (after != null && Integer.valueOf(1).equals(after.getStatus())) {
+                onGroupComplete(after);
+            }
+        }
+    }
+
+    // ================================================================
+    // 3. 成团钩子
+    // ================================================================
+
+    /**
+     * 成团后:把团里所有待推 ERP 的订单统一推送出去
+     * <p><b>关键</b>:必须在当前事务 <b>提交之后</b> 再推 ERP。</p>
+     * <p>原因:{@code createOmsOrder} 自带 {@code @Transactional},ERP 外部接口超时/异常会把当前事务
+     * 标记为 rollback-only。即便我们 try/catch 吞掉了异常,事务提交时依然会抛
+     * {@code UnexpectedRollbackException},导致 {@code tryMarkPaidAndComplete} 的成团标记(status=1)
+     * 和 paid_num+1、item 插入被连锁回滚——用户钱收了、团却没成团,数据就错乱了。</p>
+     * <p>所以这里用 afterCommit 钩子延后推 ERP;单个订单推失败不影响其他订单;
+     * 漏推的由原有 {@code PushErp} 定时任务兜底,业务最终一致。</p>
+     */
+    private void onGroupComplete(FsStoreProductGroupBuy group) {
+        log.info("[团购成团] groupId={}, groupNo={}, joinNum={}, groupNum={}",
+                group.getId(), group.getGroupNo(), group.getJoinNum(), group.getGroupNum());
+        final Long groupId = group.getId();
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            // 事务提交后再推 ERP,ERP 的任何异常都不会影响本事务落库的成团状态
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    pushErpForGroupOrders(groupId);
+                }
+            });
+        } else {
+            // 兜底:理论上 handleAfterPay 一定带事务,这里走到只可能是调用链被人改过,直接同步推
+            log.warn("[团购成团] 当前无事务同步上下文,降级同步推 ERP,groupId={}", groupId);
+            pushErpForGroupOrders(groupId);
+        }
+        // TODO: 后续接入发货 / 模板消息推送(同样建议放在 afterCommit 里)
+    }
+
+    /**
+     * 实际执行团内订单 ERP 推送:单个失败不影响其他;漏推的由原有定时任务 PushErp 兜底。
+     */
+    private void pushErpForGroupOrders(Long groupId) {
+        try {
+            List<Long> orderIds = fsStoreOrderMapper.selectOrderIdsByGroupBuyId(groupId);
+            if (orderIds == null || orderIds.isEmpty()) {
+                return;
+            }
+            for (Long orderId : orderIds) {
+                try {
+                    fsStoreOrderService.createOmsOrder(orderId);
+                } catch (Exception e) {
+                    log.error("[成团推ERP失败] orderId={}, groupId={}", orderId, groupId, e);
+                }
+            }
+        } catch (Exception e) {
+            log.error("[成团钩子异常] groupId={}", groupId, e);
+        }
+    }
+
+    // ================================================================
+    // 4. 工具方法
+    // ================================================================
+
+    /**
+     * 新建一个团,初始 join_num=1(已占位)、paid_num=0(还没付款)、status=0。
+     * 即使 groupNum=1 的极端配置也不在这里提前置成团,统一由支付回调 tryMarkPaidAndComplete 标成
+     */
+    private FsStoreProductGroupBuy createNewGroup(FsStoreProductActivity activity, Long userId) {
+        FsStoreProductGroupBuy g = new FsStoreProductGroupBuy();
+        g.setGroupNo(generateGroupNo());
+        g.setActivityId(activity.getId());
+        g.setProductId(activity.getProductId());
+        g.setGroupNum(activity.getGroupNum());
+        g.setJoinNum(1);
+        g.setPaidNum(0);
+        g.setStatus(0);
+        g.setStartTime(new Date());
+        g.setEndTime(activity.getEndTime());
+        g.setDelFlag(0);
+        g.setCreateBy(userId == null ? null : String.valueOf(userId));
+        g.setCreateTime(new Date());
+
+        groupBuyMapper.insertFsStoreProductGroupBuy(g);
+        return g;
+    }
+
+    /**
+     * 回写订单 group_buy_id(主路径下下单就写好了,这里只给兜底分支用)
+     */
+    private void attachOrderToGroup(FsStoreOrderScrm order, Long groupId) {
+        FsStoreOrderScrm up = new FsStoreOrderScrm();
+        up.setId(order.getId());
+        up.setGroupBuyId(groupId);
+        fsStoreOrderMapper.updateFsStoreOrder(up);
+        order.setGroupBuyId(groupId);
+    }
+
+    /**
+     * 插入参与详情,依靠唯一键 uk_group_user 保证幂等
+     * @return true = 真新插入(第一次处理);false = 重入(本用户在本团的 item 已存在)
+     */
+    private boolean insertItemSafely(FsStoreProductGroupBuy group,
+                                     FsStoreOrderScrm order,
+                                     FsStoreProductActivity activity) {
+        FsStoreProductGroupBuyItem item = new FsStoreProductGroupBuyItem();
+        item.setGroupId(group.getId());
+        item.setProductId(group.getProductId());
+        if (activity != null) {
+            item.setSpecId(activity.getSpecId());
+            item.setActivityId(activity.getId());
+        }
+        item.setUserId(order.getUserId());
+
+        try {
+            FsUserScrm user = userService.selectFsUserById(order.getUserId());
+            if (user != null) {
+                item.setNickName(user.getNickName());
+                item.setAvatar(user.getAvatar());
+            }
+        } catch (Exception e) {
+            log.warn("[团购] 查询用户信息失败,跳过昵称头像,userId={}", order.getUserId(), e);
+        }
+
+        item.setPayStatus(1);
+        item.setPayTime(order.getPayTime() == null ? new Date() : order.getPayTime());
+        item.setJoinTime(new Date());
+        item.setDelFlag(0);
+        item.setCreateBy(order.getUserId() == null ? null : String.valueOf(order.getUserId()));
+        item.setCreateTime(new Date());
+        try {
+            groupBuyItemMapper.insertFsStoreProductGroupBuyItem(item);
+            return true;
+        } catch (DuplicateKeyException e) {
+            // 回调重入,item 已存在,视为已处理
+            log.info("[团购] item 已存在,跳过重复插入,groupId={}, userId={}", group.getId(), order.getUserId());
+            return false;
+        }
+    }
+
+    /**
+     * 团号:G + 13位时间戳 + 6位随机串,便于业务侧识别
+     */
+    private String generateGroupNo() {
+        String ts = String.valueOf(System.currentTimeMillis());
+        String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 6).toUpperCase();
+        return "G" + ts + suffix;
+    }
+
+    // ==================== 后台查询:列表 / 详情 ====================
+
+    @Override
+    public List<FsStoreGroupBuyListVO> selectGroupBuyListForAdmin(String groupNo,
+                                                                   Long productId,
+                                                                   String productName,
+                                                                   Integer status,
+                                                                   String beginTime,
+                                                                   String endTime) {
+        List<FsStoreGroupBuyListVO> list = groupBuyMapper.selectGroupBuyListForAdmin(
+                null, groupNo, productId, productName, status, beginTime, endTime);
+        fillStatusText(list);
+        return list;
+    }
+
+    @Override
+    public List<FsStoreGroupBuyListVO> selectGroupBuyListByProduct(Long productId) {
+        if (productId == null) {
+            return java.util.Collections.emptyList();
+        }
+        List<FsStoreGroupBuyListVO> list = groupBuyMapper.selectGroupBuyListForAdmin(
+                null, null, productId, null, null, null, null);
+        fillStatusText(list);
+        return list;
+    }
+
+    @Override
+    public FsStoreGroupBuyListVO selectGroupBuyDetailForAdmin(Long id) {
+        if (id == null) {
+            return null;
+        }
+        List<FsStoreGroupBuyListVO> list = groupBuyMapper.selectGroupBuyListForAdmin(
+                id, null, null, null, null, null, null);
+        if (list == null || list.isEmpty()) {
+            return null;
+        }
+        FsStoreGroupBuyListVO vo = list.get(0);
+        fillStatusText(java.util.Collections.singletonList(vo));
+        // 拉团员,包含各自的订单状态
+        List<FsStoreGroupBuyMemberVO> members = groupBuyMapper.selectGroupBuyMembers(id);
+        vo.setMembers(members != null ? members : java.util.Collections.emptyList());
+        return vo;
+    }
+
+    /** 给列表/详情填上状态文本,前端少写 if-else */
+    private void fillStatusText(List<FsStoreGroupBuyListVO> list) {
+        if (list == null) return;
+        for (FsStoreGroupBuyListVO vo : list) {
+            if (vo.getStatus() == null) {
+                vo.setStatusText("");
+                continue;
+            }
+            switch (vo.getStatus()) {
+                case 0: vo.setStatusText("进行中"); break;
+                case 1: vo.setStatusText("拼团成功"); break;
+                case 2: vo.setStatusText("拼团失败"); break;
+                default: vo.setStatusText("未知");
+            }
+        }
+    }
+}

+ 75 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreGroupBuyListVO.java

@@ -0,0 +1,75 @@
+package com.fs.hisStore.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 团购后台列表/详情 VO
+ * <p>同时服务于列表页(不填 members)和详情页(填 members)。</p>
+ */
+@Data
+public class FsStoreGroupBuyListVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 团购主表ID */
+    private Long id;
+
+    /** 团号 */
+    private String groupNo;
+
+    /** 活动ID */
+    private Long activityId;
+
+    /** 商品ID */
+    private Long productId;
+
+    /** 商品名 */
+    private String productName;
+
+    /** 商品图 */
+    private String productImage;
+
+    /** 活动价(团购价,冗余展示用) */
+    private BigDecimal groupPrice;
+
+    /** 活动原价 */
+    private BigDecimal originalPrice;
+
+    /** 满团人数 */
+    private Integer groupNum;
+
+    /** 已参团人数(含未付款) */
+    private Integer joinNum;
+
+    /** 已付款人数(成团判定依据) */
+    private Integer paidNum;
+
+    /** 团状态 0=进行中 1=拼团完成 2=拼团失败 */
+    private Integer status;
+
+    /** 团状态文本(进行中/拼团完成/拼团失败) */
+    private String statusText;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date startTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date endTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date completeTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+
+    private String remark;
+
+    /** 团员列表(仅详情接口填充) */
+    private List<FsStoreGroupBuyMemberVO> members;
+}

+ 75 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreGroupBuyMemberVO.java

@@ -0,0 +1,75 @@
+package com.fs.hisStore.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/**
+ * 团购后台详情页 - 团员行 VO。
+ * 关联 fs_store_product_group_buy_item + fs_store_order_scrm,
+ * 把"团员维度"和"订单维度"的关键状态打平在一起返回,方便后台一屏查清楚。
+ */
+@Data
+public class FsStoreGroupBuyMemberVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    // ===== item 侧字段 =====
+    /** item 主键 */
+    private Long itemId;
+
+    /** 用户ID */
+    private Long userId;
+
+    /** 昵称 */
+    private String nickName;
+
+    /** 头像 */
+    private String avatar;
+
+    /** 选中的规格ID */
+    private Long specId;
+
+    /** 规格名称(sku) */
+    private String specName;
+
+    /** 团员支付状态 0=未支付 1=已支付 2=已退款 */
+    private Integer payStatus;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date payTime;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date joinTime;
+
+    // ===== order 侧字段(fs_store_order_scrm)=====
+    /** 订单ID */
+    private Long orderId;
+
+    /** 订单号 */
+    private String orderCode;
+
+    /**
+     * 订单状态(参考 fs_store_order_scrm.status):
+     * 1=待发货 2=待收货 3=已完成 -1=已取消 -2=已退款 等
+     */
+    private Integer orderStatus;
+
+    /** 退款状态 0/null=未退款 1=申请中 2=已退款 */
+    private Integer refundStatus;
+
+    /** 实付金额 */
+    private BigDecimal payMoney;
+
+    /** 退款金额 */
+    private BigDecimal refundPrice;
+
+    /** 外部订单号(推送ERP后才有值,可用来判断是否已推送) */
+    private String extendOrderId;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    private Date orderCreateTime;
+}

+ 13 - 0
fs-service/src/main/java/com/fs/hisStore/vo/FsStoreProductListVO.java

@@ -5,6 +5,7 @@ import lombok.Data;
 
 import java.io.Serializable;
 import java.math.BigDecimal;
+import java.util.Date;
 @Data
 /**
  * 商品对象 fs_store_product
@@ -115,4 +116,16 @@ public class FsStoreProductListVO  implements Serializable
 
     private String storeId;
     private String storeName;
+
+    /**
+     * 活动类型(0=无活动,6=限时秒杀,7=限时折扣,8=限时团购)
+     * <p>同步于 fs_store_product_scrm.activity_type,给前端列表页判断是否活动中、控制修改/删除按钮是否可见。</p>
+     */
+    private Integer activityType;
+
+    /** 活动开始时间(活动创建时写入商品表,活动进行期间不允许修改商品/删商品) */
+    private Date activityStartTime;
+
+    /** 活动结束时间 */
+    private Date activityEndTime;
 }

+ 7 - 1
fs-service/src/main/resources/application-dev.yml

@@ -51,7 +51,13 @@ spring:
                     password: Rtroot
                 # 主库数据源
                 wx:
-                    url: jdbc:mysql://139.186.77.83:3306/wechat_mmtls?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
+                    url: jdbc:mysql://139.186.77.83:3306/ylrz_his_scrm?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
+                    username: Rtroot
+                    password: Rtroot
+                slave:
+                    enabled: true
+                    url: jdbc:mysql://139.186.77.83:3306/ylrz_his_scrm?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
+                    #                    url: jdbc:mysql://139.186.77.83:3306/ylrz_his_scrm_hetai?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8&allowMultiQueries=true
                     username: Rtroot
                     password: Rtroot
                 # 初始连接数

+ 79 - 0
fs-service/src/main/resources/db/20260429-限时团购.sql

@@ -0,0 +1,79 @@
+-- =============================================================
+-- 限时团购(activity_type=8)相关表结构
+-- 依赖:fs_store_product_activity 已新增 group_num 字段
+-- =============================================================
+
+-- 商品团购主表(一个团一条记录)
+drop table if exists `fs_store_product_group_buy`;
+create table `fs_store_product_group_buy` (
+    `id`              bigint       not null auto_increment    comment '主键ID',
+    `group_no`        varchar(32)                             comment '团号(唯一,便于分享/查询)',
+    `activity_id`     bigint                                  comment '活动ID(fs_store_product_activity.id,团长所属活动规格)',
+    `product_id`      bigint       not null                   comment '商品ID',
+    `group_num`       int          not null                   comment '满团人数(冗余 fs_store_product_activity.group_num)',
+    `join_num`        int          not null default 0         comment '当前已参团人数',
+    `status`          tinyint      not null default 0         comment '团状态:0=进行中 1=拼团完成 2=拼团失败(超时未满员)',
+    `start_time`      datetime                                comment '开团时间',
+    `end_time`        datetime                                comment '截团时间(活动结束时间)',
+    `complete_time`   datetime                                comment '拼团完成时间',
+    `del_flag`        tinyint(1)            default 0         comment '删除标志:0=正常 1=删除',
+    `create_by`       varchar(64)                             comment '创建者',
+    `create_time`     datetime                                comment '创建时间',
+    `update_by`       varchar(64)                             comment '更新者',
+    `update_time`     datetime                                comment '更新时间',
+    `remark`          varchar(255)                            comment '备注',
+    primary key (`id`) using btree,
+    unique key uk_group_no (`group_no`),
+    key idx_product (`product_id`),
+    key idx_status (`status`),
+    key idx_activity (`activity_id`),
+    key idx_create (`create_time`)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_general_ci
+  comment '商品限时团购主表';
+
+
+-- 商品团购参与详情表(每位参团用户一条记录)
+drop table if exists `fs_store_product_group_buy_item`;
+create table `fs_store_product_group_buy_item` (
+    `id`              bigint       not null auto_increment    comment '主键ID',
+    `group_id`        bigint       not null                   comment '团购主表ID(fs_store_product_group_buy.id)',
+    `product_id`      bigint       not null                   comment '商品ID',
+    `spec_id`         bigint                                  comment '商品规格ID(fs_store_product_attr_value_scrm.id)',
+    `activity_id`     bigint                                  comment '活动ID(fs_store_product_activity.id)',
+    `user_id`         bigint       not null                   comment '用户ID',
+    `nick_name`       varchar(100)                            comment '用户昵称',
+    `avatar`          varchar(500)                            comment '用户头像',
+    `pay_status`      tinyint               default 0         comment '支付状态:0=未支付 1=已支付 2=已退款',
+    `pay_time`        datetime                                comment '支付时间',
+    `join_time`       datetime                                comment '参团时间',
+    `del_flag`        tinyint(1)            default 0         comment '删除标志:0=正常 1=删除',
+    `create_by`       varchar(64)                             comment '创建者',
+    `create_time`     datetime                                comment '创建时间',
+    `update_by`       varchar(64)                             comment '更新者',
+    `update_time`     datetime                                comment '更新时间',
+    primary key (`id`) using btree,
+    unique key uk_group_user (`group_id`, `user_id`),
+    key idx_group (`group_id`),
+    key idx_user (`user_id`),
+    key idx_product (`product_id`),
+    key idx_create (`create_time`)
+) engine = InnoDB
+  default charset = utf8mb4
+  collate = utf8mb4_general_ci
+  comment '商品限时团购参与详情表';
+
+
+-- 兼容前一步已添加过的 group_num(若未添加可放开执行)
+-- alter table `fs_store_product_activity`
+--     add column `group_num` int null comment '团购人数(activity_type=8时:满足该人数后开团发货)' after `discount_price`;
+
+-- 订单表关联团购ID(拼团订单使用)
+alter table `fs_store_order_scrm`
+    add column `group_buy_id` bigint null comment '团购ID(fs_store_product_group_buy.id,限时团购订单关联拼团)' after `virtual_phone`,
+    add index `idx_group_buy_id` (`group_buy_id`);
+
+-- 活动中间表新增团购价字段(activity_type=8 时必填,下单按这个价计算)
+alter table `fs_store_product_activity`
+    add column `group_price` decimal(10,2) null comment '团购价(activity_type=8时必填)' after `group_num`;

+ 5 - 1
fs-service/src/main/resources/mapper/hisStore/FsStoreOrderScrmMapper.xml

@@ -92,10 +92,11 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         <!--<result property="orderMedium"    column="order_medium"    />-->
         <result property="backendEditProductType"    column="backend_edit_product_type"    />
         <result property="virtualPhone"    column="virtual_phone"    />
+        <result property="groupBuyId"    column="group_buy_id"    />
     </resultMap>
 
     <sql id="selectFsStoreOrderVo">
-        select id, order_code,outer_oi_id,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,backend_edit_product_type,video_id,course_id,project_id,period_id,virtual_phone from fs_store_order_scrm
+        select id, order_code,outer_oi_id,service_fee, extend_order_id,pay_order_id,bank_order_id, user_id,order_visit, real_name, user_phone, user_address, cart_id, freight_price, total_num, total_price, total_postage, pay_price, pay_postage,pay_delivery,pay_money, deduction_price, coupon_id, coupon_price, paid, pay_time, pay_type, create_time, update_time, status, refund_status, refund_reason_wap_img, refund_reason_wap_explain, refund_reason_time, refund_reason_wap, refund_reason, refund_price, delivery_sn, delivery_name, delivery_type, delivery_id, gain_integral, use_integral, pay_integral, back_integral, mark, is_del, remark, cost, verify_code, store_id, shipping_type, is_channel, is_remind, is_sys_del,is_prescribe,prescribe_id ,company_id,company_user_id,is_package,package_json,item_json,order_type,package_id,finish_time,delivery_status,delivery_pay_status,delivery_time,delivery_pay_time,delivery_pay_money,tui_money,tui_money_status,delivery_import_time,tui_user_id,tui_user_money_status,order_create_type,store_house_code,dept_id,is_edit_money,customer_id,is_pay_remain,delivery_send_time,certificates,schedule_id,backend_edit_product_type,video_id,course_id,project_id,period_id,virtual_phone,group_buy_id from fs_store_order_scrm
     </sql>
 
     <select id="selectFsStoreOrderList" parameterType="FsStoreOrderScrm" resultMap="FsStoreOrderResult">
@@ -277,6 +278,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="courseId != null" >course_id,</if>
             <if test="projectId != null" >project_id,</if>
             <if test="periodId != null" >period_id,</if>
+            <if test="groupBuyId != null" >group_buy_id,</if>
          </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="orderCode != null and orderCode != ''">#{orderCode},</if>
@@ -368,6 +370,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="courseId != null" >#{courseId},</if>
             <if test="projectId != null" >#{projectId},</if>
             <if test="periodId != null" >#{periodId},</if>
+            <if test="groupBuyId != null" >#{groupBuyId},</if>
          </trim>
     </insert>
 
@@ -465,6 +468,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="videoId != null">video_id = #{videoId},</if>
             <if test="courseId != null">course_id = #{courseId},</if>
             <if test="virtualPhone != null">virtual_phone = #{virtualPhone},</if>
+            <if test="groupBuyId != null">group_buy_id = #{groupBuyId},</if>
         </trim>
         where id = #{id}
     </update>

+ 33 - 2
fs-service/src/main/resources/mapper/hisStore/FsStoreProductActivityMapper.xml

@@ -11,6 +11,8 @@
         <result property="flashPrice" column="flash_price"/>
         <result property="discount" column="discount"/>
         <result property="discountPrice" column="discount_price"/>
+        <result property="groupNum" column="group_num"/>
+        <result property="groupPrice" column="group_price"/>
         <result property="startTime" column="start_time"/>
         <result property="endTime" column="end_time"/>
         <result property="status" column="status"/>
@@ -45,6 +47,8 @@
                a.flash_price,
                a.discount,
                a.discount_price,
+               a.group_num,
+               a.group_price,
                a.start_time,
                a.end_time,
                a.status,
@@ -149,6 +153,8 @@
             <if test="flashPrice != null">flash_price,</if>
             <if test="discount != null">discount,</if>
             <if test="discountPrice != null">discount_price,</if>
+            <if test="groupNum != null">group_num,</if>
+            <if test="groupPrice != null">group_price,</if>
             <if test="startTime != null">start_time,</if>
             <if test="endTime != null">end_time,</if>
             <if test="status != null">status,</if>
@@ -167,6 +173,8 @@
             <if test="flashPrice != null">#{flashPrice},</if>
             <if test="discount != null">#{discount},</if>
             <if test="discountPrice != null">#{discountPrice},</if>
+            <if test="groupNum != null">#{groupNum},</if>
+            <if test="groupPrice != null">#{groupPrice},</if>
             <if test="startTime != null">#{startTime},</if>
             <if test="endTime != null">#{endTime},</if>
             <if test="status != null">#{status},</if>
@@ -181,12 +189,12 @@
 
     <insert id="batchInsertActivity" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="id">
         insert into fs_store_product_activity
-        (product_id, activity_type, spec_id, original_price, flash_price, discount, discount_price, start_time,
+        (product_id, activity_type, spec_id, original_price, flash_price, discount, discount_price, group_num, group_price, start_time,
         end_time, status, create_time)
         values
         <foreach collection="list" item="item" separator=",">
             (#{item.productId}, #{item.activityType}, #{item.specId}, #{item.originalPrice},
-            #{item.flashPrice}, #{item.discount}, #{item.discountPrice},
+            #{item.flashPrice}, #{item.discount}, #{item.discountPrice}, #{item.groupNum}, #{item.groupPrice},
             #{item.startTime}, #{item.endTime}, #{item.status}, now())
         </foreach>
     </insert>
@@ -201,6 +209,8 @@
             <if test="flashPrice != null">flash_price = #{flashPrice},</if>
             <if test="discount != null">discount = #{discount},</if>
             <if test="discountPrice != null">discount_price = #{discountPrice},</if>
+            <if test="groupNum != null">group_num = #{groupNum},</if>
+            <if test="groupPrice != null">group_price = #{groupPrice},</if>
             <if test="startTime != null">start_time = #{startTime},</if>
             <if test="endTime != null">end_time = #{endTime},</if>
             <if test="status != null">status = #{status},</if>
@@ -308,4 +318,25 @@
           and a.del_flag = 0
           and a.spec_id = #{specId}
     </select>
+
+    <!-- 查询团购活动列表(1小时内即将开抢+未过期,activity_type=8) -->
+    <select id="selectUpcomingGroupBuyActivityList" resultMap="FsStoreProductActivityResult">
+        <include refid="selectActivityVo"/>
+        where a.del_flag = 0
+        and a.activity_type = 8
+        and a.start_time &lt;= date_add(now(), interval 1 hour)
+        and a.end_time &gt; now()
+        and p.is_show = 1
+        and p.is_del = 0
+        order by a.start_time asc
+    </select>
+
+    <!-- 按商品ID查询当前进行中的团购活动 -->
+    <select id="selectGroupBuyActivityByProductId" parameterType="Long" resultMap="FsStoreProductActivityResult">
+        <include refid="selectActivityVo"/>
+        where a.product_id = #{productId}
+        and a.del_flag = 0
+        and a.activity_type = 8
+        and now() between a.start_time and a.end_time
+    </select>
 </mapper>

+ 82 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreProductGroupBuyItemMapper.xml

@@ -0,0 +1,82 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.hisStore.mapper.FsStoreProductGroupBuyItemMapper">
+
+    <resultMap type="com.fs.hisStore.domain.FsStoreProductGroupBuyItem" id="FsStoreProductGroupBuyItemResult">
+        <id     property="id"         column="id"          />
+        <result property="groupId"    column="group_id"    />
+        <result property="productId"  column="product_id"  />
+        <result property="specId"     column="spec_id"     />
+        <result property="activityId" column="activity_id" />
+        <result property="userId"     column="user_id"     />
+        <result property="nickName"   column="nick_name"   />
+        <result property="avatar"     column="avatar"      />
+        <result property="payStatus"  column="pay_status"  />
+        <result property="payTime"    column="pay_time"    />
+        <result property="joinTime"   column="join_time"   />
+        <result property="delFlag"    column="del_flag"    />
+        <result property="createBy"   column="create_by"   />
+        <result property="createTime" column="create_time" />
+        <result property="updateBy"   column="update_by"   />
+        <result property="updateTime" column="update_time" />
+    </resultMap>
+
+    <sql id="selectGroupBuyItemVo">
+        select id, group_id, product_id, spec_id, activity_id, user_id,
+               nick_name, avatar, pay_status, pay_time, join_time, del_flag,
+               create_by, create_time, update_by, update_time
+        from fs_store_product_group_buy_item
+    </sql>
+
+    <insert id="insertFsStoreProductGroupBuyItem" parameterType="com.fs.hisStore.domain.FsStoreProductGroupBuyItem" useGeneratedKeys="true" keyProperty="id">
+        insert into fs_store_product_group_buy_item
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="groupId != null">group_id,</if>
+            <if test="productId != null">product_id,</if>
+            <if test="specId != null">spec_id,</if>
+            <if test="activityId != null">activity_id,</if>
+            <if test="userId != null">user_id,</if>
+            <if test="nickName != null">nick_name,</if>
+            <if test="avatar != null">avatar,</if>
+            <if test="payStatus != null">pay_status,</if>
+            <if test="payTime != null">pay_time,</if>
+            <if test="joinTime != null">join_time,</if>
+            <if test="delFlag != null">del_flag,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="updateTime != null">update_time,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="groupId != null">#{groupId},</if>
+            <if test="productId != null">#{productId},</if>
+            <if test="specId != null">#{specId},</if>
+            <if test="activityId != null">#{activityId},</if>
+            <if test="userId != null">#{userId},</if>
+            <if test="nickName != null">#{nickName},</if>
+            <if test="avatar != null">#{avatar},</if>
+            <if test="payStatus != null">#{payStatus},</if>
+            <if test="payTime != null">#{payTime},</if>
+            <if test="joinTime != null">#{joinTime},</if>
+            <if test="delFlag != null">#{delFlag},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+        </trim>
+    </insert>
+
+    <select id="selectByGroupId" resultMap="FsStoreProductGroupBuyItemResult">
+        <include refid="selectGroupBuyItemVo"/>
+        where group_id = #{groupId} and del_flag = 0
+        order by join_time asc, id asc
+    </select>
+
+    <select id="existsInGroup" resultType="Integer">
+        select count(1) from fs_store_product_group_buy_item
+        where group_id = #{groupId} and user_id = #{userId} and del_flag = 0
+    </select>
+
+</mapper>

+ 306 - 0
fs-service/src/main/resources/mapper/hisStore/FsStoreProductGroupBuyMapper.xml

@@ -0,0 +1,306 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.hisStore.mapper.FsStoreProductGroupBuyMapper">
+
+    <resultMap type="com.fs.hisStore.domain.FsStoreProductGroupBuy" id="FsStoreProductGroupBuyResult">
+        <id     property="id"           column="id"            />
+        <result property="groupNo"      column="group_no"      />
+        <result property="activityId"   column="activity_id"   />
+        <result property="productId"    column="product_id"    />
+        <result property="groupNum"     column="group_num"     />
+        <result property="joinNum"      column="join_num"      />
+        <result property="paidNum"      column="paid_num"      />
+        <result property="status"       column="status"        />
+        <result property="startTime"    column="start_time"    />
+        <result property="endTime"      column="end_time"      />
+        <result property="completeTime" column="complete_time" />
+        <result property="delFlag"      column="del_flag"      />
+        <result property="createBy"     column="create_by"     />
+        <result property="createTime"   column="create_time"   />
+        <result property="updateBy"     column="update_by"     />
+        <result property="updateTime"   column="update_time"   />
+        <result property="remark"       column="remark"        />
+    </resultMap>
+
+    <sql id="selectGroupBuyVo">
+        select id, group_no, activity_id, product_id, group_num, join_num, paid_num, status,
+               start_time, end_time, complete_time, del_flag,
+               create_by, create_time, update_by, update_time, remark
+        from fs_store_product_group_buy
+    </sql>
+
+    <select id="selectFsStoreProductGroupBuyById" parameterType="Long" resultMap="FsStoreProductGroupBuyResult">
+        <include refid="selectGroupBuyVo"/>
+        where id = #{id} and del_flag = 0
+    </select>
+
+    <select id="selectJoinableGroup" resultMap="FsStoreProductGroupBuyResult">
+        <include refid="selectGroupBuyVo"/>
+        where activity_id = #{activityId}
+          and status = 0
+          and del_flag = 0
+          and end_time &gt; now()
+          and join_num &lt; group_num
+          and not exists (
+              select 1 from fs_store_product_group_buy_item i
+              where i.group_id = fs_store_product_group_buy.id
+                and i.user_id = #{userId}
+                and i.del_flag = 0
+          )
+        order by (group_num - join_num) asc, create_time asc
+        limit 1
+    </select>
+
+    <insert id="insertFsStoreProductGroupBuy" parameterType="com.fs.hisStore.domain.FsStoreProductGroupBuy" useGeneratedKeys="true" keyProperty="id">
+        insert into fs_store_product_group_buy
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="groupNo != null">group_no,</if>
+            <if test="activityId != null">activity_id,</if>
+            <if test="productId != null">product_id,</if>
+            <if test="groupNum != null">group_num,</if>
+            <if test="joinNum != null">join_num,</if>
+            <if test="paidNum != null">paid_num,</if>
+            <if test="status != null">status,</if>
+            <if test="startTime != null">start_time,</if>
+            <if test="endTime != null">end_time,</if>
+            <if test="completeTime != null">complete_time,</if>
+            <if test="delFlag != null">del_flag,</if>
+            <if test="createBy != null">create_by,</if>
+            <if test="createTime != null">create_time,</if>
+            <if test="updateBy != null">update_by,</if>
+            <if test="updateTime != null">update_time,</if>
+            <if test="remark != null">remark,</if>
+        </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="groupNo != null">#{groupNo},</if>
+            <if test="activityId != null">#{activityId},</if>
+            <if test="productId != null">#{productId},</if>
+            <if test="groupNum != null">#{groupNum},</if>
+            <if test="joinNum != null">#{joinNum},</if>
+            <if test="paidNum != null">#{paidNum},</if>
+            <if test="status != null">#{status},</if>
+            <if test="startTime != null">#{startTime},</if>
+            <if test="endTime != null">#{endTime},</if>
+            <if test="completeTime != null">#{completeTime},</if>
+            <if test="delFlag != null">#{delFlag},</if>
+            <if test="createBy != null">#{createBy},</if>
+            <if test="createTime != null">#{createTime},</if>
+            <if test="updateBy != null">#{updateBy},</if>
+            <if test="updateTime != null">#{updateTime},</if>
+            <if test="remark != null">#{remark},</if>
+        </trim>
+    </insert>
+
+    <update id="updateFsStoreProductGroupBuy" parameterType="com.fs.hisStore.domain.FsStoreProductGroupBuy">
+        update fs_store_product_group_buy
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="groupNo != null">group_no = #{groupNo},</if>
+            <if test="activityId != null">activity_id = #{activityId},</if>
+            <if test="productId != null">product_id = #{productId},</if>
+            <if test="groupNum != null">group_num = #{groupNum},</if>
+            <if test="joinNum != null">join_num = #{joinNum},</if>
+            <if test="paidNum != null">paid_num = #{paidNum},</if>
+            <if test="status != null">status = #{status},</if>
+            <if test="startTime != null">start_time = #{startTime},</if>
+            <if test="endTime != null">end_time = #{endTime},</if>
+            <if test="completeTime != null">complete_time = #{completeTime},</if>
+            <if test="delFlag != null">del_flag = #{delFlag},</if>
+            <if test="updateBy != null">update_by = #{updateBy},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+            <if test="remark != null">remark = #{remark},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <!-- 原子抢名额:仅在进行中、未过期、未满员时 +1。
+         备注:成团的标记不在预占阶段做,改由支付回调 tryMarkPaidAndComplete 按实际付款人数判定,
+         避免“占位即成团”导致未全员付款的团被当成成团发货 -->
+    <update id="tryJoinGroup">
+        update fs_store_product_group_buy
+        set join_num    = join_num + 1,
+            update_time = now()
+        where id = #{groupId}
+          and status = 0
+          and del_flag = 0
+          and end_time &gt; now()
+          and join_num &lt; group_num
+    </update>
+
+    <!-- 原子推进付款数 + 满员置成团:仅 status=0 时 paid_num+1,
+         按新的 paid_num 判定是否满员 -> 满员同步置 status=1 + complete_time,
+         非满员保持 status=0 等后续付款继续推进。
+         返回行数:1=正常推进(0=团已被别的途径标成成功/失败,跳过) -->
+    <update id="tryMarkPaidAndComplete">
+        update fs_store_product_group_buy
+        set paid_num      = paid_num + 1,
+            status        = case when paid_num + 1 &gt;= group_num then 1 else 0 end,
+            complete_time = case when paid_num + 1 &gt;= group_num then now() else complete_time end,
+            update_time   = now()
+        where id = #{groupId}
+          and status = 0
+          and del_flag = 0
+    </update>
+
+    <!-- 超时取消时释放名额:仅 status=0 且 join_num>0 的团回退 1,已成团的不动 -->
+    <update id="releaseJoin">
+        update fs_store_product_group_buy
+        set join_num    = join_num - 1,
+            update_time = now()
+        where id = #{groupId}
+          and status = 0
+          and del_flag = 0
+          and join_num &gt; 0
+    </update>
+
+    <!-- 已付款订单退款时释放:同时退 join_num 和 paid_num,避免 paid_num 虚高导致后来的团员被误判成团。
+         仅在 status=0 且计数均>0 时生效,已成团的团(status=1)不动 -->
+    <update id="releasePaidAndJoin">
+        update fs_store_product_group_buy
+        set join_num    = join_num - 1,
+            paid_num    = paid_num - 1,
+            update_time = now()
+        where id = #{groupId}
+          and status = 0
+          and del_flag = 0
+          and join_num &gt; 0
+          and paid_num &gt; 0
+    </update>
+
+    <!-- 扫过期未成团的团:status=0 且截团时间已过 -->
+    <select id="selectExpiredUnformedGroups" resultMap="FsStoreProductGroupBuyResult">
+        <include refid="selectGroupBuyVo"/>
+        where status = 0
+          and del_flag = 0
+          and end_time &lt;= now()
+        order by end_time asc
+        limit #{limit}
+    </select>
+
+    <!-- 原子标团拼团失败:仅 status=0 且 end_time<=now() 时置 2 -->
+    <update id="markGroupFailed">
+        update fs_store_product_group_buy
+        set status      = 2,
+            update_time = now()
+        where id = #{groupId}
+          and status = 0
+          and del_flag = 0
+          and end_time &lt;= now()
+    </update>
+
+    <!-- 扫孤儿订单:团已判失败,但团内还有已支付且未退款、未推 ERP 的订单
+         这个查询是支付回调卡在 endTime 后到达等极端场景的兜底切入点 -->
+    <select id="selectOrphanOrderIdsInFailedGroups" resultType="java.lang.Long">
+        select o.id
+        from fs_store_order_scrm o
+        inner join fs_store_product_group_buy g on g.id = o.group_buy_id
+        where o.order_type = 8
+          and o.group_buy_id is not null
+          and o.status = 1
+          and (o.refund_status is null or o.refund_status = 0)
+          and o.extend_order_id is null
+          and g.status = 2
+          and g.del_flag = 0
+        order by o.id asc
+        limit #{limit}
+    </select>
+
+    <!-- ==================== 后台管理:列表 / 详情 ==================== -->
+
+    <resultMap id="GroupBuyListVOResult" type="com.fs.hisStore.vo.FsStoreGroupBuyListVO">
+        <id     property="id"            column="id"/>
+        <result property="groupNo"       column="group_no"/>
+        <result property="activityId"    column="activity_id"/>
+        <result property="productId"     column="product_id"/>
+        <result property="productName"   column="product_name"/>
+        <result property="productImage"  column="product_image"/>
+        <result property="groupPrice"    column="group_price"/>
+        <result property="originalPrice" column="original_price"/>
+        <result property="groupNum"      column="group_num"/>
+        <result property="joinNum"       column="join_num"/>
+        <result property="paidNum"       column="paid_num"/>
+        <result property="status"        column="status"/>
+        <result property="startTime"     column="start_time"/>
+        <result property="endTime"       column="end_time"/>
+        <result property="completeTime"  column="complete_time"/>
+        <result property="createTime"    column="create_time"/>
+        <result property="remark"        column="remark"/>
+    </resultMap>
+
+    <!-- 后台列表/详情头部:联商品与活动(团购价直接取 a.group_price,原价取 a.original_price) -->
+    <select id="selectGroupBuyListForAdmin" resultMap="GroupBuyListVOResult">
+        select g.id, g.group_no, g.activity_id, g.product_id,
+               g.group_num, g.join_num, g.paid_num, g.status,
+               g.start_time, g.end_time, g.complete_time,
+               g.create_time, g.remark,
+               p.product_name,
+               p.image              as product_image,
+               a.group_price,
+               a.original_price
+        from fs_store_product_group_buy g
+        left join fs_store_product_scrm p on g.product_id = p.product_id
+        left join fs_store_product_activity a on g.activity_id = a.id
+        <where>
+            g.del_flag = 0
+            <if test="id != null">and g.id = #{id}</if>
+            <if test="groupNo != null and groupNo != ''">and g.group_no = #{groupNo}</if>
+            <if test="productId != null">and g.product_id = #{productId}</if>
+            <if test="productName != null and productName != ''">and p.product_name like concat('%', #{productName}, '%')</if>
+            <if test="status != null">and g.status = #{status}</if>
+            <if test="beginTime != null and beginTime != ''">and g.create_time &gt;= #{beginTime}</if>
+            <if test="endTime != null and endTime != ''">and g.create_time &lt;= #{endTime}</if>
+        </where>
+        order by g.create_time desc
+    </select>
+
+    <!-- 团员详情:同时拉 item + order,团员维度和订单维度在同一行返回 -->
+    <resultMap id="GroupBuyMemberVOResult" type="com.fs.hisStore.vo.FsStoreGroupBuyMemberVO">
+        <id     property="itemId"          column="item_id"/>
+        <result property="userId"          column="user_id"/>
+        <result property="nickName"        column="nick_name"/>
+        <result property="avatar"          column="avatar"/>
+        <result property="specId"          column="spec_id"/>
+        <result property="specName"        column="spec_name"/>
+        <result property="payStatus"       column="pay_status"/>
+        <result property="payTime"         column="pay_time"/>
+        <result property="joinTime"        column="join_time"/>
+        <result property="orderId"         column="order_id"/>
+        <result property="orderCode"       column="order_code"/>
+        <result property="orderStatus"     column="order_status"/>
+        <result property="refundStatus"    column="refund_status"/>
+        <result property="payMoney"        column="pay_money"/>
+        <result property="refundPrice"     column="refund_price"/>
+        <result property="extendOrderId"   column="extend_order_id"/>
+        <result property="orderCreateTime" column="order_create_time"/>
+    </resultMap>
+
+    <select id="selectGroupBuyMembers" resultMap="GroupBuyMemberVOResult">
+        select i.id            as item_id,
+               i.user_id,
+               i.nick_name,
+               i.avatar,
+               i.spec_id,
+               v.sku           as spec_name,
+               i.pay_status,
+               i.pay_time,
+               i.join_time,
+               o.id            as order_id,
+               o.order_code,
+               o.status        as order_status,
+               o.refund_status,
+               o.pay_money,
+               o.refund_price,
+               o.extend_order_id,
+               o.create_time   as order_create_time
+        from fs_store_product_group_buy_item i
+        left join fs_store_product_attr_value_scrm v on i.spec_id = v.id
+        left join fs_store_order_scrm o on o.user_id = i.user_id
+                                       and o.group_buy_id = i.group_id
+                                       and o.order_type = 8
+        where i.group_id = #{groupId}
+          and i.del_flag = 0
+        order by i.create_time asc
+    </select>
+
+</mapper>

+ 209 - 0
fs-user-app/src/main/java/com/fs/app/controller/store/FsStoreProductGroupBuyScrmController.java

@@ -0,0 +1,209 @@
+package com.fs.app.controller.store;
+
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.domain.R;
+import com.fs.hisStore.domain.FsStoreProductActivity;
+import com.fs.hisStore.service.IFsStoreProductActivityService;
+import io.swagger.annotations.Api;
+import io.swagger.annotations.ApiOperation;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.math.BigDecimal;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * 小程序端 - 限时团购商品
+ * <p>不处理库存(团购按人数成团,无库存概念);
+ * 返回商品维度信息 + 参与团购的所有规格数组,交互方式和秒杀/折扣保持一致。</p>
+ *
+ * @author fs
+ * @date 2026-04-29
+ */
+@Slf4j
+@Api("小程序端-限时团购商品")
+@RestController
+@RequestMapping("/store/productGroupBuy")
+public class FsStoreProductGroupBuyScrmController extends BaseController {
+
+    @Autowired
+    private IFsStoreProductActivityService activityService;
+
+    /**
+     * 团购商品列表(1小时内即将开抢 + 未过期)
+     * 按商品聚合,每个商品只返回一条,价格取该商品最低团购价
+     */
+    @ApiOperation("团购商品列表(1小时内即将开抢+未过期)")
+    @GetMapping("/activeList")
+    public R activeList() {
+        List<FsStoreProductActivity> list = activityService.selectUpcomingGroupBuyActivityList();
+        if (list == null || list.isEmpty()) {
+            return R.ok().put("data", Collections.emptyList())
+                    .put("serverTimestamp", System.currentTimeMillis());
+        }
+
+        long now = System.currentTimeMillis();
+
+        // 按 productId 聚合,保序
+        Map<Long, List<FsStoreProductActivity>> grouped = list.stream()
+                .collect(Collectors.groupingBy(
+                        FsStoreProductActivity::getProductId,
+                        LinkedHashMap::new,
+                        Collectors.toList()));
+
+        List<Map<String, Object>> result = new ArrayList<>(grouped.size());
+        for (List<FsStoreProductActivity> specs : grouped.values()) {
+            FsStoreProductActivity first = specs.get(0);
+            Map<String, Object> item = buildProductLevelMap(first, now, specs);
+            result.add(item);
+        }
+        return R.ok().put("data", result).put("serverTimestamp", now);
+    }
+
+    /**
+     * 按活动ID查详情(含规格数组)。点击列表项进入详情页使用。
+     */
+    @ApiOperation("团购活动详情(含规格数组)")
+    @GetMapping("/detail/{id}")
+    public AjaxResult getDetail(@PathVariable("id") Long id) {
+        FsStoreProductActivity activity = activityService.selectActivityDetailById(id);
+        if (activity == null) {
+            return AjaxResult.error("团购商品不存在");
+        }
+
+        long now = System.currentTimeMillis();
+        List<FsStoreProductActivity> specs = activityService.selectActivitySpecsByProductIdAndType(
+                activity.getProductId(), 8);
+        if (specs == null || specs.isEmpty()) {
+            specs = Collections.singletonList(activity);
+        }
+
+        Map<String, Object> data = buildProductLevelMap(activity, now, specs);
+        // 详情页额外补充商品富文本/轮播等字段
+        data.put("productInfo", activity.getProductInfo());
+        data.put("sliderImage", activity.getSliderImage());
+        data.put("price", activity.getPrice());
+        data.put("otPrice", activity.getOtPrice());
+        data.put("sales", activity.getSales());
+        // 库存以参与团购规格的 fs_store_product_attr_value_scrm.stock 总和为准,不覆盖 buildProductLevelMap 里已设的值
+        // (原代码 data.put("productStock", activity.getProductStock()) 会覆盖成商品表 p.stock,会跟规格库存对不上,故移除)
+        data.put("cateName", activity.getCateName());
+        data.put("barCode", activity.getBarCode());
+        data.put("specs", buildSpecList(specs));
+        data.put("serverTimestamp", now);
+        return AjaxResult.success(data);
+    }
+
+    /**
+     * 按商品ID查团购信息(规格数组)。商品详情页从非团购入口切换到团购时使用。
+     */
+    @ApiOperation("按商品ID查团购信息(规格数组)")
+    @GetMapping("/getByProductId/{productId}")
+    public R getByProductId(@PathVariable("productId") Long productId) {
+        List<FsStoreProductActivity> specs = activityService.selectActivitySpecsByProductIdAndType(productId, 8);
+        if (specs == null || specs.isEmpty()) {
+            // 兜底:查进行中的团购活动
+            specs = activityService.selectGroupBuyActivityByProductId(productId);
+        }
+        if (specs == null || specs.isEmpty()) {
+            return R.ok().put("data", null).put("serverTimestamp", System.currentTimeMillis());
+        }
+
+        long now = System.currentTimeMillis();
+        FsStoreProductActivity first = specs.get(0);
+        Map<String, Object> data = buildProductLevelMap(first, now, specs);
+        data.put("specs", buildSpecList(specs));
+        return R.ok().put("data", data).put("serverTimestamp", now);
+    }
+
+    /**
+     * 服务器时间(前端倒计时对齐用)
+     */
+    @ApiOperation("获取服务器时间(倒计时对齐)")
+    @GetMapping("/serverTime")
+    public R serverTime() {
+        return R.ok().put("serverTimestamp", System.currentTimeMillis());
+    }
+
+    // =================== 私有构建方法 ===================
+
+    /**
+     * 商品级返回:最低团购价、活动状态、倒计时、满团人数等
+     */
+    private Map<String, Object> buildProductLevelMap(FsStoreProductActivity item,
+                                                     long now,
+                                                     List<FsStoreProductActivity> specs) {
+        // 最低团购价:从 activity.group_price 字段取,后台设置活动时必填
+        BigDecimal minGroupPrice = specs.stream()
+                .map(FsStoreProductActivity::getGroupPrice)
+                .filter(Objects::nonNull)
+                .min(BigDecimal::compareTo)
+                .orElse(item.getGroupPrice());
+
+        // 可售库存:取“参与团购的所有规格库存之和”,来源 fs_store_product_attr_value_scrm.stock(从 selectActivityVo 里 v.stock as spec_stock JOIN 出来)
+        // 不用商品表 p.stock:SCRM 下商品总库存不一定维护,真实可售库存是规格表 stock
+        int totalSpecStock = specs.stream()
+                .map(FsStoreProductActivity::getSpecStock)
+                .filter(Objects::nonNull)
+                .mapToInt(Integer::intValue)
+                .sum();
+
+        Map<String, Object> map = new HashMap<>();
+        map.put("id", item.getId());
+        map.put("productId", item.getProductId());
+        map.put("productName", item.getProductName());
+        map.put("image", item.getProductImage());
+        map.put("productImage", item.getProductImage());
+        map.put("startTime", item.getStartTime());
+        map.put("endTime", item.getEndTime());
+        map.put("status", item.getStatus());
+        map.put("groupPrice", minGroupPrice);
+        map.put("originalPrice", item.getOriginalPrice());
+        map.put("groupNum", item.getGroupNum());
+        // 商品级总库存:参与团购的全部规格库存汇总,供详情页头展示“仅剩 X 件”之类
+        map.put("productStock", totalSpecStock);
+
+        long startMs = item.getStartTime() == null ? 0L : item.getStartTime().getTime();
+        long endMs   = item.getEndTime()   == null ? 0L : item.getEndTime().getTime();
+        String activityStatus;
+        long countdown = 0;
+        if (now < startMs) {
+            activityStatus = "not_started";
+            countdown = (startMs - now) / 1000;
+        } else if (now > endMs) {
+            activityStatus = "ended";
+        } else {
+            activityStatus = "ongoing";
+            countdown = (endMs - now) / 1000;
+        }
+        map.put("activityStatus", activityStatus);
+        map.put("countdown", countdown);
+        return map;
+    }
+
+    /**
+     * 规格数组:每个规格一条,含 specId/specName/团购价/原价/规格库存
+     * <p>规格库存 stock 来源 fs_store_product_attr_value_scrm.stock(由 selectActivityVo 中 v.stock as spec_stock JOIN 出)</p>
+     */
+    private List<Map<String, Object>> buildSpecList(List<FsStoreProductActivity> specs) {
+        List<Map<String, Object>> specList = new ArrayList<>(specs.size());
+        for (FsStoreProductActivity spec : specs) {
+            Map<String, Object> s = new HashMap<>();
+            s.put("id", spec.getId());
+            s.put("image", spec.getProductImage());
+            s.put("specId", spec.getSpecId());
+            s.put("specName", spec.getSpecName());
+            s.put("originalPrice", spec.getOriginalPrice());
+            s.put("groupPrice", spec.getGroupPrice());
+            s.put("groupNum", spec.getGroupNum());
+            // 规格库存:fs_store_product_attr_value_scrm.stock,null 兑底为 0 避免前端 NaN
+            s.put("stock", spec.getSpecStock() == null ? 0 : spec.getSpecStock());
+            s.put("specStock", spec.getSpecStock() == null ? 0 : spec.getSpecStock());
+            specList.add(s);
+        }
+        return specList;
+    }
+}