feat(credit): 添加被执行人企业关联匹配功能

- 在 CreditJudgmentDebtorController 中新增 refreshCompanyIdByCompanyName 接口
- 实现根据企业名称自动匹配并更新 companyId 的批量处理逻辑
- 支持按租户维度进行企业名称匹配,避免跨租户误匹配
- 添加企业名称标准化处理和模糊匹配机制
- 实现批量更新和事务管理,提升处理效率
- 优化关键词搜索条件,精确匹配企业名称而非模糊匹配
- 添加公司名称规范化方法 normalizeCompanyName
- 修复竞品表字段别名从 mainCompanyName 改为 companyName

feat(shop): 优化分销商推荐关系绑定机制

- 修改 ShopDealerRefereeController 的 save 方法为幂等绑定
- 新增 bindFirstLevel 方法实现一级推荐关系的幂等绑定
- 添加用户身份验证和安全校验机制
- 增加 source 和 scene 字段支持来源追踪
- 实现重复绑定防护和业务异常处理
- 添加经销商有效性校验机制
This commit is contained in:
2026-01-20 17:02:59 +08:00
parent 1898d3ac9b
commit 7487236ac6
10 changed files with 318 additions and 22 deletions

View File

@@ -6,9 +6,11 @@ import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.core.web.BatchParam;
import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.credit.entity.CreditCompany;
import com.gxwebsoft.credit.entity.CreditJudgmentDebtor;
import com.gxwebsoft.credit.param.CreditJudgmentDebtorImportParam;
import com.gxwebsoft.credit.param.CreditJudgmentDebtorParam;
import com.gxwebsoft.credit.service.CreditCompanyService;
import com.gxwebsoft.credit.service.CreditJudgmentDebtorService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -51,6 +53,9 @@ public class CreditJudgmentDebtorController extends BaseController {
@Resource
private BatchImportSupport batchImportSupport;
@Resource
private CreditCompanyService creditCompanyService;
@Operation(summary = "分页查询被执行人")
@GetMapping("/page")
public ApiResult<PageResult<CreditJudgmentDebtor>> page(CreditJudgmentDebtorParam param) {
@@ -143,6 +148,171 @@ public class CreditJudgmentDebtorController extends BaseController {
return fail("删除失败");
}
/**
* 根据企业名称匹配企业并更新 companyId匹配 CreditCompany.name / CreditCompany.matchName
*
* <p>默认仅更新 companyId 为空的记录;如需覆盖更新,传 onlyNull=false。</p>
*/
@PreAuthorize("hasAuthority('credit:creditJudgmentDebtor:update')")
@OperationLog
@Operation(summary = "根据企业名称匹配并更新companyId")
@PostMapping("/company-id/refresh")
public ApiResult<Map<String, Object>> refreshCompanyIdByCompanyName(
@RequestParam(value = "onlyNull", required = false, defaultValue = "true") Boolean onlyNull,
@RequestParam(value = "limit", required = false) Integer limit
) {
User loginUser = getLoginUser();
Integer currentTenantId = loginUser != null ? loginUser.getTenantId() : null;
// 1) 读取待处理数据(仅取必要字段,避免一次性拉全表字段)
var debtorQuery = creditJudgmentDebtorService.lambdaQuery()
.select(CreditJudgmentDebtor::getId, CreditJudgmentDebtor::getName, CreditJudgmentDebtor::getCompanyId, CreditJudgmentDebtor::getTenantId)
.eq(currentTenantId != null, CreditJudgmentDebtor::getTenantId, currentTenantId)
.isNotNull(CreditJudgmentDebtor::getName);
if (Boolean.TRUE.equals(onlyNull)) {
debtorQuery.isNull(CreditJudgmentDebtor::getCompanyId);
}
if (limit != null && limit > 0) {
debtorQuery.last("limit " + Math.min(limit, 200000));
}
List<CreditJudgmentDebtor> debtors = debtorQuery.list();
if (CollectionUtils.isEmpty(debtors)) {
Map<String, Object> result = new LinkedHashMap<>();
result.put("updated", 0);
result.put("matched", 0);
result.put("notFound", 0);
result.put("ambiguous", 0);
return success("无可更新数据", result);
}
// 2) 按租户维度匹配(避免管理员/跨租户场景误匹配)
Map<Integer, List<CreditJudgmentDebtor>> debtorsByTenant = new LinkedHashMap<>();
for (CreditJudgmentDebtor d : debtors) {
if (d == null) {
continue;
}
Integer tenantId = currentTenantId != null ? currentTenantId : d.getTenantId();
if (tenantId == null) {
// 未知租户下不做跨租户匹配,避免误更新
continue;
}
debtorsByTenant.computeIfAbsent(tenantId, k -> new ArrayList<>()).add(d);
}
// 3) 批量更新 companyId
int updated = 0;
int matched = 0;
int notFound = 0;
int ambiguous = 0;
final int batchSize = 500;
List<CreditJudgmentDebtor> updates = new ArrayList<>(batchSize);
final int inChunkSize = 900;
for (Map.Entry<Integer, List<CreditJudgmentDebtor>> entry : debtorsByTenant.entrySet()) {
Integer tenantId = entry.getKey();
List<CreditJudgmentDebtor> tenantDebtors = entry.getValue();
if (tenantId == null || CollectionUtils.isEmpty(tenantDebtors)) {
continue;
}
// 3.1) 查询当前租户下的 companyId 映射
LinkedHashMap<String, Integer> companyIdByName = new LinkedHashMap<>();
LinkedHashMap<String, Integer> ambiguousByName = new LinkedHashMap<>();
java.util.LinkedHashSet<String> nameSet = new java.util.LinkedHashSet<>();
for (CreditJudgmentDebtor d : tenantDebtors) {
String name = normalizeCompanyName(d != null ? d.getName() : null);
if (name != null) {
nameSet.add(name);
}
}
List<String> allNames = new ArrayList<>(nameSet);
for (int i = 0; i < allNames.size(); i += inChunkSize) {
List<String> chunk = allNames.subList(i, Math.min(allNames.size(), i + inChunkSize));
if (CollectionUtils.isEmpty(chunk)) {
continue;
}
List<CreditCompany> companies = creditCompanyService.lambdaQuery()
.select(CreditCompany::getId, CreditCompany::getName, CreditCompany::getMatchName, CreditCompany::getTenantId)
.eq(CreditCompany::getTenantId, tenantId)
.and(w -> w.in(CreditCompany::getName, chunk).or().in(CreditCompany::getMatchName, chunk))
.list();
for (CreditCompany c : companies) {
if (c == null || c.getId() == null) {
continue;
}
addCompanyNameMapping(companyIdByName, ambiguousByName, normalizeCompanyName(c.getName()), c.getId());
addCompanyNameMapping(companyIdByName, ambiguousByName, normalizeCompanyName(c.getMatchName()), c.getId());
}
}
// 3.2) 更新当前租户下的被执行人 companyId
for (CreditJudgmentDebtor d : tenantDebtors) {
String key = normalizeCompanyName(d != null ? d.getName() : null);
if (key == null) {
continue;
}
Integer amb = ambiguousByName.get(key);
if (amb != null && amb > 0) {
ambiguous++;
continue;
}
Integer companyId = companyIdByName.get(key);
if (companyId == null) {
notFound++;
continue;
}
matched++;
boolean needUpdate = d.getCompanyId() == null || !companyId.equals(d.getCompanyId());
if (Boolean.TRUE.equals(onlyNull)) {
needUpdate = d.getCompanyId() == null;
}
if (!needUpdate) {
continue;
}
CreditJudgmentDebtor patch = new CreditJudgmentDebtor();
patch.setId(d.getId());
patch.setCompanyId(companyId);
updates.add(patch);
if (updates.size() >= batchSize) {
updated += batchImportSupport.runInNewTx(() -> {
boolean ok = creditJudgmentDebtorService.updateBatchById(updates, batchSize);
return ok ? updates.size() : 0;
});
updates.clear();
}
}
}
// currentTenantId 为空时,租户缺失的数据不做匹配更新,避免误更新
if (currentTenantId == null) {
for (CreditJudgmentDebtor d : debtors) {
if (d != null && d.getTenantId() == null) {
notFound++;
}
}
}
if (!updates.isEmpty()) {
updated += batchImportSupport.runInNewTx(() -> {
boolean ok = creditJudgmentDebtorService.updateBatchById(updates, batchSize);
return ok ? updates.size() : 0;
});
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("updated", updated);
result.put("matched", matched);
result.put("notFound", notFound);
result.put("ambiguous", ambiguous);
return success("更新完成,更新" + updated + "", result);
}
/**
* 批量导入被执行人
*/
@@ -244,10 +414,32 @@ public class CreditJudgmentDebtorController extends BaseController {
if (isImportHeaderRow(param)) {
return true;
}
return ImportHelper.isBlank(param.getCaseNumber())
&& ImportHelper.isBlank(param.getName())
&& ImportHelper.isBlank(param.getName1())
&& ImportHelper.isBlank(param.getCode());
return ImportHelper.isBlank(param.getCaseNumber());
}
private static String normalizeCompanyName(String name) {
if (name == null) {
return null;
}
String v = name.replace(' ', ' ').trim();
return v.isEmpty() ? null : v;
}
private static void addCompanyNameMapping(Map<String, Integer> idByName,
Map<String, Integer> ambiguousByName,
String key,
Integer companyId) {
if (key == null || companyId == null) {
return;
}
Integer existing = idByName.get(key);
if (existing == null) {
idByName.put(key, companyId);
return;
}
if (!existing.equals(companyId)) {
ambiguousByName.put(key, 1);
}
}
private boolean isImportHeaderRow(CreditJudgmentDebtorImportParam param) {

View File

@@ -146,8 +146,10 @@ public class CreditJudicialDocumentController extends BaseController {
int successCount = 0;
try {
// 支持按选项卡名称导入默认读取“裁判文书”sheet不存在则回退到第 0 个sheet
int sheetIndex = ExcelImportSupport.findSheetIndex(file, "裁判文书", 0);
ExcelImportSupport.ImportResult<CreditJudicialDocumentImportParam> importResult = ExcelImportSupport.read(
file, CreditJudicialDocumentImportParam.class, this::isEmptyImportRow);
file, CreditJudicialDocumentImportParam.class, this::isEmptyImportRow, sheetIndex);
List<CreditJudicialDocumentImportParam> list = importResult.getData();
int usedTitleRows = importResult.getTitleRows();
int usedHeadRows = importResult.getHeadRows();
@@ -544,8 +546,7 @@ public class CreditJudicialDocumentController extends BaseController {
if (param == null) {
return true;
}
return ImportHelper.isBlank(param.getCaseNumber())
&& ImportHelper.isBlank(param.getCauseOfAction());
return ImportHelper.isBlank(param.getCaseNumber());
}
private CreditJudicialDocument convertImportParamToEntity(CreditJudicialDocumentImportParam param) {

View File

@@ -228,10 +228,10 @@ public class CreditJudiciaryController extends BaseController {
}
int excelRowNumber = i + 1 + usedTitleRows + usedHeadRows;
// 验证必填字段
if (item.getName() == null || item.getName().trim().isEmpty()) {
errorMessages.add("" + excelRowNumber + "行:项目名称不能为空");
continue;
}
// if (item.getName() == null || item.getName().trim().isEmpty()) {
// errorMessages.add("第" + excelRowNumber + "行:项目名称不能为空");
// continue;
// }
if (item.getCode() == null || item.getCode().trim().isEmpty()) {
errorMessages.add("" + excelRowNumber + "行:唯一标识不能为空");
continue;

View File

@@ -4,7 +4,7 @@
<!-- 关联查询sql -->
<sql id="selectSql">
SELECT a.*, b.name AS mainCompanyName, u.real_name AS realName
SELECT a.*, b.name AS companyName, u.real_name AS realName
FROM credit_competitor a
LEFT JOIN credit_company b ON a.company_id = b.id
LEFT JOIN gxwebsoft_core.sys_user u ON a.user_id = u.user_id

View File

@@ -66,7 +66,7 @@
<if test="param.keywords != null">
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
OR a.case_number = #{param.keywords}
OR b.name LIKE CONCAT('%', #{param.keywords}, '%')
OR b.name = #{param.keywords}
)
</if>
</where>

View File

@@ -63,6 +63,7 @@
<if test="param.keywords != null">
AND (a.comments LIKE CONCAT('%', #{param.keywords}, '%')
OR b.name = #{param.keywords}
OR a.main_bodyName = #{param.keywords}
)
</if>
</where>

View File

@@ -1,7 +1,8 @@
package com.gxwebsoft.shop.controller;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.shop.entity.ShopUserReferee;
import com.gxwebsoft.common.core.Constants;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.shop.service.ShopDealerRefereeService;
import com.gxwebsoft.shop.entity.ShopDealerReferee;
import com.gxwebsoft.shop.param.ShopDealerRefereeParam;
@@ -63,20 +64,33 @@ public class ShopDealerRefereeController extends BaseController {
return success(shopDealerRefereeService.getByUserIdRel(userId));
}
@PreAuthorize("hasAuthority('shop:shopDealerReferee:save')")
@OperationLog
@Operation(summary = "添加分销商推荐关系")
@Operation(summary = "绑定分销商推荐关系(一级,幂等)")
@PostMapping()
public ApiResult<?> save(@RequestBody ShopDealerReferee shopDealerReferee) {
// 记录当前登录用户id
User loginUser = getLoginUser();
if (loginUser != null) {
shopDealerReferee.setUserId(loginUser.getUserId());
if (loginUser == null) {
throw new BusinessException(Constants.UNAUTHENTICATED_CODE, Constants.UNAUTHENTICATED_MSG);
}
if (shopDealerRefereeService.save(shopDealerReferee)) {
return success("添加成功");
// 安全仅信任token中的userId若body.userId存在且不一致则拒绝
if (shopDealerReferee.getUserId() != null && !shopDealerReferee.getUserId().equals(loginUser.getUserId())) {
throw new BusinessException("非法userId参数");
}
return fail("添加失败");
Integer tenantId = getTenantId();
if (tenantId == null) {
throw new BusinessException("TenantId不能为空");
}
boolean created = shopDealerRefereeService.bindFirstLevel(
shopDealerReferee.getDealerId(),
loginUser.getUserId(),
tenantId,
shopDealerReferee.getSource(),
shopDealerReferee.getScene()
);
return success(created ? "绑定成功" : "已绑定");
}
@PreAuthorize("hasAuthority('shop:shopDealerReferee:update')")

View File

@@ -67,6 +67,12 @@ public class ShopDealerReferee implements Serializable {
@Schema(description = "推荐关系层级(1,2,3)")
private Integer level;
@Schema(description = "来源(如 goods_share)")
private String source;
@Schema(description = "场景参数(用于溯源统计,如 inviter=<inviter>&source=goods_share&t=<t>)")
private String scene;
@Schema(description = "商城ID")
private Integer tenantId;

View File

@@ -40,4 +40,17 @@ public interface ShopDealerRefereeService extends IService<ShopDealerReferee> {
ShopDealerReferee getByIdRel(Integer id);
ShopDealerReferee getByUserIdRel(Integer userId);
/**
* 绑定分销商推荐关系(一级)。
* <p>
* 规则:
* <ul>
* <li>仅首次绑定生效:已存在(同一tenant、同一user、level=1)则不改绑</li>
* <li>建议配合唯一索引保证并发幂等UNIQUE(tenant_id, user_id, level)</li>
* </ul>
*
* @return true=本次新绑定写入成功false=已绑定(幂等)
*/
boolean bindFirstLevel(Integer dealerId, Integer userId, Integer tenantId, String source, String scene);
}

View File

@@ -1,14 +1,23 @@
package com.gxwebsoft.shop.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.exception.BusinessException;
import com.gxwebsoft.shop.mapper.ShopDealerRefereeMapper;
import com.gxwebsoft.shop.entity.ShopDealerUser;
import com.gxwebsoft.shop.service.ShopDealerUserService;
import com.gxwebsoft.shop.service.ShopDealerRefereeService;
import com.gxwebsoft.shop.entity.ShopDealerReferee;
import com.gxwebsoft.shop.param.ShopDealerRefereeParam;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
/**
@@ -20,6 +29,9 @@ import java.util.List;
@Service
public class ShopDealerRefereeServiceImpl extends ServiceImpl<ShopDealerRefereeMapper, ShopDealerReferee> implements ShopDealerRefereeService {
@Resource
private ShopDealerUserService shopDealerUserService;
@Override
public PageResult<ShopDealerReferee> pageRel(ShopDealerRefereeParam param) {
PageParam<ShopDealerReferee, ShopDealerRefereeParam> page = new PageParam<>(param);
@@ -52,4 +64,61 @@ public class ShopDealerRefereeServiceImpl extends ServiceImpl<ShopDealerRefereeM
return param.getOne(baseMapper.selectListRel(param));
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean bindFirstLevel(Integer dealerId, Integer userId, Integer tenantId, String source, String scene) {
if (dealerId == null || dealerId <= 0) {
throw new BusinessException("dealerId不能为空");
}
if (userId == null || userId <= 0) {
throw new BusinessException("userId不能为空");
}
if (tenantId == null || tenantId <= 0) {
throw new BusinessException("TenantId不能为空");
}
if (dealerId.equals(userId)) {
throw new BusinessException("禁止绑定自己");
}
// 已绑定则直接返回(幂等,不改绑)
ShopDealerReferee existed = getOne(new LambdaQueryWrapper<ShopDealerReferee>()
.eq(ShopDealerReferee::getTenantId, tenantId)
.eq(ShopDealerReferee::getUserId, userId)
.eq(ShopDealerReferee::getLevel, 1)
.last("limit 1"));
if (existed != null) {
return false;
}
// 校验邀请人(分销商)存在且有效
ShopDealerUser dealerUser = shopDealerUserService.getOne(new LambdaQueryWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, dealerId)
.eq(ShopDealerUser::getIsDelete, 0)
.last("limit 1"));
if (dealerUser == null) {
throw new BusinessException("邀请人不存在或已失效");
}
LocalDateTime now = LocalDateTime.now();
ShopDealerReferee entity = new ShopDealerReferee();
entity.setDealerId(dealerId);
entity.setUserId(userId);
entity.setLevel(1);
entity.setTenantId(tenantId);
entity.setSource(source);
entity.setScene(scene);
entity.setCreateTime(now);
entity.setUpdateTime(now);
try {
// 并发下依赖唯一索引保证幂等;重复写入视为已绑定
return save(entity);
} catch (DuplicateKeyException e) {
return false;
} catch (DataIntegrityViolationException e) {
return false;
}
}
}