diff --git a/src/main/java/com/gxwebsoft/common/core/websocket/WebSocketConfig.java b/src/main/java/com/gxwebsoft/common/core/websocket/WebSocketConfig.java index ba511dd..10c99f0 100644 --- a/src/main/java/com/gxwebsoft/common/core/websocket/WebSocketConfig.java +++ b/src/main/java/com/gxwebsoft/common/core/websocket/WebSocketConfig.java @@ -16,4 +16,4 @@ public class WebSocketConfig { return exporter; } -} \ No newline at end of file +} diff --git a/src/main/java/com/gxwebsoft/shop/entity/ShopDealerReferee.java b/src/main/java/com/gxwebsoft/shop/entity/ShopDealerReferee.java index 5be4bd4..b750db5 100644 --- a/src/main/java/com/gxwebsoft/shop/entity/ShopDealerReferee.java +++ b/src/main/java/com/gxwebsoft/shop/entity/ShopDealerReferee.java @@ -64,9 +64,13 @@ public class ShopDealerReferee implements Serializable { @TableField(exist = false) private Boolean isAdmin; - @Schema(description = "推荐关系层级(1,2,3)") + @Schema(description = "推荐关系层级(弃用)") private Integer level; + @Schema(description = "上级是否门店角色(shop)") + @TableField(exist = false) + private Boolean isShopRole; + @Schema(description = "来源(如 goods_share)") // NOTE: 表 shop_dealer_referee 若未新增该字段,需要 exist=false,避免 MyBatis-Plus 自动生成SQL时报 Unknown column。 @TableField(exist = false) diff --git a/src/main/java/com/gxwebsoft/shop/mapper/ShopDealerRefereeMapper.java b/src/main/java/com/gxwebsoft/shop/mapper/ShopDealerRefereeMapper.java index 70ddf37..63111d9 100644 --- a/src/main/java/com/gxwebsoft/shop/mapper/ShopDealerRefereeMapper.java +++ b/src/main/java/com/gxwebsoft/shop/mapper/ShopDealerRefereeMapper.java @@ -34,4 +34,22 @@ public interface ShopDealerRefereeMapper extends BaseMapper { */ List selectListRel(@Param("param") ShopDealerRefereeParam param); + /** + * 查询指定用户的推荐关系链路(按 level 升序),并计算每个上级是否包含门店角色(shop)。 + *

+ * 用于定时任务分佣:避免逐个查询 sys_user_role。 + *

+ * 注意:对应XML里为了兼容 MyBatis-Plus 的 SQL 解析,limit 使用字面量拼接(${limit}), + * 这里的 limit 必须由服务端常量传入(不要透传用户输入)。 + */ + List selectRefereeChainWithShopRole(@Param("tenantId") Integer tenantId, + @Param("userId") Integer userId, + @Param("limit") Integer limit); + + /** + * 查询指定用户的一级推荐人(level=1),并计算该推荐人是否包含门店角色(shop)。 + */ + ShopDealerReferee selectFirstLevelRefereeWithShopRole(@Param("tenantId") Integer tenantId, + @Param("userId") Integer userId); + } diff --git a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerRefereeMapper.xml b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerRefereeMapper.xml index 67b05ed..eb5f2b4 100644 --- a/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerRefereeMapper.xml +++ b/src/main/java/com/gxwebsoft/shop/mapper/xml/ShopDealerRefereeMapper.xml @@ -2,12 +2,27 @@ + + + SELECT DISTINCT ur.user_id + FROM gxwebsoft_core.sys_user_role ur + LEFT JOIN gxwebsoft_core.sys_role r ON ur.role_id = r.role_id AND r.deleted = 0 + WHERE (LOWER(TRIM(r.role_code)) = 'shop' OR LOWER(TRIM(r.role_name)) = 'shop') + + SELECT DISTINCT a.*, d.nickname AS dealerName, d.avatar AS dealerAvatar, d.phone AS dealerPhone, + CASE WHEN sr.user_id IS NULL THEN 0 ELSE 1 END AS isShopRole, u.nickname, u.avatar, u.alias, @@ -15,8 +30,14 @@ u.is_admin as isAdmin FROM shop_dealer_referee a INNER JOIN gxwebsoft_core.sys_user d ON a.dealer_id = d.user_id AND d.deleted = 0 + LEFT JOIN ( + + ) sr ON sr.user_id = a.dealer_id INNER JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id AND u.deleted = 0 + + AND a.tenant_id = #{param.tenantId} + AND a.id = #{param.id} @@ -55,4 +76,46 @@ + + + + + + diff --git a/src/main/java/com/gxwebsoft/shop/task/DealerOrderSettlement10584Task.java b/src/main/java/com/gxwebsoft/shop/task/DealerOrderSettlement10584Task.java index 6e04b46..8325ed5 100644 --- a/src/main/java/com/gxwebsoft/shop/task/DealerOrderSettlement10584Task.java +++ b/src/main/java/com/gxwebsoft/shop/task/DealerOrderSettlement10584Task.java @@ -3,13 +3,12 @@ package com.gxwebsoft.shop.task; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.gxwebsoft.common.core.annotation.IgnoreTenant; -import com.gxwebsoft.common.system.entity.Role; -import com.gxwebsoft.common.system.service.UserRoleService; import com.gxwebsoft.shop.entity.ShopDealerCapital; import com.gxwebsoft.shop.entity.ShopDealerOrder; import com.gxwebsoft.shop.entity.ShopDealerReferee; import com.gxwebsoft.shop.entity.ShopDealerUser; import com.gxwebsoft.shop.entity.ShopOrder; +import com.gxwebsoft.shop.mapper.ShopDealerRefereeMapper; import com.gxwebsoft.shop.service.ShopDealerCapitalService; import com.gxwebsoft.shop.service.ShopDealerOrderService; import com.gxwebsoft.shop.service.ShopDealerRefereeService; @@ -65,12 +64,12 @@ public class DealerOrderSettlement10584Task { private ShopDealerOrderService shopDealerOrderService; @Resource - private UserRoleService userRoleService; + private ShopDealerRefereeMapper shopDealerRefereeMapper; /** - * 每20秒执行一次。 + * 每30秒执行一次。 */ - @Scheduled(cron = "0/20 * * * * ?") + @Scheduled(cron = "0/30 * * * * ?") @IgnoreTenant("该定时任务仅处理租户10584,但需要显式按tenantId过滤,避免定时任务线程无租户上下文导致查询异常") public void settleTenant10584Orders() { try { @@ -84,9 +83,6 @@ public class DealerOrderSettlement10584Task { orders.size(), orders.stream().limit(10).map(ShopOrder::getOrderNo).toList()); - // 缓存:减少同一批次内重复查角色 - Map shopRoleCache = new HashMap<>(); - for (ShopOrder order : orders) { try { transactionTemplate.executeWithoutResult(status -> { @@ -94,7 +90,7 @@ public class DealerOrderSettlement10584Task { if (!claimOrderToSettle(order.getOrderId())) { return; } - settleOneOrder(order, shopRoleCache); + settleOneOrder(order); }); } catch (Exception e) { log.error("订单结算失败,将回滚本订单并在下次任务重试 - orderId={}, orderNo={}", order.getOrderId(), order.getOrderNo(), e); @@ -127,7 +123,7 @@ public class DealerOrderSettlement10584Task { ); } - private void settleOneOrder(ShopOrder order, Map shopRoleCache) { + private void settleOneOrder(ShopOrder order) { if (order.getUserId() == null || order.getOrderNo() == null) { throw new IllegalStateException("订单关键信息缺失,无法结算 - orderId=" + order.getOrderId()); } @@ -144,7 +140,7 @@ public class DealerOrderSettlement10584Task { DealerRefereeCommission dealerRefereeCommission = settleDealerRefereeCommission(order, baseAmount); // 2) 角色为shop的推荐人(shop_dealer_referee 链路向上查找 role=shop 的上级) - ShopRoleCommission shopRoleCommission = settleShopRoleRefereeCommission(order, baseAmount, shopRoleCache); + ShopRoleCommission shopRoleCommission = settleShopRoleRefereeCommission(order, baseAmount); // 3) 写入分销订单记录(用于排查/统计;详细分佣以 ShopDealerCapital 为准) createDealerOrderRecord(order, baseAmount, dealerRefereeCommission, shopRoleCommission); @@ -153,9 +149,14 @@ public class DealerOrderSettlement10584Task { } private DealerRefereeCommission settleDealerRefereeCommission(ShopOrder order, BigDecimal baseAmount) { - // level=2 可能不可靠:按“查两次”方式获取简推(直推的上级)。 - Integer directDealerId = getDealerRefereeId(order.getUserId()); - Integer simpleDealerId = directDealerId != null ? getDealerRefereeId(directDealerId) : null; + // 兼容两种数据形态: + // 1) 同一 userId 下有 level=1/2 的多级关系(直接按 level 取); + // 2) 仅维护 level=1(用“查两次”回退获取上级)。 + Integer directDealerId = getDealerRefereeId(order.getUserId(), 1); + Integer simpleDealerId = getDealerRefereeId(order.getUserId(), 2); + if (simpleDealerId == null && directDealerId != null) { + simpleDealerId = getDealerRefereeId(directDealerId, 1); + } BigDecimal directMoney = directDealerId != null ? calcMoney(baseAmount, RATE_0_10) : BigDecimal.ZERO; // 允许同一条线内同一个人同时拿到“直推 + 简推”(即使 directDealerId == simpleDealerId 也照常发放两笔) @@ -164,13 +165,18 @@ public class DealerOrderSettlement10584Task { log.info("分销直推/简推查询结果 - orderNo={}, buyerUserId={}, directDealerId={}, directMoney={}, simpleDealerId={}, simpleMoney={}", order.getOrderNo(), order.getUserId(), directDealerId, directMoney, simpleDealerId, simpleMoney); - creditDealerCommission(directDealerId, directMoney, order, "直推佣金(10%)"); - creditDealerCommission(simpleDealerId, simpleMoney, order, "简推佣金(5%)"); + // 直推:对方=买家;推荐奖(5%):对方=直推分销商(便于在资金明细中看出“来自哪个下级分销商/团队订单”) + creditDealerCommission(directDealerId, directMoney, order, order.getUserId(), "直推佣金(10%)"); + creditDealerCommission(simpleDealerId, simpleMoney, order, directDealerId, "推荐奖(5%)"); return new DealerRefereeCommission(directDealerId, directMoney, simpleDealerId, simpleMoney); } private Integer getDealerRefereeId(Integer userId) { + return getDealerRefereeId(userId, 1); + } + + private Integer getDealerRefereeId(Integer userId, int level) { if (userId == null) { return null; } @@ -178,17 +184,17 @@ public class DealerOrderSettlement10584Task { new LambdaQueryWrapper() .eq(ShopDealerReferee::getTenantId, TENANT_ID) .eq(ShopDealerReferee::getUserId, userId) - .eq(ShopDealerReferee::getLevel, 1) + .eq(ShopDealerReferee::getLevel, level) .orderByDesc(ShopDealerReferee::getId) .last("limit 1") ); - log.debug("shop_dealer_referee(level=1) 查询 - tenantId={}, userId={}, dealerId={}", - TENANT_ID, userId, rel != null ? rel.getDealerId() : null); + log.debug("shop_dealer_referee(level={}) 查询 - tenantId={}, userId={}, dealerId={}", + level, TENANT_ID, userId, rel != null ? rel.getDealerId() : null); return rel != null ? rel.getDealerId() : null; } - private ShopRoleCommission settleShopRoleRefereeCommission(ShopOrder order, BigDecimal baseAmount, Map shopRoleCache) { - List shopRoleReferees = findFirstTwoShopRoleRefereesByDealerReferee(order.getUserId(), shopRoleCache); + private ShopRoleCommission settleShopRoleRefereeCommission(ShopOrder order, BigDecimal baseAmount) { + List shopRoleReferees = findFirstTwoShopRoleRefereesByDealerReferee(order.getUserId()); log.info("门店(角色shop)上级链路结果 - orderNo={}, buyerUserId={}, shopRoleReferees={}", order.getOrderNo(), order.getUserId(), shopRoleReferees); if (shopRoleReferees.isEmpty()) { @@ -196,23 +202,24 @@ public class DealerOrderSettlement10584Task { } if (shopRoleReferees.size() == 1) { - BigDecimal money = calcMoney(baseAmount, RATE_0_10); + // 门店直推:2% + BigDecimal money = calcMoney(baseAmount, RATE_0_02); log.info("门店直推/简推发放(仅1人) - orderNo={}, storeDirectUserId={}, money={}", order.getOrderNo(), shopRoleReferees.get(0), money); - creditDealerCommission(shopRoleReferees.get(0), money, order, "门店直推佣金(角色shop,仅1人,10%)"); + creditDealerCommission(shopRoleReferees.get(0), money, order, order.getUserId(), "门店直推佣金(角色shop,仅1人,2%)"); return new ShopRoleCommission(shopRoleReferees.get(0), money, null, BigDecimal.ZERO); } - // 两个或以上:第一个0.02,第二个0.08 + // 两个或以上:第一个0.02,第二个0.01 BigDecimal storeDirectMoney = calcMoney(baseAmount, RATE_0_02); BigDecimal storeSimpleMoney = calcMoney(baseAmount, RATE_0_01); log.info("门店直推/门店简推发放 - orderNo={}, storeDirectUserId={}, storeDirectMoney={}, storeSimpleUserId={}, storeSimpleMoney={}", order.getOrderNo(), shopRoleReferees.get(0), storeDirectMoney, shopRoleReferees.get(1), storeSimpleMoney); - creditDealerCommission(shopRoleReferees.get(0), storeDirectMoney, order, "门店直推佣金(角色shop,第1个,2%)"); - creditDealerCommission(shopRoleReferees.get(1), storeSimpleMoney, order, "门店简推佣金(角色shop,第2个,1%)"); + creditDealerCommission(shopRoleReferees.get(0), storeDirectMoney, order, order.getUserId(), "门店直推佣金(角色shop,第1个,2%)"); + creditDealerCommission(shopRoleReferees.get(1), storeSimpleMoney, order, order.getUserId(), "门店简推佣金(角色shop,第2个,1%)"); return new ShopRoleCommission(shopRoleReferees.get(0), storeDirectMoney, shopRoleReferees.get(1), storeSimpleMoney); } - private List findFirstTwoShopRoleRefereesByDealerReferee(Integer buyerUserId, Map shopRoleCache) { + private List findFirstTwoShopRoleRefereesByDealerReferee(Integer buyerUserId) { if (buyerUserId == null) { return Collections.emptyList(); } @@ -220,9 +227,31 @@ public class DealerOrderSettlement10584Task { List result = new ArrayList<>(2); Set visited = new HashSet<>(); + // 优先:直接从该买家(userId)的多级关系(level=1/2/3/...)里,按 level 升序找到最近的两级门店(角色shop)。 + // 背景:部分数据只维护“买家 -> 上级们”的多级(level)记录,但不一定维护“上级 -> 更上级”的记录; + // 若仅做链路向上查找,会导致门店直推/简推无法命中,收益与资金明细都不会写入。 + // 注意:isShopRole 为扩展字段(非表字段),需用自定义SQL带出。 + List relList = shopDealerRefereeMapper.selectRefereeChainWithShopRole( + TENANT_ID, buyerUserId, MAX_REFEREE_CHAIN_DEPTH + ); + for (ShopDealerReferee rel : relList) { + Integer dealerId = rel != null ? rel.getDealerId() : null; + if (dealerId == null || !visited.add(dealerId)) { + continue; + } + if (Boolean.TRUE.equals(rel.getIsShopRole())) { + result.add(dealerId); + if (result.size() >= 2) { + return result; + } + } + } + + // 兜底:若只维护了 level=1 的推荐关系,则尝试按“查两次/多次”的方式一路向上找门店角色。 Integer current = buyerUserId; for (int i = 0; i < MAX_REFEREE_CHAIN_DEPTH && current != null; i++) { - Integer parentId = getDealerRefereeId(current); + ShopDealerReferee parentRel = shopDealerRefereeMapper.selectFirstLevelRefereeWithShopRole(TENANT_ID, current); + Integer parentId = parentRel != null ? parentRel.getDealerId() : null; if (parentId == null) { break; } @@ -233,7 +262,7 @@ public class DealerOrderSettlement10584Task { break; } - if (hasShopRole(parentId, shopRoleCache)) { + if (parentRel != null && Boolean.TRUE.equals(parentRel.getIsShopRole())) { log.debug("门店(角色shop)命中 - parentId={}", parentId); result.add(parentId); if (result.size() >= 2) { @@ -247,23 +276,7 @@ public class DealerOrderSettlement10584Task { return result; } - private boolean hasShopRole(Integer userId, Map shopRoleCache) { - Boolean cached = shopRoleCache.get(userId); - if (cached != null) { - return cached; - } - - List roles = userRoleService.listByUserId(userId); - boolean isShop = roles != null && roles.stream().anyMatch(r -> "shop".equalsIgnoreCase(r.getRoleCode())); - log.debug("角色判定 - userId={}, roles={}, isShop={}", - userId, - roles == null ? null : roles.stream().map(Role::getRoleCode).toList(), - isShop); - shopRoleCache.put(userId, isShop); - return isShop; - } - - private void creditDealerCommission(Integer dealerUserId, BigDecimal money, ShopOrder order, String comments) { + private void creditDealerCommission(Integer dealerUserId, BigDecimal money, ShopOrder order, Integer toUserId, String comments) { if (dealerUserId == null || money == null || money.signum() <= 0) { return; } @@ -280,8 +293,47 @@ public class DealerOrderSettlement10584Task { ); if (!updated) { - log.warn("发放佣金失败:未找到分销商账户 - tenantId={}, dealerUserId={}, orderNo={}", TENANT_ID, dealerUserId, order.getOrderNo()); - return; + // 门店角色用户可能未开通分销账户:此时门店直推/简推会“找到了人但入不了账”,收益与明细都不会写入。 + // 这里补偿创建账户后再尝试入账一次。 + ShopDealerUser existed = shopDealerUserService.getOne( + new LambdaQueryWrapper() + .eq(ShopDealerUser::getTenantId, TENANT_ID) + .eq(ShopDealerUser::getUserId, dealerUserId) + .last("limit 1") + ); + if (existed == null) { + ShopDealerUser newDealerUser = new ShopDealerUser(); + newDealerUser.setTenantId(TENANT_ID); + newDealerUser.setUserId(dealerUserId); + newDealerUser.setType(0); + newDealerUser.setIsDelete(0); + newDealerUser.setSortNumber(0); + newDealerUser.setFirstNum(0); + newDealerUser.setSecondNum(0); + newDealerUser.setThirdNum(0); + newDealerUser.setMoney(BigDecimal.ZERO); + newDealerUser.setFreezeMoney(BigDecimal.ZERO); + newDealerUser.setTotalMoney(BigDecimal.ZERO); + newDealerUser.setCreateTime(java.time.LocalDateTime.now()); + newDealerUser.setUpdateTime(java.time.LocalDateTime.now()); + try { + shopDealerUserService.save(newDealerUser); + } catch (Exception ignore) { + // 并发下可能已被其他线程/实例创建,忽略后继续重试入账。 + } + } + + updated = shopDealerUserService.update( + new LambdaUpdateWrapper() + .eq(ShopDealerUser::getTenantId, TENANT_ID) + .eq(ShopDealerUser::getUserId, dealerUserId) + .setSql("money = IFNULL(money,0) + " + money.toPlainString()) + .setSql("total_money = IFNULL(total_money,0) + " + money.toPlainString()) + ); + if (!updated) { + log.warn("发放佣金失败:未找到/创建分销商账户 - tenantId={}, dealerUserId={}, orderNo={}", TENANT_ID, dealerUserId, order.getOrderNo()); + return; + } } ShopDealerCapital cap = new ShopDealerCapital(); @@ -290,7 +342,7 @@ public class DealerOrderSettlement10584Task { cap.setFlowType(10); cap.setMoney(money); cap.setComments(comments); - cap.setToUserId(order.getUserId()); + cap.setToUserId(toUserId); cap.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); cap.setTenantId(TENANT_ID); shopDealerCapitalService.save(cap); @@ -332,6 +384,8 @@ public class DealerOrderSettlement10584Task { dealerOrder.setIsSettled(1); dealerOrder.setSettleTime(java.time.LocalDateTime.now()); + // ShopDealerCapital 关联查询会拿 shop_dealer_order.month(覆盖 a.month),这里需要同步填充。 + dealerOrder.setMonth(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM"))); dealerOrder.setTenantId(TENANT_ID); dealerOrder.setComments(buildCommissionTraceComment(dealerRefereeCommission, shopRoleCommission));