Browse Source

feat(generator): 表单/报表元数据租户隔离与增量更新

- OnlFormHead/OnlCgreportHead 实体补充 app_code 映射,对应库中
  tenant_id/app_code 列与 idx_tenant_app 索引
- page 按 TokenData.currentTenantId() 过滤:A 端 = 当前租户,
  B 端 tenant_id IS NULL;app_code 有值才参与过滤
- getById/listByHeadId/tableInfo/editAll/removeAllById 等 byId 入口校验归属,
  不匹配抛 DataNotFoundException → 404
- 表单 editAll 按库中记录归属分流:A 端走 upsertChildData,
  只更新/新增本次提交的行,管理端下发但本次未提交的字段保持原样;
  B 端与 EAS 同步(tenant_id 为空或 isync=true)保持全量覆盖语义
- 新增 mergeChildIds:子表 id 只接受本表单已有行,其余按新增处理,
  避免复用他行主键
- 报表 editAll 改为增量 upsert:items/params 为 null 视为未提交、
  保持原配置;B 端删除未提交行,A 端只 upsert;跨报表 item id 置空后按新增处理
- 新增 getOwnedHead 统一报表归属校验;getRpColumns 按租户定位 code
feature/2026-10/tenant-multitenant
lml 1 day ago
parent
commit
b76b44db91
  1. 15
      common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java
  2. 15
      common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java
  3. 4
      common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java
  4. 4
      common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java
  5. 5
      common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java
  6. 214
      common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java
  7. 257
      common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java

15
common/common-generator/src/main/java/apelet/common/generator/controller/OnlCgreportHeadController.java

@ -6,6 +6,7 @@ import apelet.common.core.object.MyOrderParam; @@ -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 { @@ -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<OnlCgreportHead> list = onlCgreportHeadService.list(wq);
long totalCount = 0L;
if (list instanceof com.github.pagehelper.Page) {
@ -152,7 +164,8 @@ public class OnlCgreportHeadController { @@ -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的字段

15
common/common-generator/src/main/java/apelet/common/generator/controller/OnlFormHeadController.java

@ -7,6 +7,7 @@ import apelet.common.core.object.MyOrderParam; @@ -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 { @@ -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<OnlFormHead> list = onlFormHeadService.list(wq);
long totalCount = 0L;
@ -387,7 +400,7 @@ public class OnlFormHeadController { @@ -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", "标识为空 ");

4
common/common-generator/src/main/java/apelet/common/generator/model/OnlCgreportHead.java

@ -55,4 +55,8 @@ public class OnlCgreportHead extends BaseModel implements Serializable { @@ -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;
}

4
common/common-generator/src/main/java/apelet/common/generator/model/OnlFormHead.java

@ -118,6 +118,10 @@ public class OnlFormHead extends BaseModel implements Serializable { @@ -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<OnlFormField> onlFormFieldList;

5
common/common-generator/src/main/java/apelet/common/generator/service/IOnlCgreportHeadService.java

@ -24,6 +24,11 @@ import java.util.Map; @@ -24,6 +24,11 @@ import java.util.Map;
public interface IOnlCgreportHeadService extends IService<OnlCgreportHead> {
Map<String, Object> getSql(String sql, String dbKey);
/**
* 按 id 查询并校验租户归属,不存在或不属于当前租户时抛 404。
*/
OnlCgreportHead getOwnedHead(String id);
List<OnlCgreportItem> getFieldsById(String id);
List<OnlCgreportParam> getParamsById(String id);

214
common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlCgreportHeadServiceImpl.java

@ -1,5 +1,6 @@ @@ -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<OnlCgreportHeadMappe @@ -66,6 +67,49 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
@Autowired
private EASSyncService easSyncService;
/**
* 给 onl_cgreport_head 查询追加租户隔离条件:
* A 端 tenant_id = 当前租户;B 端 tenant_id IS NULL。app_code 有值才参与过滤(FR-4.14)。
*/
private void applyTenantScope(LambdaQueryWrapper<OnlCgreportHead> 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。
*
* <p>无 HTTP 请求上下文(MQ、定时任务)不做租户校验,避免阻断同步链路。</p>
*/
@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<String, Object> getSql(String sql, String dbKey) {
//检查sql是否有数据
@ -191,6 +235,8 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe @@ -191,6 +235,8 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
if (StringUtils.isEmpty(id)) {
return new ArrayList<>();
}
//先校验父表归属,子表只通过 cgrhead_id 关联(子表不加 tenant_id)
getOwnedHead(id);
LambdaQueryWrapper<OnlCgreportItem> wq = new LambdaQueryWrapper<OnlCgreportItem>()
.eq(StringUtils.isNotEmpty(id), OnlCgreportItem::getCgrheadId, id);
return onlCgreportItemService.list(wq);
@ -201,6 +247,7 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe @@ -201,6 +247,7 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
if (StringUtils.isEmpty(id)) {
return new ArrayList<>();
}
getOwnedHead(id);
LambdaQueryWrapper<OnlCgreportParam> wq = new LambdaQueryWrapper<OnlCgreportParam>()
.eq(StringUtils.isNotEmpty(id), OnlCgreportParam::getCgrheadId, id);
return onlCgreportParamService.list(wq);
@ -210,17 +257,33 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe @@ -210,17 +257,33 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
@Transactional
public String addAll(OnlCgreportHeadDto dto) {
OnlCgreportHead head = BeanUtil.copyProperties(dto, OnlCgreportHead.class);
//写接口强制赋值(PRD FR-4.17):DTO 不暴露 tenantId/appCode,避免前端伪造。
Long tenantId = TokenData.currentTenantId();
if (tenantId != null) {
head.setTenantId(tenantId);
}
String appCode = TokenData.currentAppCode();
if (StringUtils.isNotBlank(appCode)) {
head.setAppCode(appCode);
}
baseMapper.insert(head);
String id = head.getId();
List<OnlCgreportItem> items = dto.getItems();
List<OnlCgreportParam> params = dto.getParams();
if (StringUtils.isNotEmpty(id)) {
//新增报表时子表行一定是新行:清掉前端传来的 id,避免复用他行主键导致主键冲突(任务 #8)
if (ObjectUtil.isNotEmpty(items)) {
List<OnlCgreportItem> itemList = items.parallelStream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList());
List<OnlCgreportItem> itemList = items.stream().peek(x -> {
x.setId(null);
x.setCgrheadId(id);
}).collect(Collectors.toList());
onlCgreportItemService.saveBatch(itemList);
}
if (ObjectUtil.isNotEmpty(params)) {
List<OnlCgreportParam> paramList = params.parallelStream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList());
List<OnlCgreportParam> 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<OnlCgreportHeadMappe @@ -231,31 +294,137 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
@Override
@Transactional
public String editAll(OnlCgreportHeadDto dto) {
//归属校验必须在更新前完成(PRD FR-4.16)
OnlCgreportHead origin = getOwnedHead(dto == null ? null : dto.getId());
OnlCgreportHead head = BeanUtil.copyProperties(dto, OnlCgreportHead.class);
//以库中归属为准,防止 updateById 把 tenant_id/app_code 刷成 NULL(FR-4.17)
head.setTenantId(origin.getTenantId());
head.setAppCode(origin.getAppCode());
baseMapper.updateById(head);
String id = head.getId();
List<OnlCgreportItem> items = dto.getItems();
List<OnlCgreportParam> params = dto.getParams();
//查询原数据没改动就不更新
if (StringUtils.isNotEmpty(id)) {
//删除原有数据
onlCgreportItemService.remove(new LambdaQueryWrapper<OnlCgreportItem>().eq(OnlCgreportItem::getCgrheadId, id));
onlCgreportParamService.remove(new LambdaQueryWrapper<OnlCgreportParam>().eq(OnlCgreportParam::getCgrheadId, id));
//重新添加
if (ObjectUtil.isNotEmpty(params)) {
onlCgreportParamService.saveOrUpdateBatch(params.stream().peek(x -> x.setCgrheadId(id)).collect(Collectors.toList()));
}
if (ObjectUtil.isNotEmpty(items)) {
onlCgreportItemService.saveOrUpdateBatch(items.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;
}
return "修改成功";
/**
* 增量保存报表字段(任务 #8)。
*
* @param headId 报表主键Id。
* @param items 本次提交的字段,null 表示本次未提交。
* @param admin 是否管理端;管理端以提交内容为准,会删除本次未提交的行。
*/
private void saveReportItems(String headId, List<OnlCgreportItem> items, boolean admin) {
if (items == null) {
return;
}
Map<String, OnlCgreportItem> existing = this.listReportItems(headId);
List<OnlCgreportItem> toSave = new ArrayList<>(items.size());
Set<String> 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());
}
item.setCgrheadId(headId);
toSave.add(item);
}
if (admin) {
List<String> removeIds = existing.keySet().stream()
.filter(existingId -> !submittedIds.contains(existingId))
.collect(Collectors.toList());
if (!removeIds.isEmpty()) {
onlCgreportItemService.removeByIds(removeIds);
}
}
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<OnlCgreportParam> params, boolean admin) {
if (params == null) {
return;
}
Map<String, OnlCgreportParam> existing = this.listReportParams(headId);
List<OnlCgreportParam> toSave = new ArrayList<>(params.size());
Set<String> 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<String> 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<String, OnlCgreportItem> listReportItems(String headId) {
List<OnlCgreportItem> list = onlCgreportItemService.list(
new LambdaQueryWrapper<OnlCgreportItem>().eq(OnlCgreportItem::getCgrheadId, headId));
Map<String, OnlCgreportItem> result = new HashMap<>(list.size());
for (OnlCgreportItem item : list) {
result.put(item.getId(), item);
}
return result;
}
/**
* 查询报表下的全部参数,按主键索引。
*
* @param headId 报表主键Id。
* @return 主键到参数对象的映射。
*/
private Map<String, OnlCgreportParam> listReportParams(String headId) {
List<OnlCgreportParam> list = onlCgreportParamService.list(
new LambdaQueryWrapper<OnlCgreportParam>().eq(OnlCgreportParam::getCgrheadId, headId));
Map<String, OnlCgreportParam> 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<OnlCgreportItem>()
@ -270,10 +439,13 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe @@ -270,10 +439,13 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
@Override
public OnlFormHeadVo2 getRpColumns(String code) {
if (StringUtils.isEmpty(code)) throw new RuntimeException("传入的code编码不能为空");
List<OnlCgreportHead> list = this.list(new LambdaQueryWrapper<OnlCgreportHead>()
.eq(OnlCgreportHead::getCode, code)
);
if (ObjectUtil.isEmpty(list) || list.size() > 1) throw new RuntimeException("code编码 配置多条数据");
//同一租户内按 code 定位,避免多租户同名 code 冲突(同步端允许 code 在不同租户重复)
LambdaQueryWrapper<OnlCgreportHead> wrapper = new LambdaQueryWrapper<OnlCgreportHead>()
.eq(OnlCgreportHead::getCode, code);
applyTenantScope(wrapper);
List<OnlCgreportHead> 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<OnlCgreportHeadMappe @@ -389,7 +561,7 @@ public class OnlCgreportHeadServiceImpl extends ServiceImpl<OnlCgreportHeadMappe
@Override
public IPage<JSONObject> 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动态替换值

257
common/common-generator/src/main/java/apelet/common/generator/service/impl/OnlFormHeadServiceImpl.java

@ -2,6 +2,7 @@ package apelet.common.generator.service.impl; @@ -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; @@ -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 ServiceImpl<OnlFormHeadMapper, OnlFo @@ -132,6 +134,55 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
redissonClient.getBucket(tableIdKey).delete();
}
/**
* 当前请求的租户Id。B 端(管理端)为 null,A 端(租户端)为登录租户Id。
*/
private Long currentTenantId() {
return TokenData.currentTenantId();
}
/**
* 归属校验(PRD FR-4.16):有 HTTP 请求上下文时,记录的 tenant_id / app_code 必须与当前请求一致,
* 不匹配按“不存在”处理并返回 404,避免越权探测。
*
* <p>app_code 为可选维度:请求未携带 appCode 时不校验(FR-4.14)。</p>
*
* <p>无请求上下文(MQ 消费、定时任务)不做校验,否则租户同步链路无法删除/更新租户数据。</p>
*
* @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<OnlFormHead> 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<OnlFormHeadMapper, OnlFo @@ -146,6 +197,15 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
@Transactional
public OnlFormHeadDto addAll(OnlFormHeadDto dto) {
OnlFormHead head = BeanUtil.copyProperties(dto, OnlFormHead.class);
//写接口强制赋值(PRD FR-4.17):A 端落当前租户,B 端保持 null。DTO 不暴露 tenantId,避免前端伪造。
Long tenantId = currentTenantId();
if (tenantId != null) {
head.setTenantId(tenantId);
}
String appCode = TokenData.currentAppCode();
if (StringUtils.isNotBlank(appCode)) {
head.setAppCode(appCode);
}
String string = JSONUtil.parseObj(dto).toJSONString(0);
head.setOldData(string);
//新增基本数据
@ -160,9 +220,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -160,9 +220,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
//主表名
String name = dto.getSort();
if (StringUtils.isNotEmpty(name)) {
List<OnlFormHead> list = this.list(new LambdaQueryWrapper<OnlFormHead>()
.eq(OnlFormHead::getTableName, name)
);
LambdaQueryWrapper<OnlFormHead> mainWrapper = new LambdaQueryWrapper<OnlFormHead>()
.eq(OnlFormHead::getTableName, name);
applyTenantScope(mainWrapper);
List<OnlFormHead> 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<OnlFormHeadMapper, OnlFo @@ -196,6 +257,18 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
@Override
@Transactional
public OnlFormHeadDto editAll(OnlFormHeadDto dto,boolean isync) {
//归属校验(PRD FR-4.16):必须在删缓存之前完成,避免跨租户请求清掉别人的缓存。
//isync=true 是 EAS 异构同步,数据由外部系统驱动,不做租户归属拦截。
if (dto.getId() == null) {
throw new DataNotFoundException();
}
OnlFormHead origin = this.getById(dto.getId());
if (origin == null) {
throw new DataNotFoundException();
}
if (!isync) {
verifyOwnership(origin.getTenantId(), origin.getAppCode());
}
//检查是否为附表,且有关联字段
String sortFiled = dto.getSortFiled();
String tableType = dto.getTableType();
@ -211,9 +284,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -211,9 +284,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
formkey = CacheKey.makeOneToManyTable(name);
redissonClient.getBucket(formkey).delete();
if (StringUtils.isNotEmpty(name)) {
OnlFormHead onlFormHead = this.getOne(
new LambdaQueryWrapper<OnlFormHead>().eq(OnlFormHead::getTableName, name)
);
LambdaQueryWrapper<OnlFormHead> mainWrapper = new LambdaQueryWrapper<OnlFormHead>()
.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<OnlFormHeadMapper, OnlFo @@ -236,6 +310,9 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
}
//数据转换
OnlFormHead head = BeanUtil.copyProperties(dto, OnlFormHead.class);
//归属以库中记录为准,不信任提交内容,防止 updateById 把 tenant_id 刷成 NULL(PRD FR-4.17 / FR-4.16)
head.setTenantId(origin.getTenantId());
head.setAppCode(origin.getAppCode());
head.setVersion(String.valueOf(Integer.parseInt(head.getVersion()) + 1));
head.setStatus("0");
//更新基本数据
@ -252,10 +329,22 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -252,10 +329,22 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
dto.setId(head.getId());
//更新分录数据
if (save && ObjectUtil.isNotEmpty(head.getId())) {
//先删除
removeChildData(head.getId(),isync);
//再添加
// 判定口径(PRD FR-4.13):tenant_id 有值 = 租户端(A),为空 = 管理端(B)。
// 用库中记录判断,DTO 没有 tenantId 字段,不能拿 DTO 判断。
// isync=true 是 EAS 异构同步,外部系统为权威源,同样保持“先清理再重插”。
if (isync || origin.getTenantId() == null) {
// B 端设计器提交的是完整字段集,删除字段/改字段名必须靠“先删后插”才能落库;
// 这里也是 removeChildData 在保存路径上的唯一保留点。
removeChildData(head.getId(), isync);
addChildData(dto);
} else {
// A 端保存:改为增量 upsert(PRD FR-4.9 / 任务 #5)。
// 原实现是“按 headId 整表删除 + 按提交内容重插”,租户端只提交部分字段时,
// 管理端下发但本次未提交的字段会被物理删除(P-9)。
// 注意:A 端的 tenantId 由任务 #6 在进入本方法前强制赋值(FR-4.17)。
upsertChildData(head.getId(), dto);
}
// TODO(FR-4.9): A 端删除自有 TENANT 字段本期不做(N-6 只能隐藏),后续按 source='TENANT' 补删
}
return dto;
}
@ -263,6 +352,12 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -263,6 +352,12 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
@Override
@Transactional
public Boolean removeAllById(Long id) {
//归属校验后再删除(PRD FR-4.16 / FR-4.12),避免传别家 id 直接删库
OnlFormHead origin = id == null ? null : this.getById(id);
if (origin == null) {
throw new DataNotFoundException();
}
verifyOwnership(origin.getTenantId(), origin.getAppCode());
//删除主数据
boolean remove = this.removeById(id);
if (remove) {
@ -920,7 +1015,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -920,7 +1015,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
@Override
public Boolean checkTableName(String tableName) {
List<OnlFormHead> list = this.list(new LambdaQueryWrapper<OnlFormHead>().eq(OnlFormHead::getTableName, tableName));
//同一租户内判重,避免跨租户误判表名已存在(PRD FR-4.15)
LambdaQueryWrapper<OnlFormHead> wrapper = new LambdaQueryWrapper<OnlFormHead>()
.eq(OnlFormHead::getTableName, tableName);
applyTenantScope(wrapper);
List<OnlFormHead> list = this.list(wrapper);
return list.isEmpty();
}
@ -928,7 +1027,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -928,7 +1027,11 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
public JSONObject tableInfo(Long id) {
JSONObject result = new JSONObject();
//主表
OnlFormHead main = this.getById(id);
OnlFormHead main = id == null ? null : this.getById(id);
if (main == null) {
throw new DataNotFoundException();
}
verifyOwnership(main.getTenantId(), main.getAppCode());
result.put("main", main);
String tableType = main.getTableType();
//风格
@ -959,9 +1062,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -959,9 +1062,10 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
vo3.setNote("Tab风格");
list.add(vo3);
//附表
List<OnlFormHead> sub = this.list(new LambdaQueryWrapper<OnlFormHead>()
.in(OnlFormHead::getTableName, main.getChildName().split(","))
);
LambdaQueryWrapper<OnlFormHead> subWrapper = new LambdaQueryWrapper<OnlFormHead>()
.in(OnlFormHead::getTableName, main.getChildName().split(","));
applyTenantScope(subWrapper);
List<OnlFormHead> sub = this.list(subWrapper);
if (ObjectUtil.isNotEmpty(sub)) {
result.put("sub", sub);
List<OnlFormCodeDto> subList = new ArrayList<>();
@ -999,7 +1103,123 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -999,7 +1103,123 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
}
//删除子数据
/**
* 子数据增量写入:只 upsert,不做物理删除(PRD FR-4.9 / 任务 #5)。
*
* <p>规则与任务清单里的“已确认的关键决策”一致:</p>
* <ul>
* <li>提交里有、库里没有 → 插入;</li>
* <li>提交里有、库里也有 → 更新;</li>
* <li>库里有、提交里没有 → 保留(不删)。这是本方法存在的全部意义:租户端只提交自己关心的字段时,
* 管理端下发但本次未提交的字段不会被物理删除(P-9 / R-2)。</li>
* </ul>
*
* <p>为什么还需要回填 id:旧实现靠前面的 {@code removeChildData} 全删来保证“重插不产生重复行”。
* 去掉删除以后,若前端不传子行 id(新增行本来就没有 id),同一行重复提交会插出两行同名字段。
* 因此提交行没带 id(或带的 id 不属于当前 headId)时,按业务唯一键回填库里已有行的 id 后再走更新。</p>
*
* <p>子表语义说明:六张子表都只通过 {@code head_id} 从属父表、没有独立的删除入口(N-6 本期不做删除,
* A 端只能隐藏),所以统一按“不删”处理,不区分是哪张子表。</p>
*
* @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<OnlFormField>().eq(OnlFormField::getHeadId, headId)),
OnlFormField::getId, OnlFormField::setId, OnlFormField::getFieldName));
dto.setOnlFormFindList(this.mergeChildIds(dto.getOnlFormFindList(),
onlFormFindService.list(new LambdaQueryWrapper<OnlFormFind>().eq(OnlFormFind::getHeadId, headId)),
OnlFormFind::getId, OnlFormFind::setId, OnlFormFind::getFieldName));
dto.setOnlFormCheckList(this.mergeChildIds(dto.getOnlFormCheckList(),
onlFormCheckService.list(new LambdaQueryWrapper<OnlFormCheck>().eq(OnlFormCheck::getHeadId, headId)),
OnlFormCheck::getId, OnlFormCheck::setId, OnlFormCheck::getFieldName));
dto.setOnlFormIndexList(this.mergeChildIds(dto.getOnlFormIndexList(),
onlFormIndexService.list(new LambdaQueryWrapper<OnlFormIndex>().eq(OnlFormIndex::getHeadId, headId)),
OnlFormIndex::getId, OnlFormIndex::setId, OnlFormIndex::getIndexName));
dto.setOnlFormKeyList(this.mergeChildIds(dto.getOnlFormKeyList(),
onlFormKeyService.list(new LambdaQueryWrapper<OnlFormKey>().eq(OnlFormKey::getHeadId, headId)),
OnlFormKey::getId, OnlFormKey::setId, OnlFormKey::getFieldName));
dto.setOnlFormPageList(this.mergeChildIds(dto.getOnlFormPageList(),
onlFormPageService.list(new LambdaQueryWrapper<OnlFormPage>().eq(OnlFormPage::getHeadId, headId)),
OnlFormPage::getId, OnlFormPage::setId, OnlFormPage::getFieldName));
// addChildData 内部用 saveOrUpdateBatch:id 有值走更新、无值走插入,正好匹配上面的回填结果。
addChildData(dto);
}
/**
* 为提交的子行决定最终 id,并按 id / 业务唯一键去重(同一行重复提交只保留最后一条)。
*
* <p>三种情况:</p>
* <ol>
* <li>提交行 id 已存在于当前 headId 下 → 保持 id,更新该行;</li>
* <li>提交行 id 为空、或该 id 不属于当前 headId(可能是伪造/串种)→ 先按业务唯一键找库里同名的行,
* 找到就用它的 id(更新),找不到就置空 id(插入)。<b>不直接用提交进来的 id 落库</b>,
* 否则可以把别的父表下的子行“搬”到当前父表(对照 FR-4.16 的归属校验)。</li>
* <li>同一次提交里出现两条同一业务唯一键 → 后者覆盖前者。</li>
* </ol>
*
* @param submitted 本次提交的子行集合,允许为 null/空(返回 null,调用方不会删任何数据)。
* @param existing 库里当前 headId 下已有的子行。
* @param idGetter 子行 id 读取器。
* @param idSetter 子行 id 写回器(就地回填)。
* @param keyGetter 业务唯一键读取器(字段名 / 索引名)。
* @return 回填 id 并去重后的提交集合。
*/
private <T> List<T> mergeChildIds(List<T> submitted, List<T> existing,
Function<T, Long> idGetter, BiConsumer<T, Long> idSetter,
Function<T, String> keyGetter) {
if (ObjectUtil.isEmpty(submitted)) {
return submitted;
}
Set<Long> existingIds = new HashSet<>();
Map<String, Long> 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<String, T> 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 <T> void removeChildData(T id,boolean isync) {
onlFormFindService.remove(new LambdaQueryWrapper<OnlFormFind>()
.eq(OnlFormFind::getHeadId, id)
@ -1023,7 +1243,12 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo @@ -1023,7 +1243,12 @@ public class OnlFormHeadServiceImpl extends ServiceImpl<OnlFormHeadMapper, OnlFo
//获取子数据vo
private void getChildData(OnlFormHeadVo vo, Long id) {
OnlFormHead main = this.getById(id);
OnlFormHead main = id == null ? null : this.getById(id);
if (main == null) {
throw new DataNotFoundException();
}
//按 id 查询时必须校验归属(PRD FR-4.16),子表通过 head_id 随之隔离
verifyOwnership(main.getTenantId(), main.getAppCode());
BeanUtil.copyProperties(main, vo);
vo.setOnlFormFindList(onlFormFindService.list(new LambdaQueryWrapper<OnlFormFind>().eq(OnlFormFind::getHeadId, id)));
vo.setOnlFormFieldList(onlFormFieldService.queryOnlFormField(new LambdaQueryWrapper<OnlFormField>().eq(OnlFormField::getHeadId, id)));

Loading…
Cancel
Save