fix: 消除 GROUP BY 非分组列报错并修复连锁标识脏数据

MySQL ONLY_FULL_GROUP_BY 下,MyBatis-Plus 的 list(wrapper) 默认 SELECT
实体全部字段,配合 groupBy 后非分组列既不在 GROUP BY 中也不函数依赖分组键,
导致 SQLSyntaxErrorException。即便放宽模式,这些字段也只是组内随机一行的值。

根因是借用 SQL GROUP BY 实现"按某列去重",共 7 处,统一改为一次查询 +
内存分组,返回值结构与原实现一致:

- MerchantServiceImpl.groupByChain(本次线上报错点)
- YxeProductServiceImpl.listByGroup / listByChain / listByCate(2 个重载)
- YxeTaskProductServiceImpl.productGroupByTask
- YxeShareServiceImpl.listByMerchant(2 个重载)

分组父节点用独立副本(BeanUtils.copyProperties)而非复用子列表实例,
否则 parent.setChildren(children) 会造成对象自引用,JSON 序列化报
"Direct self-reference leading to cycle"。

附带消除 N+1:groupByChain 原为 1+12 次查询、重复拉取 8105 行。

同时 groupByChain 返回稳定 chainId(连锁名,与字典 sys_dict_data.dict_id=175
的 dict_data_code 一致),从源头消除前端回退到 `chain_${index}` 位置型占位符,
避免其经审核端写入 yxe_self_report.chain_id 后按连锁等值筛选永久漏查。

SQL: fix_self_report_chain_id_placeholder_2026-10-11.sql(本地已执行 5 行)
This commit is contained in:
2026-10-11 16:34:21 +08:00
parent 8e1d552eed
commit 92baf41073
5 changed files with 162 additions and 72 deletions
@@ -0,0 +1,42 @@
-- 用途:修正 yxe_self_report.chain_id 中残留的位置型占位符(chain_0、chain_3、chain_6 …)
-- 日期:2026-10-11
-- 背景:前端 normalizeMerchantChain 在接口未返回 chainId 时会回退成 `chain_${index}`,
-- 该值经审核端 saveMerchantEdit 写入 chain_id 落库。而按连锁筛选走的是等值匹配
-- (YxeSelfReportMapper.xml: a.chain_id = #{param.chainId}),占位符导致这些记录
-- 在按连锁筛选时被永久漏掉。本系统没有连锁表,chain 名称即事实上的连锁标识
-- (连锁字典 sys_dict_data.dict_id=175 的 dict_data_code 同样是连锁名)。
-- 影响:5 行(chain_6 -> 老百姓 3 行;chain_3 -> 百姓人家 2 行)
-- 性质:幂等,可重复执行;以 chain_name 为准回填 chain_id
-- 配套代码:MerchantServiceImpl.groupByChain 返回稳定 chainId;
-- AuditSelfReportItem.vue 兜底链改用连锁名并在写入前拦截 chain_ 前缀
-- 执行前核对(应为 5 行)
SELECT id, chain_id, chain_name
FROM yxe_self_report
WHERE chain_id REGEXP '^chain_[0-9]+$'
ORDER BY id;
-- 修正
UPDATE yxe_self_report
SET chain_id = chain_name
WHERE chain_id REGEXP '^chain_[0-9]+$'
AND chain_name IS NOT NULL
AND chain_name <> '';
-- 执行后核对(remaining_placeholder 与 mismatch 均应为 0)
SELECT SUM(chain_id REGEXP '^chain_[0-9]+$') AS remaining_placeholder,
SUM(chain_id <> chain_name) AS mismatch,
COUNT(*) AS total
FROM yxe_self_report;
-- ============================================================
-- 执行记录
-- ============================================================
-- 2026-10-11 本地开发库(127.0.0.1:3306/yaoxiaoer,root)
-- 执行结果:受影响 5 行
-- 执行后核对:remaining_placeholder = 0,mismatch = 0
-- 功能验证:按连锁筛选命中数
-- chainId=老百姓 279 -> 282(找回 id 119、130、169)
-- chainId=百姓人家 174 -> 176(找回 id 138、139)
--
-- 生产库:尚未执行。需经确认后按同一条语句复核执行。
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -78,22 +79,31 @@ public class MerchantServiceImpl extends ServiceImpl<MerchantMapper, Merchant> i
@Override
public List<Map<String, Object>> groupByChain() {
List<Merchant> merchantList = list(
// 一次性查出全部有效商户,在内存中按 chain 分组。
// 这里不能用 SQL 的 GROUP BY chain:list() 会 SELECT 实体全部字段,
// 而 id/name/code 等列既不在 GROUP BY 中、也不函数依赖于 chain,
// 在 ONLY_FULL_GROUP_BY 下直接报错;即便放宽模式,取到的也只是组内随机一行的值。
List<Merchant> allMerchant = list(
new LambdaQueryWrapper<Merchant>()
.isNotNull(Merchant::getChain)
.groupBy(Merchant::getChain)
);
List<Map<String, Object>> miniList = new ArrayList<>();
for (Merchant merchant : merchantList) {
Map<String, List<Merchant>> chainGroup = new LinkedHashMap<>();
for (Merchant merchant : allMerchant) {
chainGroup.computeIfAbsent(merchant.getChain(), k -> new ArrayList<>()).add(merchant);
}
List<Map<String, Object>> miniList = new ArrayList<>();
for (Map.Entry<String, List<Merchant>> entry : chainGroup.entrySet()) {
Map<String, Object> map = new HashMap<>();
map.put("chain", merchant.getChain());
List<Merchant> childrenMerchant = list(
new LambdaQueryWrapper<Merchant>()
.eq(Merchant::getChain, merchant.getChain())
);
map.put("chain", entry.getKey());
// 同时返回 chainId。本系统没有连锁表,chain 名称就是事实上的连锁标识
// (连锁字典 sys_dict_data.dict_id=175 的 dict_data_code 同样是连锁名)。
// 显式给出稳定 id,前端就不必回退到 `chain_${index}` 这种位置型占位符;
// 那种占位符一旦被写进 yxe_self_report.chain_id,按连锁等值筛选就查不到该记录。
map.put("chainId", entry.getKey());
List<Map<String, Object>> childrenList = new ArrayList<>();
for (Merchant merchantChild : childrenMerchant) {
for (Merchant merchantChild : entry.getValue()) {
Map<String, Object> mapChild = new HashMap<>();
mapChild.put("id", merchantChild.getId());
mapChild.put("name", merchantChild.getName());
@@ -12,6 +12,7 @@ import com.gxwebsoft.yxe.entity.YxeProduct;
import com.gxwebsoft.yxe.param.YxeProductParam;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -20,7 +21,10 @@ import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -109,15 +113,20 @@ public class YxeProductServiceImpl extends ServiceImpl<YxeProductMapper, YxeProd
@Override
public List<YxeProduct> listByGroup(YxeProductParam param) {
LambdaQueryWrapper<YxeProduct> queryWrapper = new LambdaQueryWrapper<YxeProduct>()
.groupBy(YxeProduct::getChain);
queryWrapper.eq(YxeProduct::getStatus, 0);
.eq(YxeProduct::getStatus, 0);
if (param != null && param.getChain() != null) {
queryWrapper.eq(YxeProduct::getChain, param.getChain());
}
List<YxeProduct> list = list(queryWrapper);
// 按 chain 去重:查出符合条件的产品后在内存里归组。
// 不能用 SQL 的 GROUP BY chain —— list() 会 SELECT 实体全部字段,
// 这些非分组列在 ONLY_FULL_GROUP_BY 下非法,放宽模式也只是取组内随机一行的值。
// 注意:这里不加 chain IS NOT NULL,保持与原 GROUP BY 行为一致(NULL 自成一组)。
List<YxeProduct> list = distinctByKey(list(queryWrapper), YxeProduct::getChain);
List<YxeProduct> all = list();
for (YxeProduct product : list) {
product.setChildren(all.stream().filter(p -> p.getChain().equals(product.getChain())).collect(Collectors.toList()));
product.setChildren(all.stream()
.filter(p -> Objects.equals(p.getChain(), product.getChain()))
.collect(Collectors.toList()));
}
return list;
}
@@ -210,67 +219,77 @@ public class YxeProductServiceImpl extends ServiceImpl<YxeProductMapper, YxeProd
@Override
public List<YxeProduct> listByChain() {
List<YxeProduct> list = list(
// 一次查出全部有效产品,在内存中按 chain 归组,避免 SQL GROUP BY 选非分组列。
List<YxeProduct> all = list(
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.isNotNull(YxeProduct::getChain)
.groupBy(YxeProduct::getChain)
.orderByDesc(YxeProduct::getCreateTime)
);
for (YxeProduct product : list) {
product.setChildren(list(
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.eq(YxeProduct::getChain, product.getChain())
.orderByDesc(YxeProduct::getCreateTime)
));
Map<String, List<YxeProduct>> chainGroup = new LinkedHashMap<>();
for (YxeProduct product : all) {
chainGroup.computeIfAbsent(product.getChain(), k -> new ArrayList<>()).add(product);
}
List<YxeProduct> list = new ArrayList<>();
for (Map.Entry<String, List<YxeProduct>> entry : chainGroup.entrySet()) {
List<YxeProduct> children = entry.getValue();
// 父节点必须是组内首条产品的“独立副本”。原实现中父行来自 GROUP BY 查询、
// 子列表来自另一次查询,是两个不同对象;若直接把子列表中的实例当父节点,
// parent.setChildren(children) 会让对象引用自身,JSON 序列化时报
// "Direct self-reference leading to cycle"。
YxeProduct parent = new YxeProduct();
BeanUtils.copyProperties(children.get(0), parent);
parent.setChildren(children);
list.add(parent);
}
return list;
}
@Override
public List<YxeProduct> listByCate() {
List<YxeProduct> list = list(
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.isNotNull(YxeProduct::getCate)
.groupBy(YxeProduct::getCate)
);
for (YxeProduct product : list) {
product.setChildren(list(
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.eq(YxeProduct::getCate, product.getCate())
.orderByDesc(YxeProduct::getCreateTime)
));
}
return list;
return listByCate(null);
}
@Override
public List<YxeProduct> listByCate(YxeProductParam param) {
LambdaQueryWrapper<YxeProduct> wrapper =
// 一次查出全部有效产品,在内存中按 cate 归组,避免 SQL GROUP BY 选非分组列。
LambdaQueryWrapper<YxeProduct> allWrapper =
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.isNotNull(YxeProduct::getCate)
.groupBy(YxeProduct::getCate);
.orderByDesc(YxeProduct::getCreateTime);
if (param != null) {
if (param.getChain() != null) wrapper.eq(YxeProduct::getChain, param.getChain());
if (param.getType() != null) wrapper.eq(YxeProduct::getType, param.getType());
if (param.getChain() != null) allWrapper.eq(YxeProduct::getChain, param.getChain());
if (param.getType() != null) allWrapper.eq(YxeProduct::getType, param.getType());
}
List<YxeProduct> list = list(wrapper);
for (YxeProduct product : list) {
LambdaQueryWrapper<YxeProduct> childWrapper =
new LambdaQueryWrapper<YxeProduct>()
.eq(YxeProduct::getStatus, 0)
.eq(YxeProduct::getCate, product.getCate())
.orderByDesc(YxeProduct::getCreateTime);
if (param != null) {
if (param.getChain() != null) childWrapper.eq(YxeProduct::getChain, param.getChain());
if (param.getType() != null) childWrapper.eq(YxeProduct::getType, param.getType());
}
product.setChildren(list(childWrapper));
List<YxeProduct> all = list(allWrapper);
Map<String, List<YxeProduct>> cateGroup = new LinkedHashMap<>();
for (YxeProduct product : all) {
cateGroup.computeIfAbsent(product.getCate(), k -> new ArrayList<>()).add(product);
}
List<YxeProduct> list = new ArrayList<>();
for (Map.Entry<String, List<YxeProduct>> entry : cateGroup.entrySet()) {
List<YxeProduct> children = entry.getValue();
// 同上:父节点用独立副本,避免对象自引用导致 JSON 序列化成环。
YxeProduct parent = new YxeProduct();
BeanUtils.copyProperties(children.get(0), parent);
parent.setChildren(children);
list.add(parent);
}
return list;
}
/**
* 按指定字段去重,保留每组首次出现的元素(保持原查询顺序)。
* 用于替代原来用 SQL GROUP BY 实现的“按某列取唯一值”语义。
*/
private List<YxeProduct> distinctByKey(List<YxeProduct> source, java.util.function.Function<YxeProduct, String> keyGetter) {
Map<String, YxeProduct> seen = new LinkedHashMap<>();
for (YxeProduct item : source) {
seen.putIfAbsent(keyGetter.apply(item), item);
}
return new ArrayList<>(seen.values());
}
}
@@ -204,23 +204,32 @@ public class YxeShareServiceImpl extends ServiceImpl<YxeShareMapper, YxeShare> i
@Override
public List<YxeShare> listByMerchant() {
return list(
new LambdaQueryWrapper<YxeShare>()
.eq(YxeShare::getStatus, 1)
.groupBy(YxeShare::getMerchantName)
);
return listByMerchant(null);
}
@Override
public List<YxeShare> listByMerchant(YxeShareParam param) {
// 一次查出全部已通过的分享记录,在内存中按 merchantName 去重。
// 不能用 SQL GROUP BY merchant_name:list() 会 SELECT 实体全部字段,
// 这些非分组列在 ONLY_FULL_GROUP_BY 下非法,放宽模式也只是取组内随机一行的值。
LambdaQueryWrapper<YxeShare> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(YxeShare::getStatus, 1);
if (param.getProvince() != null && param.getCity() != null) {
if (param != null && param.getProvince() != null && param.getCity() != null) {
queryWrapper.eq(YxeShare::getProvince, param.getProvince());
queryWrapper.eq(YxeShare::getCity, param.getCity());
}
queryWrapper.groupBy(YxeShare::getMerchantName);
return list(queryWrapper);
return distinctByMerchantName(list(queryWrapper));
}
/**
* 按 merchantName 去重,保留每个门店首次出现的记录(保持原查询顺序)。
*/
private List<YxeShare> distinctByMerchantName(List<YxeShare> source) {
Map<String, YxeShare> seen = new LinkedHashMap<>();
for (YxeShare item : source) {
seen.putIfAbsent(item.getMerchantName(), item);
}
return new ArrayList<>(seen.values());
}
@Override
@@ -10,6 +10,7 @@ import com.gxwebsoft.yxe.entity.YxeTaskProduct;
import com.gxwebsoft.yxe.param.YxeTaskProductParam;
import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@@ -130,23 +131,32 @@ public class YxeTaskProductServiceImpl extends ServiceImpl<YxeTaskProductMapper,
}
List<YxeProduct> productList = new ArrayList<>();
if (!productIds.isEmpty()) {
productList = productService.list(new LambdaQueryWrapper<YxeProduct>()
.in(YxeProduct::getId, productIds)
.groupBy(YxeProduct::getMakerAbb)
// 取出任务下的全部产品后在内存里按 makerAbb 归组。
// 不能用 SQL GROUP BY:list() 会 SELECT 实体全部字段,这些非分组列在
// ONLY_FULL_GROUP_BY 下非法,放宽模式也只是取组内随机一行的值。
List<YxeProduct> allProducts = productService.list(
new LambdaQueryWrapper<YxeProduct>()
.in(YxeProduct::getId, productIds)
);
for (YxeProduct product : productList) {
List<YxeProduct> children = productService.list(
new LambdaQueryWrapper<YxeProduct>()
.in(YxeProduct::getId, productIds)
.eq(YxeProduct::getMakerAbb, product.getMakerAbb())
);
Map<String, List<YxeProduct>> makerGroup = new LinkedHashMap<>();
for (YxeProduct product : allProducts) {
makerGroup.computeIfAbsent(product.getMakerAbb(), k -> new ArrayList<>()).add(product);
}
for (Map.Entry<String, List<YxeProduct>> entry : makerGroup.entrySet()) {
List<YxeProduct> children = entry.getValue();
for (YxeProduct child : children) {
YxeTaskProduct taskProduct = list.stream().filter(item -> item.getProductId().equals(child.getId())).findFirst().orElse(null);
if (taskProduct != null) {
child.setTaskIncome(taskProduct.getAmount());
}
}
product.setChildren(children);
// 父节点必须是组内首条产品的“独立副本”:原实现中父行来自 GROUP BY 查询、
// 子列表来自另一次查询,是两个不同对象;直接复用子列表实例会让对象自引用,
// JSON 序列化时报 "Direct self-reference leading to cycle"。
YxeProduct parent = new YxeProduct();
BeanUtils.copyProperties(children.get(0), parent);
parent.setChildren(children);
productList.add(parent);
}
}
return productList;