package jnpf.onlinedev.service.impl; import cn.hutool.core.bean.BeanUtil; import jnpf.base.UserInfo; import jnpf.base.entity.VisualdevReleaseEntity; import jnpf.base.model.VisualLogModel; import jnpf.base.service.SuperServiceImpl; import jnpf.base.service.VisualdevReleaseService; import jnpf.constant.JnpfConst; import jnpf.consts.ProjectEventConst; import jnpf.event.ProjectEventListener; import jnpf.model.visualjson.FieLdsModel; import jnpf.model.visualjson.FormDataModel; import jnpf.model.visualjson.TableFields; import jnpf.model.visualjson.TableModel; import jnpf.model.visualjson.config.ConfigModel; import jnpf.module.ProjectEventBuilder; import jnpf.module.ProjectEventInstance; import jnpf.onlinedev.entity.VisualLogEntity; import jnpf.onlinedev.mapper.VisualLogMapper; import jnpf.onlinedev.model.enums.OnlineDataTypeEnum; import jnpf.onlinedev.model.log.VisualLogForm; import jnpf.onlinedev.model.log.VisualLogPage; import jnpf.onlinedev.service.VisualLogService; import jnpf.onlinedev.util.OnlinePublicUtils; import jnpf.onlinedev.util.OnlineSwapDataUtils; import jnpf.util.IpUtil; import jnpf.util.JsonUtil; import jnpf.util.PublishEventUtil; import jnpf.util.TableFeildsEnum; import jnpf.util.UserProvider; import jnpf.util.visiual.JnpfKeyConsts; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.collections4.MapUtils; import org.springframework.stereotype.Service; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; import java.util.stream.Collectors; /** * 数据日志实现 * * @author JNPF开发平台组 * @version v5.1.0 * @copyright 引迈信息技术有限公司 * @date 2024/8/27 18:24:42 */ @Slf4j @Service @RequiredArgsConstructor public class VisualLogServiceImpl extends SuperServiceImpl implements VisualLogService { private final VisualdevReleaseService visualdevReleaseService; private final OnlineSwapDataUtils onlineSwapDataUtils; /** * 创建日志事件 * * @param form 数据id * *

[LIMS PATCH 2026-05-15] 从 publishLocalEvent 改为 BROADCASTING, * 使 jnpf-extend 等其它微服务能通过 RocketMQ 跨 JVM 监听 VSLOG_EVENT_KEY, * 便于 LIMS 业务日志统一聚合到 lims_biz_log。 * 详见 tasks/jnpf-platform-patches.md 和 docs/release-checklist.md。

*/ @Override public void createEventLog(VisualLogForm form) { ensureBoundaryListLog(form); // AUDIT-P1(登记 tasks/jnpf-platform-patches.md):附带请求上下文供审计层0消费 try { ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); UserInfo user = UserProvider.getUser(); // 静态方法(终验 javap 核实:无实例 get()、无 userProvider 注入字段) if (attrs != null && user != null) { Map auditCtx = new HashMap<>(); auditCtx.put("userId", user.getUserId()); auditCtx.put("userName", user.getUserName()); auditCtx.put("tenantId", user.getTenantId()); auditCtx.put("ip", IpUtil.getIpAddr()); // 无参方法(终验 javap 核实:不存在接收 request 的重载) auditCtx.put("userAgent", attrs.getRequest().getHeader("User-Agent")); auditCtx.put("uri", attrs.getRequest().getRequestURI()); // AUDIT-P1 扩展(2026-07-31):操作入口菜单。header 由前端全局拦截器注入, // 取不到就不放这个键——审计侧按"没带"处理并留痕,不猜。 String auditMenuId = attrs.getRequest().getHeader("Jnpf-Menu-Id"); if (auditMenuId != null && !auditMenuId.trim().isEmpty()) { auditCtx.put("menuId", auditMenuId.trim()); } auditCtx.put("appCode", attrs.getRequest().getHeader("App-Code")); auditCtx.put("appScope", attrs.getRequest().getHeader("Jnpf-App-Scope")); String auditBizSign = attrs.getRequest().getHeader("X-Audit-Biz-Sign"); if (auditBizSign != null && !auditBizSign.trim().isEmpty()) { // 固化请求时的关联键;MQ 消费时已脱离 HTTP 线程,不能再读取请求头。 auditCtx.put("auditBizSign", auditBizSign.trim()); } auditCtx.put("opTime", System.currentTimeMillis()); form.setAuditCtx(JsonUtil.getObjectToString(auditCtx)); // form = 该方法内既有的 VisualLogForm 实例 } } catch (Throwable t) { log.warn("AUDIT-P1 上下文采集失败, 不影响主流程", t); } try { PublishEventUtil.publish( new ProjectEventBuilder(JnpfConst.VSLOG_EVENT_KEY, form) .setMessageModel(ProjectEventConst.EVENT_PUBLISH_MODEL_BROADCASTING)); } catch (Throwable t) { // 日志事件是旁路能力,RocketMQ 不可用时不能影响在线表单主流程保存。 log.warn("[VisualLog] 发布表单日志事件失败,已跳过。modelId={}, dataId={}, type={}, error={}", form == null ? null : form.getModelId(), form == null ? null : form.getDataId(), form == null ? null : form.getType(), t.getMessage()); } } /** * 新增和删除路径原本只发布整行快照,导致统一审计拿不到子表行级变化。 * 复用更新路径的 addLog 契约:新增以空数据为 before,删除以空数据为 after。 */ private void ensureBoundaryListLog(VisualLogForm form) { if (form == null || CollectionUtils.isNotEmpty(form.getListLog()) || form.getNewData() == null || (!Objects.equals(form.getType(), 0) && !Objects.equals(form.getType(), 2))) { return; } Map snapshot = form.getNewData(); Map originalOld = form.getOldData(); try { if (Objects.equals(form.getType(), 0)) { form.setOldData(Collections.emptyMap()); } else { form.setOldData(snapshot); form.setNewData(Collections.emptyMap()); } List listLog = new ArrayList<>(); addLog(form, listLog); form.setListLog(listLog); } catch (Throwable t) { log.warn("[AUDIT] 子表边界差异生成失败,主表审计继续。modelId={}, dataId={}, type={}, error={}", form.getModelId(), form.getDataId(), form.getType(), t.getMessage()); } finally { form.setOldData(originalOld); form.setNewData(snapshot); } } @Override public List getList(VisualLogPage pagination) { return this.baseMapper.getList(pagination); } /** * 监听日志插入 * * @param redisEvent */ @ProjectEventListener(channelRegex = JnpfConst.VSLOG_EVENT_KEY + ".*") public void createLogByEvent(ProjectEventInstance redisEvent) { // [LIMS PATCH 2026-05-15] BROADCASTING 模式下事件经 RocketMQ 跨 JVM 后 source 反序列化为 JSONObject, // 不能直接 cast 成 VisualLogForm(@Builder 无无参构造器无法 parseObject 回去)。 // 这里安全跳过非 VisualLogForm 的 source —— 否则抛 ClassCastException 会阻断同一 channel 上后续 listener // (如 jnpf-extend 的 BaseVisualLogListener)。base_visual_log 在跨 JVM 路径下不再被写入,由 lims_biz_log 接管。 Object source = redisEvent.getSource(); if (!(source instanceof VisualLogForm)) { return; } VisualLogForm form = (VisualLogForm) source; if (Boolean.TRUE.equals(form.getAuditOnly())) { return; } VisualLogEntity visualLogEntity = new VisualLogEntity(); visualLogEntity.setType(form.getType()); visualLogEntity.setModelId(form.getModelId()); visualLogEntity.setDataId(form.getDataId()); //修改数据 if (Objects.equals(form.getType(), 1)) { List listLog = new ArrayList<>(); if (CollectionUtils.isNotEmpty(form.getListLog())) { listLog = form.getListLog(); } else { addLog(form, listLog); } visualLogEntity.setDataLog(JsonUtil.getObjectToString(listLog)); if (CollectionUtils.isNotEmpty(listLog)) { this.save(visualLogEntity); } } else { //新增数据 this.save(visualLogEntity); } } public void addLog(VisualLogForm formSource, List listLog) { VisualLogForm form = BeanUtil.copyProperties(formSource, VisualLogForm.class); VisualdevReleaseEntity visualdevEntity = visualdevReleaseService.getById(form.getModelId()); FormDataModel formData = JsonUtil.getJsonToBean(visualdevEntity.getFormData(), FormDataModel.class); List fieLdsModels = JsonUtil.getJsonToList(formData.getFields(), FieLdsModel.class); List tableModels = JsonUtil.getJsonToList(visualdevEntity.getVisualTables(), TableModel.class); List fields = new ArrayList<>(); OnlinePublicUtils.recursionFields(fields, fieLdsModels); List> swapInfoOld = onlineSwapDataUtils.getSwapInfo(Arrays.asList(form.getOldData()), fields, visualdevEntity.getId(), false, null); Map oldData = swapInfoOld.get(0); List> swapInfoNew = onlineSwapDataUtils.getSwapInfo(Arrays.asList(form.getNewData()), fields, visualdevEntity.getId(), false, null); Map newData = swapInfoNew.get(0); for (FieLdsModel item : fields) { //移除系统字段 if (isSkipFields(item)) continue; if (JnpfKeyConsts.CHILD_TABLE.equals(item.getConfig().getJnpfKey())) { //子表字段 addChildTableLog(item, tableModels, listLog, oldData, newData); } else { addMainMastLog(item, listLog, oldData, newData); } } } /** * 添加主附表字段日志 * * @param item * @param listLog * @param oldData * @param newData */ private void addMainMastLog(FieLdsModel item, List listLog, Map oldData, Map newData) { String vModel = item.getVModel(); ConfigModel config = item.getConfig(); String jnpfKey = config.getJnpfKey(); String label = config.getLabel(); String dataType = config.getDataType(); if (!Objects.equals(oldData.get(vModel), newData.get(vModel))) { Integer actionType = 1;//0-新增,1-修改 if (oldData.get(vModel) == null || oldData.get(vModel).toString().trim().isEmpty()) { actionType = 0; } boolean nameModified = false; if (JnpfKeyConsts.getNameModified().contains(jnpfKey) && (JnpfKeyConsts.getNameModifiedNotDynamic().contains(jnpfKey) || OnlineDataTypeEnum.DYNAMIC.getType().equals(dataType))) { nameModified = true; } String newValue = getValueByType(newData, vModel, jnpfKey); String oldValue = getValueByType(oldData, vModel, jnpfKey); VisualLogModel vlogModel = new VisualLogModel(); vlogModel.setField(vModel); vlogModel.setFieldName(label); vlogModel.setJnpfKey(jnpfKey); vlogModel.setNewData(newValue); vlogModel.setOldData(oldValue); vlogModel.setType(actionType); vlogModel.setNameModified(nameModified); listLog.add(vlogModel); } } /** * 添加子表字段日志 * * @param item * @param tableModels * @param listLog * @param oldData * @param newData */ private void addChildTableLog(FieLdsModel item, List tableModels, List listLog, Map oldData, Map newData) { ConfigModel config = item.getConfig(); String jnpfKey = config.getJnpfKey(); String label = config.getLabel(); TableModel tableModel = tableModels.stream().filter(t -> t.getTable().equals(config.getTableName())).findFirst().orElse(null); TableFields mainField = tableModel.getFields().stream().filter(t -> Objects.equals(t.getPrimaryKey(), 1) && !t.getField().equalsIgnoreCase(TableFeildsEnum.TENANTID.getField())).findFirst().orElse(null); String mainKey = mainField.getField(); List children = item.getConfig().getChildren(); String vModel = item.getVModel(); //子表数据 List> childData = new ArrayList<>(); //子表表头 List> childField = new ArrayList<>(); for (FieLdsModel childItem : children) { String jnpfKeyChild = childItem.getConfig().getJnpfKey(); if (isSkipFields(childItem)) continue; Map childItemMap = new HashMap<>(); childItemMap.put("prop", childItem.getVModel()); childItemMap.put("label", childItem.getConfig().getLabel()); childItemMap.put("jnpfKey", jnpfKeyChild); boolean nameModified = false; if (JnpfKeyConsts.getNameModified().contains(jnpfKeyChild) && (JnpfKeyConsts.getNameModifiedNotDynamic().contains(jnpfKeyChild) || OnlineDataTypeEnum.DYNAMIC.getType().equals(childItem.getConfig().getDataType()))) { nameModified = true; } childItemMap.put("nameModified", nameModified); childField.add(childItemMap); } List> childOld = oldData.get(vModel) == null ? new ArrayList<>() : (List) oldData.get(vModel); List> childNew = newData.get(vModel) == null ? new ArrayList<>() : (List) newData.get(vModel); List newIds = childNew.stream().map(t -> t.get(mainKey)).collect(Collectors.toList()); List> deleteMap = childOld.stream().filter(t -> !newIds.contains(t.get(mainKey))).collect(Collectors.toList()); for (Map chilMap : deleteMap) { doDelData(chilMap, children, childData); } for (Map chilMap : childNew) { doNewData(chilMap, mainKey, childOld, children, childData); } if (CollectionUtils.isNotEmpty(childData)) { VisualLogModel vlogModel = new VisualLogModel(); vlogModel.setField(vModel); vlogModel.setFieldName(label); vlogModel.setJnpfKey(jnpfKey); vlogModel.setTargetTable(config.getTableName()); vlogModel.setChidData(childData); vlogModel.setChidField(childField); vlogModel.setType(1); listLog.add(vlogModel); } } private void doDelData(Map chilMap, List children, List> childData) { Map childDataMap = new HashMap<>(); for (FieLdsModel childItem : children) { String childJnpfKey = childItem.getConfig().getJnpfKey(); //移除系统字段 if (isSkipFields(childItem)) continue; String childVmodel = childItem.getVModel(); String childVmodelOld = "jnpf_old_" + childItem.getVModel(); String oldValue = getValueByType(chilMap, childVmodel, childJnpfKey); childDataMap.put(childVmodel, null); childDataMap.put(childVmodelOld, oldValue); childDataMap.put("jnpf_type", 2); } childData.add(childDataMap); } private void doNewData(Map chilMap, String mainKey, List> childOld, List children, List> childData) { Object mainId = chilMap.get(mainKey); Map oldMap = childOld.stream().filter(t -> Objects.equals(t.get(mainKey), mainId)).findFirst().orElse(null); Integer jnpfType = 1; if (oldMap == null) { jnpfType = 0; } Map childDataMap = new HashMap<>(); boolean hasChanged = false; for (FieLdsModel childItem : children) { String childJnpfKey = childItem.getConfig().getJnpfKey(); //移除系统字段 if (isSkipFields(childItem)) continue; String childVmodel = childItem.getVModel(); String childVmodelOld = "jnpf_old_" + childItem.getVModel(); String newValue = getValueByType(chilMap, childVmodel, childJnpfKey); String oldValue = getValueByType(oldMap, childVmodel, childJnpfKey); if (!Objects.equals(newValue, oldValue)) { hasChanged = true; } childDataMap.put(childVmodel, newValue); childDataMap.put(childVmodelOld, oldValue); childDataMap.put("jnpf_type", jnpfType); } if (hasChanged) { childData.add(childDataMap); } } /** * 跳过字段不处理 * * @param childItem * @return */ private boolean isSkipFields(FieLdsModel childItem) { //字段不处理直接跳过 :(JnpfKeyConsts.CALCULATE 计算公式仅展示也跳过) List skipFields = new ArrayList<>(); skipFields.addAll(JnpfKeyConsts.getSystemKey()); skipFields.add(JnpfKeyConsts.POPUPSELECT_ATTR); skipFields.add(JnpfKeyConsts.RELATIONFORM_ATTR); String jnpfKey = childItem.getConfig().getJnpfKey(); return skipFields.contains(jnpfKey) || (JnpfKeyConsts.CALCULATE.equals(jnpfKey) && Objects.equals(childItem.getIsStorage(), 0)); } private String getValueByType(Map map, String vModel, String jnpfKey) { if (map != null) { Object o = map.get(vModel); String value = o == null ? "" : o.toString(); switch (jnpfKey) { case JnpfKeyConsts.UPLOADFZ: List> listM = (List) o; if (listM != null) { StringJoiner sj = new StringJoiner(","); listM.stream().forEach(t -> sj.add(t.get("name"))); value = sj.toString(); } break; case JnpfKeyConsts.LOCATION: Map lcationMap = JsonUtil.stringToMap(value); if (MapUtils.isNotEmpty(lcationMap)) { value = lcationMap.get("fullAddress") != null ? lcationMap.get("fullAddress").toString() : ""; } break; default: break; } return value; } return null; } }