refactor(settlement): 优化经销商订单结算逻辑

- 移除不必要的UserRoleService依赖注入
- 将定时任务执行频率从每20秒调整为每30秒
- 删除过期的shopRoleCache缓存机制
- 重构推荐关系查询逻辑,支持多级关系和门店角色识别
- 添加对两种数据形态的兼容处理(level=1/2多级关系 vs 仅level=1关系)
- 修改佣金分配逻辑,区分直推佣金和推荐奖金
- 优化门店角色佣金计算,调整为直推2%和简推1%
- 添加分销商账户自动创建机制,确保佣金能够正常发放
- 更新资金明细记录,准确关联到对应的用户ID
- 新增自定义SQL查询方法,一次性获取推荐关系链路和门店角色信息
- 扩展ShopDealerReferee实体,增加isShopRole扩展字段
- 优化MyBatis XML映射,使用LEFT JOIN预加载角色信息避免N+1查询问题
This commit is contained in:
2026-01-26 00:14:34 +08:00
parent d15cc03e48
commit 918190148b
5 changed files with 190 additions and 51 deletions

View File

@@ -16,4 +16,4 @@ public class WebSocketConfig {
return exporter;
}
}
}

View File

@@ -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)

View File

@@ -34,4 +34,22 @@ public interface ShopDealerRefereeMapper extends BaseMapper<ShopDealerReferee> {
*/
List<ShopDealerReferee> selectListRel(@Param("param") ShopDealerRefereeParam param);
/**
* 查询指定用户的推荐关系链路(按 level 升序),并计算每个上级是否包含门店角色(shop)。
* <p>
* 用于定时任务分佣:避免逐个查询 sys_user_role。
* <p>
* 注意对应XML里为了兼容 MyBatis-Plus 的 SQL 解析limit 使用字面量拼接(${limit}
* 这里的 limit 必须由服务端常量传入(不要透传用户输入)。
*/
List<ShopDealerReferee> 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);
}

View File

@@ -2,12 +2,27 @@
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.gxwebsoft.shop.mapper.ShopDealerRefereeMapper">
<!--
MyBatis-Plus 3.4.x uses JSqlParser 3.2 to parse and rewrite SQL (e.g. for tenant/pagination).
JSqlParser 3.2 has limited MySQL expression support and may fail on constructs like:
- `EXISTS(subquery)` in SELECT items
- `MAX((cond) OR (cond))` in derived tables
This derived table keeps the SQL parse-friendly: one row per user_id that has role=shop.
-->
<sql id="shopRoleUserIds">
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')
</sql>
<!-- 关联查询sql -->
<sql id="selectSql">
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 (
<include refid="shopRoleUserIds"/>
) 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
<where>
<if test="param.tenantId != null">
AND a.tenant_id = #{param.tenantId}
</if>
<if test="param.id != null">
AND a.id = #{param.id}
</if>
@@ -55,4 +76,46 @@
<include refid="selectSql"></include>
</select>
<!-- 定时任务:查某个用户的推荐关系链路(按 level 升序),并带出是否门店角色 -->
<select id="selectRefereeChainWithShopRole" resultType="com.gxwebsoft.shop.entity.ShopDealerReferee">
SELECT a.id,
a.dealer_id AS dealerId,
a.user_id AS userId,
a.level,
a.tenant_id AS tenantId,
CASE WHEN sr.user_id IS NULL THEN 0 ELSE 1 END AS isShopRole
FROM shop_dealer_referee a
LEFT JOIN (
<include refid="shopRoleUserIds"/>
) sr ON sr.user_id = a.dealer_id
WHERE a.tenant_id = #{tenantId}
AND a.user_id = #{userId}
ORDER BY a.level ASC, a.id DESC
<!--
MyBatis-Plus TenantLineInnerInterceptor uses JSqlParser to parse SQL.
Some versions can't parse "LIMIT ?" (prepared placeholder), so use a literal here.
`limit` is an Integer passed from server-side constants (no user input).
-->
LIMIT ${limit}
</select>
<!-- 定时任务:查某个用户的一级推荐关系(level=1),并带出是否门店角色 -->
<select id="selectFirstLevelRefereeWithShopRole" resultType="com.gxwebsoft.shop.entity.ShopDealerReferee">
SELECT a.id,
a.dealer_id AS dealerId,
a.user_id AS userId,
a.level,
a.tenant_id AS tenantId,
CASE WHEN sr.user_id IS NULL THEN 0 ELSE 1 END AS isShopRole
FROM shop_dealer_referee a
LEFT JOIN (
<include refid="shopRoleUserIds"/>
) sr ON sr.user_id = a.dealer_id
WHERE a.tenant_id = #{tenantId}
AND a.user_id = #{userId}
AND a.level = 1
ORDER BY a.id DESC
LIMIT 1
</select>
</mapper>

View File

@@ -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<Integer, Boolean> 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<Integer, Boolean> 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<ShopDealerReferee>()
.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<Integer, Boolean> shopRoleCache) {
List<Integer> shopRoleReferees = findFirstTwoShopRoleRefereesByDealerReferee(order.getUserId(), shopRoleCache);
private ShopRoleCommission settleShopRoleRefereeCommission(ShopOrder order, BigDecimal baseAmount) {
List<Integer> 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<Integer> findFirstTwoShopRoleRefereesByDealerReferee(Integer buyerUserId, Map<Integer, Boolean> shopRoleCache) {
private List<Integer> findFirstTwoShopRoleRefereesByDealerReferee(Integer buyerUserId) {
if (buyerUserId == null) {
return Collections.emptyList();
}
@@ -220,9 +227,31 @@ public class DealerOrderSettlement10584Task {
List<Integer> result = new ArrayList<>(2);
Set<Integer> visited = new HashSet<>();
// 优先:直接从该买家(userId)的多级关系(level=1/2/3/...)里,按 level 升序找到最近的两级门店(角色shop)。
// 背景:部分数据只维护“买家 -> 上级们”的多级(level)记录,但不一定维护“上级 -> 更上级”的记录;
// 若仅做链路向上查找,会导致门店直推/简推无法命中,收益与资金明细都不会写入。
// 注意isShopRole 为扩展字段(非表字段)需用自定义SQL带出。
List<ShopDealerReferee> 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<Integer, Boolean> shopRoleCache) {
Boolean cached = shopRoleCache.get(userId);
if (cached != null) {
return cached;
}
List<Role> 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<ShopDealerUser>()
.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<ShopDealerUser>()
.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));