refactor(task): 优化门店分佣逻辑按type字段判定

- 替换原有的角色shop判定逻辑,改为按ShopDealerUser.type=1判定门店分红用户
- 新增User和UserMapper依赖注入用于获取用户基础信息
- 添加幂等检查机制避免同一订单重复发放佣金
- 在创建分销商账户时补充基础信息防止字段约束导致插入失败
- 优化SQL查询逻辑,直接关联shop_dealer_user表而非查询系统角色表
- 更新日志信息中的描述文字以反映新的判定方式
- 添加对已存在订单记录的回填功能,支持补发门店分佣时更新分红字段
This commit is contained in:
2026-01-26 01:01:13 +08:00
parent 918190148b
commit 375a65be6a
4 changed files with 99 additions and 45 deletions

View File

@@ -67,7 +67,7 @@ public class ShopDealerReferee implements Serializable {
@Schema(description = "推荐关系层级(弃用)")
private Integer level;
@Schema(description = "上级是否门店角色(shop)")
@Schema(description = "上级是否门店分红用户(ShopDealerUser.type=1扩展字段)")
@TableField(exist = false)
private Boolean isShopRole;

View File

@@ -37,7 +37,7 @@ public interface ShopDealerRefereeMapper extends BaseMapper<ShopDealerReferee> {
/**
* 查询指定用户的推荐关系链路(按 level 升序),并计算每个上级是否包含门店角色(shop)。
* <p>
* 用于定时任务分佣:避免逐个查询 sys_user_role。
* 用于定时任务分佣:按 ShopDealerUser.type=1 判定“门店分红”上级,避免查询 core 的 sys_user_role。
* <p>
* 注意对应XML里为了兼容 MyBatis-Plus 的 SQL 解析limit 使用字面量拼接(${limit}
* 这里的 limit 必须由服务端常量传入(不要透传用户输入)。
@@ -47,7 +47,7 @@ public interface ShopDealerRefereeMapper extends BaseMapper<ShopDealerReferee> {
@Param("limit") Integer limit);
/**
* 查询指定用户的一级推荐人level=1计算该推荐人是否包含门店角色(shop)
* 查询指定用户的一级推荐人level=1按 ShopDealerUser.type=1 判定该推荐人是否为“门店分红”上级
*/
ShopDealerReferee selectFirstLevelRefereeWithShopRole(@Param("tenantId") Integer tenantId,
@Param("userId") Integer userId);

View File

@@ -2,27 +2,13 @@
<!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,
CASE WHEN du.user_id IS NULL THEN 0 ELSE 1 END AS isShopRole,
u.nickname,
u.avatar,
u.alias,
@@ -30,9 +16,11 @@
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
LEFT JOIN shop_dealer_user du
ON du.user_id = a.dealer_id
AND du.tenant_id = a.tenant_id
AND du.type = 1
AND (du.is_delete = 0 OR du.is_delete IS NULL)
INNER JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id AND u.deleted = 0
<where>
<if test="param.tenantId != null">
@@ -83,11 +71,13 @@
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
CASE WHEN du.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
LEFT JOIN shop_dealer_user du
ON du.user_id = a.dealer_id
AND du.tenant_id = a.tenant_id
AND du.type = 1
AND (du.is_delete = 0 OR du.is_delete IS NULL)
WHERE a.tenant_id = #{tenantId}
AND a.user_id = #{userId}
ORDER BY a.level ASC, a.id DESC
@@ -106,11 +96,13 @@
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
CASE WHEN du.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
LEFT JOIN shop_dealer_user du
ON du.user_id = a.dealer_id
AND du.tenant_id = a.tenant_id
AND du.type = 1
AND (du.is_delete = 0 OR du.is_delete IS NULL)
WHERE a.tenant_id = #{tenantId}
AND a.user_id = #{userId}
AND a.level = 1

View File

@@ -8,6 +8,8 @@ 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.common.system.entity.User;
import com.gxwebsoft.common.system.mapper.UserMapper;
import com.gxwebsoft.shop.mapper.ShopDealerRefereeMapper;
import com.gxwebsoft.shop.service.ShopDealerCapitalService;
import com.gxwebsoft.shop.service.ShopDealerOrderService;
@@ -66,6 +68,9 @@ public class DealerOrderSettlement10584Task {
@Resource
private ShopDealerRefereeMapper shopDealerRefereeMapper;
@Resource
private UserMapper userMapper;
/**
* 每30秒执行一次。
*/
@@ -139,7 +144,7 @@ public class DealerOrderSettlement10584Task {
// 1) 直推/简推shop_dealer_referee
DealerRefereeCommission dealerRefereeCommission = settleDealerRefereeCommission(order, baseAmount);
// 2) 角色为shop的推荐人shop_dealer_referee 链路向上查找 role=shop 的上级
// 2) 门店分红上级(按 ShopDealerUser.type=1shop_dealer_referee 链路向上查找)
ShopRoleCommission shopRoleCommission = settleShopRoleRefereeCommission(order, baseAmount);
// 3) 写入分销订单记录(用于排查/统计;详细分佣以 ShopDealerCapital 为准)
@@ -195,7 +200,7 @@ public class DealerOrderSettlement10584Task {
private ShopRoleCommission settleShopRoleRefereeCommission(ShopOrder order, BigDecimal baseAmount) {
List<Integer> shopRoleReferees = findFirstTwoShopRoleRefereesByDealerReferee(order.getUserId());
log.info("门店(角色shop)上级链路结果 - orderNo={}, buyerUserId={}, shopRoleReferees={}",
log.info("门店(type=1)上级链路结果 - orderNo={}, buyerUserId={}, shopRoleReferees={}",
order.getOrderNo(), order.getUserId(), shopRoleReferees);
if (shopRoleReferees.isEmpty()) {
return ShopRoleCommission.empty();
@@ -205,7 +210,7 @@ public class DealerOrderSettlement10584Task {
// 门店直推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, order.getUserId(), "门店直推佣金(角色shop仅1人2%)");
creditDealerCommission(shopRoleReferees.get(0), money, order, order.getUserId(), "门店直推佣金(type=1仅1人2%)");
return new ShopRoleCommission(shopRoleReferees.get(0), money, null, BigDecimal.ZERO);
}
@@ -214,8 +219,8 @@ public class DealerOrderSettlement10584Task {
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, order.getUserId(), "门店直推佣金(角色shop第1个2%)");
creditDealerCommission(shopRoleReferees.get(1), storeSimpleMoney, order, order.getUserId(), "门店简推佣金(角色shop第2个1%)");
creditDealerCommission(shopRoleReferees.get(0), storeDirectMoney, order, order.getUserId(), "门店直推佣金(type=1第1个2%)");
creditDealerCommission(shopRoleReferees.get(1), storeSimpleMoney, order, order.getUserId(), "门店简推佣金(type=1第2个1%)");
return new ShopRoleCommission(shopRoleReferees.get(0), storeDirectMoney, shopRoleReferees.get(1), storeSimpleMoney);
}
@@ -227,10 +232,10 @@ public class DealerOrderSettlement10584Task {
List<Integer> result = new ArrayList<>(2);
Set<Integer> visited = new HashSet<>();
// 优先:直接从该买家(userId)的多级关系(level=1/2/3/...)里,按 level 升序找到最近的两级门店(角色shop)。
// 优先:直接从该买家(userId)的多级关系(level=1/2/3/...)里,按 level 升序找到最近的两级门店(type=1)。
// 背景:部分数据只维护“买家 -> 上级们”的多级(level)记录,但不一定维护“上级 -> 更上级”的记录;
// 若仅做链路向上查找,会导致门店直推/简推无法命中,收益与资金明细都不会写入。
// 注意isShopRole 为扩展字段(非表字段)需用自定义SQL带出。
// 注意isShopRole 为扩展字段(非表字段)自定义SQL按 ShopDealerUser.type=1 计算带出。
List<ShopDealerReferee> relList = shopDealerRefereeMapper.selectRefereeChainWithShopRole(
TENANT_ID, buyerUserId, MAX_REFEREE_CHAIN_DEPTH
);
@@ -247,7 +252,7 @@ public class DealerOrderSettlement10584Task {
}
}
// 兜底:若只维护了 level=1 的推荐关系,则尝试按“查两次/多次”的方式一路向上找门店角色
// 兜底:若只维护了 level=1 的推荐关系,则尝试按“查两次/多次”的方式一路向上找门店(type=1)
Integer current = buyerUserId;
for (int i = 0; i < MAX_REFEREE_CHAIN_DEPTH && current != null; i++) {
ShopDealerReferee parentRel = shopDealerRefereeMapper.selectFirstLevelRefereeWithShopRole(TENANT_ID, current);
@@ -256,14 +261,14 @@ public class DealerOrderSettlement10584Task {
break;
}
log.debug("门店(角色shop)链路向上 - buyerOrChildUserId={}, parentId={}", current, parentId);
log.debug("门店(type=1)链路向上 - buyerOrChildUserId={}, parentId={}", current, parentId);
if (!visited.add(parentId)) {
break;
}
if (parentRel != null && Boolean.TRUE.equals(parentRel.getIsShopRole())) {
log.debug("门店(角色shop)命中 - parentId={}", parentId);
log.debug("门店(type=1)命中 - parentId={}", parentId);
result.add(parentId);
if (result.size() >= 2) {
break;
@@ -281,6 +286,28 @@ public class DealerOrderSettlement10584Task {
return;
}
// 幂等:同一订单同一类型佣金,避免重复发放(用于任务重跑/补发场景)
LambdaQueryWrapper<ShopDealerCapital> idempotentQw = new LambdaQueryWrapper<ShopDealerCapital>()
.eq(ShopDealerCapital::getTenantId, TENANT_ID)
.eq(ShopDealerCapital::getOrderNo, order.getOrderNo())
.eq(ShopDealerCapital::getUserId, dealerUserId);
if (comments != null) {
// 以“佣金类型前缀”做幂等键,避免 comments 细节文案调整导致重复发放。
String commentPrefix = comments;
int idx = comments.indexOf('(');
if (idx > 0) {
commentPrefix = comments.substring(0, idx) + "(";
}
idempotentQw.likeRight(ShopDealerCapital::getComments, commentPrefix);
} else {
idempotentQw.isNull(ShopDealerCapital::getComments);
}
boolean alreadyCredited = shopDealerCapitalService.count(idempotentQw) > 0;
if (alreadyCredited) {
log.info("佣金已入账,跳过 - orderNo={}, toDealerUserId={}, money={}, comments={}", order.getOrderNo(), dealerUserId, money, comments);
return;
}
log.info("佣金入账 - orderNo={}, toDealerUserId={}, money={}, comments={}", order.getOrderNo(), dealerUserId, money, comments);
// 先累加佣金到分销商账户避免并发下丢失更新用SQL自增
@@ -314,12 +341,25 @@ public class DealerOrderSettlement10584Task {
newDealerUser.setMoney(BigDecimal.ZERO);
newDealerUser.setFreezeMoney(BigDecimal.ZERO);
newDealerUser.setTotalMoney(BigDecimal.ZERO);
// 尽量补齐基础信息,避免表字段 NOT NULL 导致插入失败(插入失败会让门店分佣“找到了人但入不了账”)。
try {
User sysUser = userMapper.selectByIdIgnoreTenant(dealerUserId);
if (sysUser != null) {
newDealerUser.setRealName(sysUser.getRealName() != null ? sysUser.getRealName() : sysUser.getNickname());
newDealerUser.setMobile(sysUser.getPhone());
}
} catch (Exception e) {
// 拉取基础信息失败不应阻断结算;后续 update 若失败会有 warn 日志。
log.warn("创建分销商账户时读取sys_user失败 - tenantId={}, dealerUserId={}", TENANT_ID, dealerUserId, e);
}
newDealerUser.setCreateTime(java.time.LocalDateTime.now());
newDealerUser.setUpdateTime(java.time.LocalDateTime.now());
try {
shopDealerUserService.save(newDealerUser);
} catch (Exception ignore) {
// 并发下可能已被其他线程/实例创建,忽略后继续重试入账
} catch (Exception e) {
// 并发下可能已被其他线程/实例创建;也可能是字段约束导致插入失败
// 继续重试 update若仍失败会输出 warn便于定位原因。
log.warn("创建分销商账户失败 - tenantId={}, dealerUserId={}, orderNo={}", TENANT_ID, dealerUserId, order.getOrderNo(), e);
}
}
@@ -355,13 +395,35 @@ public class DealerOrderSettlement10584Task {
ShopRoleCommission shopRoleCommission
) {
// 幂等:同一订单只写一条(依赖 order_no + tenant_id 作为业务唯一)
boolean exists = shopDealerOrderService.count(
ShopDealerOrder existed = shopDealerOrderService.getOne(
new LambdaQueryWrapper<ShopDealerOrder>()
.eq(ShopDealerOrder::getTenantId, TENANT_ID)
.eq(ShopDealerOrder::getOrderNo, order.getOrderNo())
) > 0;
if (exists) {
log.info("ShopDealerOrder已存在跳过写入 - orderNo={}", order.getOrderNo());
.last("limit 1")
);
if (existed != null) {
// 允许“补发”门店分佣时回填分红字段,避免订单已结算但分红字段一直为空,影响排查/对账。
LambdaUpdateWrapper<ShopDealerOrder> uw = new LambdaUpdateWrapper<ShopDealerOrder>()
.eq(ShopDealerOrder::getTenantId, TENANT_ID)
.eq(ShopDealerOrder::getOrderNo, order.getOrderNo());
boolean needUpdate = false;
if (existed.getFirstDividendUser() == null && shopRoleCommission.storeDirectUserId != null) {
uw.set(ShopDealerOrder::getFirstDividendUser, shopRoleCommission.storeDirectUserId);
uw.set(ShopDealerOrder::getFirstDividend, shopRoleCommission.storeDirectMoney);
needUpdate = true;
}
if (existed.getSecondDividendUser() == null && shopRoleCommission.storeSimpleUserId != null) {
uw.set(ShopDealerOrder::getSecondDividendUser, shopRoleCommission.storeSimpleUserId);
uw.set(ShopDealerOrder::getSecondDividend, shopRoleCommission.storeSimpleMoney);
needUpdate = true;
}
if (needUpdate) {
shopDealerOrderService.update(uw);
log.info("ShopDealerOrder已存在回填门店分红字段 - orderNo={}, firstDividendUser={}, secondDividendUser={}",
order.getOrderNo(), shopRoleCommission.storeDirectUserId, shopRoleCommission.storeSimpleUserId);
} else {
log.info("ShopDealerOrder已存在跳过写入 - orderNo={}", order.getOrderNo());
}
return;
}