diff --git a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java index dbf518b..7ab8a04 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java +++ b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java @@ -6,6 +6,7 @@ import apelet.common.core.object.MyOrderParam; import apelet.common.core.object.MyPageData; import apelet.common.core.object.MyPageParam; import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; import apelet.common.core.util.MyPageUtil; import apelet.common.generator.dto.OnlCgreportHeadDto; import apelet.common.generator.dto.SQLParseDto; @@ -137,6 +138,17 @@ public class OnlCgreportHeadController { wq.like(StringUtils.isNotEmpty(OnlCgreportHeadDto.getCode()),OnlCgreportHead::getCode,OnlCgreportHeadDto.getCode()) .like(StringUtils.isNotEmpty(OnlCgreportHeadDto.getName()),OnlCgreportHead::getName,OnlCgreportHeadDto.getName()); } + //租户隔离(PRD FR-4.15):A 端只看本租户,B 端只看 tenant_id IS NULL + Long tenantId = TokenData.currentTenantId(); + if (tenantId == null) { + wq.isNull(OnlCgreportHead::getTenantId); + } else { + wq.eq(OnlCgreportHead::getTenantId, tenantId); + } + String appCode = TokenData.currentAppCode(); + if (StringUtils.isNotBlank(appCode)) { + wq.eq(OnlCgreportHead::getAppCode, appCode); + } List list = onlCgreportHeadService.list(wq); long totalCount = 0L; if (list instanceof com.github.pagehelper.Page) { @@ -152,7 +164,8 @@ public class OnlCgreportHeadController { */ @GetMapping(value = "/getById") public ResponseResult getById(@RequestParam("id") String id) { - return ResponseResult.success(onlCgreportHeadService.getById(id)); + //归属校验:不存在或不属于当前租户返回 404(PRD FR-4.16) + return ResponseResult.success(onlCgreportHeadService.getOwnedHead(id)); } /** * 查询对应sql的字段 diff --git a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java index 782cc84..4a2013f 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java +++ b/common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java @@ -7,6 +7,7 @@ import apelet.common.core.object.MyOrderParam; import apelet.common.core.object.MyPageData; import apelet.common.core.object.MyPageParam; import apelet.common.core.object.ResponseResult; +import apelet.common.core.object.TokenData; import apelet.common.core.util.MyPageUtil; import apelet.common.eas.service.impl.EASSyncService; import apelet.common.generator.dto.OnlFormCodeDto; @@ -101,6 +102,18 @@ public class OnlFormHeadController { .like(ObjectUtil.isNotEmpty(dto.getTableType()), OnlFormHead::getTableType, dto.getTableType()) .like(ObjectUtil.isNotEmpty(dto.getTableTxt()), OnlFormHead::getTableTxt, dto.getTableTxt()); } + //租户隔离:A 端只看本租户,B 端只看 tenant_id IS NULL 的管理端数据 + Long tenantId = TokenData.currentTenantId(); + if (tenantId == null) { + wq.isNull(OnlFormHead::getTenantId); + } else { + wq.eq(OnlFormHead::getTenantId, tenantId); + } + //app_code 为可选维度:有值才过滤(PRD FR-4.14) + String appCode = TokenData.currentAppCode(); + if (StringUtils.isNotBlank(appCode)) { + wq.eq(OnlFormHead::getAppCode, appCode); + } wq.select(OnlFormHead.class, i -> !i.getProperty().equals("oldData")); List list = onlFormHeadService.list(wq); long totalCount = 0L; @@ -387,7 +400,7 @@ public class OnlFormHeadController { * @return */ @GetMapping(value = "/onlFormSync") - ResponseResult onlFormSync(@RequestParam("billType") String billType, @RequestParam("id") Long id) { + public ResponseResult onlFormSync(@RequestParam("billType") String billType, @RequestParam("id") Long id) { try { if (StringUtils.isEmpty(billType)) { return ResponseResult.error("500", "标识为空 "); diff --git a/common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java b/common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java index ec3280d..4ff74be 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java +++ b/common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java @@ -55,4 +55,8 @@ public class OnlCgreportHead extends BaseModel implements Serializable { @Schema(description = "租户id") @TableField(value = "tenant_id") private Long tenantId; + + @Schema(description = "应用编码") + @TableField(value = "app_code") + private String appCode; } diff --git a/common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java b/common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java index b155e65..073a140 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java +++ b/common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java @@ -118,6 +118,10 @@ public class OnlFormHead extends BaseModel implements Serializable { @TableField(value = "tenant_id") private Long tenantId; + @Schema(description = "应用编码") + @TableField(value = "app_code") + private String appCode; + @Schema(description = "数据库属性分录") @TableField(exist = false) private List onlFormFieldList; diff --git a/common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java b/common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java index b9e409c..420b8b0 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java +++ b/common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java @@ -24,6 +24,11 @@ import java.util.Map; public interface IOnlCgreportHeadService extends IService { Map getSql(String sql, String dbKey); + /** + * 按 id 查询并校验租户归属,不存在或不属于当前租户时抛 404。 + */ + OnlCgreportHead getOwnedHead(String id); + List getFieldsById(String id); List getParamsById(String id); diff --git a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java index 4a5ba1c..17a33fd 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java +++ b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java @@ -1,5 +1,6 @@ package apelet.common.generator.service.impl; +import apelet.common.core.exception.DataNotFoundException; import apelet.common.core.object.MyPageParam; import apelet.common.core.object.TokenData; import apelet.common.dbutil.util.SQLStringBuffer; @@ -66,6 +67,49 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl wrapper) { + Long tenantId = TokenData.currentTenantId(); + if (tenantId == null) { + wrapper.isNull(OnlCgreportHead::getTenantId); + } else { + wrapper.eq(OnlCgreportHead::getTenantId, tenantId); + } + String appCode = TokenData.currentAppCode(); + if (StringUtils.isNotBlank(appCode)) { + wrapper.eq(OnlCgreportHead::getAppCode, appCode); + } + } + + /** + * 按 id 查询并校验归属:不存在或不属于当前租户统一抛 404。 + * + *

无 HTTP 请求上下文(MQ、定时任务)不做租户校验,避免阻断同步链路。

+ */ + @Override + public OnlCgreportHead getOwnedHead(String id) { + OnlCgreportHead head = StringUtils.isEmpty(id) ? null : this.getById(id); + if (head == null) { + throw new DataNotFoundException(); + } + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null) { + return head; + } + if (!Objects.equals(head.getTenantId(), tokenData.getTenantId())) { + throw new DataNotFoundException(); + } + //app_code 为可选维度:请求未携带 appCode 时不校验(FR-4.14) + String appCode = tokenData.getAppCode(); + if (StringUtils.isNotBlank(appCode) && !Objects.equals(head.getAppCode(), appCode)) { + throw new DataNotFoundException(); + } + return head; + } + @Override public Map getSql(String sql, String dbKey) { //检查sql是否有数据 @@ -191,6 +235,8 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl(); } + //先校验父表归属,子表只通过 cgrhead_id 关联(子表不加 tenant_id) + getOwnedHead(id); LambdaQueryWrapper wq = new LambdaQueryWrapper() .eq(StringUtils.isNotEmpty(id), OnlCgreportItem::getCgrheadId, id); return onlCgreportItemService.list(wq); @@ -201,6 +247,7 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl(); } + getOwnedHead(id); LambdaQueryWrapper wq = new LambdaQueryWrapper() .eq(StringUtils.isNotEmpty(id), OnlCgreportParam::getCgrheadId, id); return onlCgreportParamService.list(wq); @@ -210,17 +257,33 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl items = dto.getItems(); List params = dto.getParams(); if (StringUtils.isNotEmpty(id)) { + //新增报表时子表行一定是新行:清掉前端传来的 id,避免复用他行主键导致主键冲突(任务 #8) if (ObjectUtil.isNotEmpty(items)) { - List itemList = items.parallelStream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList()); + List itemList = items.stream().peek(x -> { + x.setId(null); + x.setCgrheadId(id); + }).collect(Collectors.toList()); onlCgreportItemService.saveBatch(itemList); } if (ObjectUtil.isNotEmpty(params)) { - List paramList = params.parallelStream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList()); + List paramList = params.stream().peek(x -> { + x.setId(null); + x.setCgrheadId(id); + }).collect(Collectors.toList()); onlCgreportParamService.saveBatch(paramList); } return id; @@ -231,31 +294,137 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl items = dto.getItems(); - List params = dto.getParams(); - //查询原数据没改动就不更新 - if (StringUtils.isNotEmpty(id)) { - //删除原有数据 - onlCgreportItemService.remove(new LambdaQueryWrapper().eq(OnlCgreportItem::getCgrheadId, id)); - onlCgreportParamService.remove(new LambdaQueryWrapper().eq(OnlCgreportParam::getCgrheadId, id)); - //重新添加 - if (ObjectUtil.isNotEmpty(params)) { - onlCgreportParamService.saveOrUpdateBatch(params.stream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList())); + if (StringUtils.isEmpty(id)) { + return "修改成功"; + } + //子表改为增量 upsert(任务 #8):不再无条件“先全删再重插”。 + // 1) items/params 为 null 表示本次没有提交该子表,库中配置保持不变; + // 2) 管理端(tenant_id 为空)提交的是完整配置,删除本次未提交的行,保证删字段/删参数能落库; + // 3) 租户端(tenant_id 有值)只 upsert 不做物理删除,与任务 #5 的口径一致。 + boolean admin = origin.getTenantId() == null; + saveReportItems(id, dto.getItems(), admin); + saveReportParams(id, dto.getParams(), admin); + return id; + } + + /** + * 增量保存报表字段(任务 #8)。 + * + * @param headId 报表主键Id。 + * @param items 本次提交的字段,null 表示本次未提交。 + * @param admin 是否管理端;管理端以提交内容为准,会删除本次未提交的行。 + */ + private void saveReportItems(String headId, List items, boolean admin) { + if (items == null) { + return; + } + Map existing = this.listReportItems(headId); + List toSave = new ArrayList<>(items.size()); + Set submittedIds = new HashSet<>(); + for (OnlCgreportItem item : items) { + //只允许更新本报表下的行,伪造或来自其它报表的 id 一律按新增处理,避免把他行数据挂到当前报表 + if (StringUtils.isEmpty(item.getId()) || !existing.containsKey(item.getId())) { + item.setId(null); + } else { + submittedIds.add(item.getId()); } - if (ObjectUtil.isNotEmpty(items)) { - onlCgreportItemService.saveOrUpdateBatch(items.stream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList())); + item.setCgrheadId(headId); + toSave.add(item); + } + if (admin) { + List removeIds = existing.keySet().stream() + .filter(existingId -> !submittedIds.contains(existingId)) + .collect(Collectors.toList()); + if (!removeIds.isEmpty()) { + onlCgreportItemService.removeByIds(removeIds); } - return id; } - return "修改成功"; + if (!toSave.isEmpty()) { + onlCgreportItemService.saveOrUpdateBatch(toSave); + } + } + + /** + * 增量保存报表参数(任务 #8),口径同 {@link #saveReportItems(String, List, boolean)}。 + * + * @param headId 报表主键Id。 + * @param params 本次提交的参数,null 表示本次未提交。 + * @param admin 是否管理端;管理端以提交内容为准,会删除本次未提交的行。 + */ + private void saveReportParams(String headId, List params, boolean admin) { + if (params == null) { + return; + } + Map existing = this.listReportParams(headId); + List toSave = new ArrayList<>(params.size()); + Set submittedIds = new HashSet<>(); + for (OnlCgreportParam param : params) { + if (StringUtils.isEmpty(param.getId()) || !existing.containsKey(param.getId())) { + param.setId(null); + } else { + submittedIds.add(param.getId()); + } + param.setCgrheadId(headId); + toSave.add(param); + } + if (admin) { + List removeIds = existing.keySet().stream() + .filter(existingId -> !submittedIds.contains(existingId)) + .collect(Collectors.toList()); + if (!removeIds.isEmpty()) { + onlCgreportParamService.removeByIds(removeIds); + } + } + if (!toSave.isEmpty()) { + onlCgreportParamService.saveOrUpdateBatch(toSave); + } + } + + /** + * 查询报表下的全部字段,按主键索引。 + * + * @param headId 报表主键Id。 + * @return 主键到字段对象的映射。 + */ + private Map listReportItems(String headId) { + List list = onlCgreportItemService.list( + new LambdaQueryWrapper().eq(OnlCgreportItem::getCgrheadId, headId)); + Map result = new HashMap<>(list.size()); + for (OnlCgreportItem item : list) { + result.put(item.getId(), item); + } + return result; + } + + /** + * 查询报表下的全部参数,按主键索引。 + * + * @param headId 报表主键Id。 + * @return 主键到参数对象的映射。 + */ + private Map listReportParams(String headId) { + List list = onlCgreportParamService.list( + new LambdaQueryWrapper().eq(OnlCgreportParam::getCgrheadId, headId)); + Map result = new HashMap<>(list.size()); + for (OnlCgreportParam param : list) { + result.put(param.getId(), param); + } + return result; } @Override @Transactional public String removeAllById(Long id) { + //先校验归属再删(PRD FR-4.12 / FR-4.16) + getOwnedHead(id == null ? null : String.valueOf(id)); String msg = "删除数据成功"; baseMapper.deleteById(id); onlCgreportItemService.remove(new LambdaQueryWrapper() @@ -270,10 +439,13 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl list = this.list(new LambdaQueryWrapper() - .eq(OnlCgreportHead::getCode, code) - ); - if (ObjectUtil.isEmpty(list) || list.size() > 1) throw new RuntimeException("code编码 配置多条数据"); + //同一租户内按 code 定位,避免多租户同名 code 冲突(同步端允许 code 在不同租户重复) + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OnlCgreportHead::getCode, code); + applyTenantScope(wrapper); + List list = this.list(wrapper); + if (ObjectUtil.isEmpty(list)) throw new DataNotFoundException(); + if (list.size() > 1) throw new RuntimeException("code编码 配置多条数据"); //返回参数 OnlFormHeadVo2 result = new OnlFormHeadVo2(); OnlCgreportHead head = list.get(0); @@ -389,7 +561,7 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl getData(JSONObject jsonObject, String id, MyPageParam pageParam) { - OnlCgreportHead head = this.getById(id); + OnlCgreportHead head = getOwnedHead(id); if (ObjectUtil.isEmpty(head)) throw new RuntimeException("数据为空,查询失败"); String sql = head.getCgrSql(); //todo sql动态替换值 diff --git a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java index a4b789b..1e11e89 100644 --- a/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java +++ b/common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java @@ -2,6 +2,7 @@ package apelet.common.generator.service.impl; import apelet.common.core.cache.CacheKey; import apelet.common.core.constant.GlobalDeletedFlag; +import apelet.common.core.exception.DataNotFoundException; import apelet.common.core.object.TokenData; import apelet.common.dict.model.GlobalDictItem; import apelet.common.dict.service.GlobalDictItemService; @@ -51,6 +52,7 @@ import java.sql.ResultSet; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -132,6 +134,55 @@ public class OnlFormHeadServiceImpl extends ServiceImplapp_code 为可选维度:请求未携带 appCode 时不校验(FR-4.14)。

+ * + *

无请求上下文(MQ 消费、定时任务)不做校验,否则租户同步链路无法删除/更新租户数据。

+ * + * @param rowTenantId 库中记录的 tenant_id。 + * @param rowAppCode 库中记录的 app_code。 + */ + private void verifyOwnership(Long rowTenantId, String rowAppCode) { + TokenData tokenData = TokenData.takeFromRequest(); + if (tokenData == null) { + return; + } + if (!Objects.equals(rowTenantId, tokenData.getTenantId())) { + throw new DataNotFoundException(); + } + String appCode = tokenData.getAppCode(); + if (StringUtils.isNotBlank(appCode) && !Objects.equals(rowAppCode, appCode)) { + throw new DataNotFoundException(); + } + } + + /** + * 给 onl_form_head 查询追加租户隔离条件(PRD FR-4.15): + * A 端 tenant_id = 当前租户;B 端 tenant_id IS NULL。app_code 有值才参与过滤(FR-4.14)。 + */ + private void applyTenantScope(LambdaQueryWrapper wrapper) { + Long tenantId = currentTenantId(); + if (tenantId == null) { + wrapper.isNull(OnlFormHead::getTenantId); + } else { + wrapper.eq(OnlFormHead::getTenantId, tenantId); + } + String appCode = TokenData.currentAppCode(); + if (StringUtils.isNotBlank(appCode)) { + wrapper.eq(OnlFormHead::getAppCode, appCode); + } + } + @Override public OnlFormHeadVo listByHeadId(Long id) { @@ -146,6 +197,15 @@ public class OnlFormHeadServiceImpl extends ServiceImpl list = this.list(new LambdaQueryWrapper() - .eq(OnlFormHead::getTableName, name) - ); + LambdaQueryWrapper mainWrapper = new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, name); + applyTenantScope(mainWrapper); + List list = this.list(mainWrapper); if (ObjectUtil.isNotEmpty(list)) { for (OnlFormHead onlFormHead : list) { String childName = onlFormHead.getChildName(); @@ -196,6 +257,18 @@ public class OnlFormHeadServiceImpl extends ServiceImpl().eq(OnlFormHead::getTableName, name) - ); + LambdaQueryWrapper mainWrapper = new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, name); + applyTenantScope(mainWrapper); + OnlFormHead onlFormHead = this.getOne(mainWrapper); if (onlFormHead!=null) { // BeanUtil.copyProperties(dto,onlFormHead); String childName = onlFormHead.getChildName(); @@ -236,6 +310,9 @@ public class OnlFormHeadServiceImpl extends ServiceImpl list = this.list(new LambdaQueryWrapper().eq(OnlFormHead::getTableName, tableName)); + //同一租户内判重,避免跨租户误判表名已存在(PRD FR-4.15) + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(OnlFormHead::getTableName, tableName); + applyTenantScope(wrapper); + List list = this.list(wrapper); return list.isEmpty(); } @@ -928,7 +1027,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl sub = this.list(new LambdaQueryWrapper() - .in(OnlFormHead::getTableName, main.getChildName().split(",")) - ); + LambdaQueryWrapper subWrapper = new LambdaQueryWrapper() + .in(OnlFormHead::getTableName, main.getChildName().split(",")); + applyTenantScope(subWrapper); + List sub = this.list(subWrapper); if (ObjectUtil.isNotEmpty(sub)) { result.put("sub", sub); List subList = new ArrayList<>(); @@ -999,7 +1103,123 @@ public class OnlFormHeadServiceImpl extends ServiceImpl规则与任务清单里的“已确认的关键决策”一致:

+ *
    + *
  • 提交里有、库里没有 → 插入;
  • + *
  • 提交里有、库里也有 → 更新;
  • + *
  • 库里有、提交里没有 → 保留(不删)。这是本方法存在的全部意义:租户端只提交自己关心的字段时, + * 管理端下发但本次未提交的字段不会被物理删除(P-9 / R-2)。
  • + *
+ * + *

为什么还需要回填 id:旧实现靠前面的 {@code removeChildData} 全删来保证“重插不产生重复行”。 + * 去掉删除以后,若前端不传子行 id(新增行本来就没有 id),同一行重复提交会插出两行同名字段。 + * 因此提交行没带 id(或带的 id 不属于当前 headId)时,按业务唯一键回填库里已有行的 id 后再走更新。

+ * + *

子表语义说明:六张子表都只通过 {@code head_id} 从属父表、没有独立的删除入口(N-6 本期不做删除, + * A 端只能隐藏),所以统一按“不删”处理,不区分是哪张子表。

+ * + * @param headId 父表 id,子行的 headId 会在 {@link #addChildData} 里被强制改写为该值。 + * @param dto 本次提交的表单数据,方法会就地回填子行 id 并覆盖其子表集合。 + */ + private void upsertChildData(Long headId, OnlFormHeadDto dto) { + dto.setOnlFormFieldList(this.mergeChildIds(dto.getOnlFormFieldList(), + onlFormFieldService.list(new LambdaQueryWrapper().eq(OnlFormField::getHeadId, headId)), + OnlFormField::getId, OnlFormField::setId, OnlFormField::getFieldName)); + dto.setOnlFormFindList(this.mergeChildIds(dto.getOnlFormFindList(), + onlFormFindService.list(new LambdaQueryWrapper().eq(OnlFormFind::getHeadId, headId)), + OnlFormFind::getId, OnlFormFind::setId, OnlFormFind::getFieldName)); + dto.setOnlFormCheckList(this.mergeChildIds(dto.getOnlFormCheckList(), + onlFormCheckService.list(new LambdaQueryWrapper().eq(OnlFormCheck::getHeadId, headId)), + OnlFormCheck::getId, OnlFormCheck::setId, OnlFormCheck::getFieldName)); + dto.setOnlFormIndexList(this.mergeChildIds(dto.getOnlFormIndexList(), + onlFormIndexService.list(new LambdaQueryWrapper().eq(OnlFormIndex::getHeadId, headId)), + OnlFormIndex::getId, OnlFormIndex::setId, OnlFormIndex::getIndexName)); + dto.setOnlFormKeyList(this.mergeChildIds(dto.getOnlFormKeyList(), + onlFormKeyService.list(new LambdaQueryWrapper().eq(OnlFormKey::getHeadId, headId)), + OnlFormKey::getId, OnlFormKey::setId, OnlFormKey::getFieldName)); + dto.setOnlFormPageList(this.mergeChildIds(dto.getOnlFormPageList(), + onlFormPageService.list(new LambdaQueryWrapper().eq(OnlFormPage::getHeadId, headId)), + OnlFormPage::getId, OnlFormPage::setId, OnlFormPage::getFieldName)); + // addChildData 内部用 saveOrUpdateBatch:id 有值走更新、无值走插入,正好匹配上面的回填结果。 + addChildData(dto); + } + + /** + * 为提交的子行决定最终 id,并按 id / 业务唯一键去重(同一行重复提交只保留最后一条)。 + * + *

三种情况:

+ *
    + *
  1. 提交行 id 已存在于当前 headId 下 → 保持 id,更新该行;
  2. + *
  3. 提交行 id 为空、或该 id 不属于当前 headId(可能是伪造/串种)→ 先按业务唯一键找库里同名的行, + * 找到就用它的 id(更新),找不到就置空 id(插入)。不直接用提交进来的 id 落库, + * 否则可以把别的父表下的子行“搬”到当前父表(对照 FR-4.16 的归属校验)。
  4. + *
  5. 同一次提交里出现两条同一业务唯一键 → 后者覆盖前者。
  6. + *
+ * + * @param submitted 本次提交的子行集合,允许为 null/空(返回 null,调用方不会删任何数据)。 + * @param existing 库里当前 headId 下已有的子行。 + * @param idGetter 子行 id 读取器。 + * @param idSetter 子行 id 写回器(就地回填)。 + * @param keyGetter 业务唯一键读取器(字段名 / 索引名)。 + * @return 回填 id 并去重后的提交集合。 + */ + private List mergeChildIds(List submitted, List existing, + Function idGetter, BiConsumer idSetter, + Function keyGetter) { + if (ObjectUtil.isEmpty(submitted)) { + return submitted; + } + Set existingIds = new HashSet<>(); + Map keyToId = new HashMap<>(); + if (ObjectUtil.isNotEmpty(existing)) { + for (T row : existing) { + Long id = idGetter.apply(row); + if (id == null) { + continue; + } + existingIds.add(id); + String key = this.normalizeChildKey(keyGetter.apply(row)); + if (key != null && !keyToId.containsKey(key)) { + keyToId.put(key, id); + } + } + } + Map merged = new LinkedHashMap<>(); + int newRowIndex = 0; + for (T row : submitted) { + Long id = idGetter.apply(row); + String key = this.normalizeChildKey(keyGetter.apply(row)); + if (id != null && existingIds.contains(id)) { + merged.put("id:" + id, row); + continue; + } + if (key != null && keyToId.containsKey(key)) { + idSetter.accept(row, keyToId.get(key)); + merged.put("key:" + key, row); + continue; + } + if (id != null) { + log.warn("子表数据提交了不属于当前元数据头的id,已按新增处理。id={}", id); + } + idSetter.accept(row, null); + merged.put(key != null ? "key:" + key : "new:" + (newRowIndex++), row); + } + return new ArrayList<>(merged.values()); + } + + /** + * 子行业务唯一键归一化:去空格、转小写,空串视为无键(不参与同名匹配)。 + */ + private String normalizeChildKey(String key) { + String normalized = StringUtils.trimToNull(key); + return normalized == null ? null : normalized.toLowerCase(Locale.ROOT); + } + + //删除子数据。removeAllById(整表物理删除)与 EAS 异构同步(isync=true,外部系统为准)仍走这里; + //表单设计器保存已改为 upsertChildData,不再调用本方法。 private void removeChildData(T id,boolean isync) { onlFormFindService.remove(new LambdaQueryWrapper() .eq(OnlFormFind::getHeadId, id) @@ -1023,7 +1243,12 @@ public class OnlFormHeadServiceImpl extends ServiceImpl().eq(OnlFormFind::getHeadId, id))); vo.setOnlFormFieldList(onlFormFieldService.queryOnlFormField(new LambdaQueryWrapper().eq(OnlFormField::getHeadId, id)));