columnEntry : columnWidgetMap.entrySet()) {
String columnName = columnEntry.getKey();
diff --git a/common/common-online/src/main/java/apelet/common/online/util/TenantFieldUtil.java b/common/common-online/src/main/java/apelet/common/online/util/TenantFieldUtil.java
new file mode 100644
index 0000000..9403c6f
--- /dev/null
+++ b/common/common-online/src/main/java/apelet/common/online/util/TenantFieldUtil.java
@@ -0,0 +1,150 @@
+package apelet.common.online.util;
+
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import org.apache.commons.lang3.StringUtils;
+
+/**
+ * 租户自定义字段的命名约定。
+ *
+ * A 端(租户端)新增的字段统一加 {@code tenant_} 前缀,与 B 端(管理端)下发的标准字段区分开;
+ * B 端新增字段时禁止使用该前缀(见 OnlineFormController 的校验)。
+ *
+ * 字段名在各处统一小写(MetaCacheService、syncNewFieldsAndSubTables 均有 toLowerCase),
+ * 因此前缀判断前先归一化。
+ *
+ * @author chenchuchuan
+ */
+public final class TenantFieldUtil {
+
+ public static final String TENANT_FIELD_PREFIX = "tenant_";
+
+ /**
+ * 当前请求是否来自租户端(A端)。
+ *
+ * 无 TokenData(定时任务、内部调用等非 HTTP 场景)按管理端处理。
+ *
+ * @return true 表示租户端请求。
+ */
+ public static boolean isTenantRequest() {
+ apelet.common.core.object.TokenData tokenData = apelet.common.core.object.TokenData.takeFromRequest();
+ return tokenData != null && tokenData.getTenantId() != null;
+ }
+
+ /**
+ * 判断是否租户自定义字段(按列名)。
+ *
+ * @param fieldName 数据库列名,如 tenant_budget。
+ * @return true 表示租户端新增的字段。
+ */
+ public static boolean isTenantField(String fieldName) {
+ return StringUtils.isNotBlank(fieldName)
+ && fieldName.toLowerCase().startsWith(TENANT_FIELD_PREFIX);
+ }
+
+ /**
+ * 给租户自定义字段加前缀;已带前缀则原样返回。
+ *
+ * @param fieldName 原始列名,如 budget。
+ * @return 带前缀的列名,如 tenant_budget。
+ */
+ public static String applyTenantPrefix(String fieldName) {
+ if (StringUtils.isBlank(fieldName) || isTenantField(fieldName)) {
+ return fieldName;
+ }
+ return TENANT_FIELD_PREFIX + fieldName;
+ }
+
+ /**
+ * 把 widgetJson 里指定表下所有字段控件的绑定列名改写为 {@code fn} 的返回值。
+ *
+ * 只改归属 {@code table} 的控件,避免主表/子表同名时误改。
+ * 取值口径与 {@link WidgetJsonUtil#buildTableFieldMap(JSONObject)} 一致:
+ * 优先 bindData.columnName,兼容仅有 columnFieldName 的移动端字段;
+ * 表头控件(widgetType=100)读取 props.tableColumnList。
+ *
+ * @param widgetJson pc/mobile 根节点。
+ * @param tableId 目标表Id,可为 null。
+ * @param tableName 目标表名,可为 null。
+ * @param fn 列名转换函数,入参/出参均为列名。
+ */
+ public static void renameColumnNames(JSONObject widgetJson, Long tableId, String tableName,
+ java.util.function.UnaryOperator fn) {
+ if (widgetJson == null || fn == null) return;
+ for (String mode : new String[]{"pc", "mobile"}) {
+ JSONObject modeObj = widgetJson.getJSONObject(mode);
+ if (modeObj == null) continue;
+ renameInWidgets(modeObj.getJSONArray("widgetList"), tableId, tableName, fn);
+ JSONObject tableWidget = modeObj.getJSONObject("tableWidget");
+ if (tableWidget != null) {
+ renameInWidgets(tableWidget.getJSONArray("childWidgetList"), tableId, tableName, fn);
+ }
+ }
+ }
+
+ private static void renameInWidgets(JSONArray widgetList, Long tableId, String tableName,
+ java.util.function.UnaryOperator fn) {
+ if (widgetList == null || widgetList.isEmpty()) return;
+ for (int i = 0; i < widgetList.size(); i++) {
+ JSONObject widget = widgetList.getJSONObject(i);
+ if (widget == null) continue;
+ Integer widgetType = widget.getInteger("widgetType");
+ if (widgetType != null && widgetType == 100) {
+ renameInTableColumns(widget, tableId, tableName, fn);
+ continue;
+ }
+ JSONArray childWidgetList = widget.getJSONArray("childWidgetList");
+ if (childWidgetList != null && !childWidgetList.isEmpty()) {
+ renameInWidgets(childWidgetList, tableId, tableName, fn);
+ continue;
+ }
+ JSONObject bindData = widget.getJSONObject("bindData");
+ if (bindData == null || !tableMatched(bindData, tableId, tableName)) continue;
+ String columnName = bindData.getString("columnName");
+ if (StringUtils.isNotEmpty(columnName)) {
+ bindData.put("columnName", fn.apply(columnName));
+ }
+ String columnFieldName = bindData.getString("columnFieldName");
+ if (StringUtils.isNotEmpty(columnFieldName)) {
+ bindData.put("columnFieldName", fn.apply(columnFieldName));
+ }
+ }
+ }
+
+ private static void renameInTableColumns(JSONObject tableWidget, Long tableId, String tableName,
+ java.util.function.UnaryOperator fn) {
+ JSONObject bindData = tableWidget.getJSONObject("bindData");
+ JSONObject props = tableWidget.getJSONObject("props");
+ if (bindData == null || props == null || !tableMatched(bindData, tableId, tableName)) return;
+ JSONArray tableColumnList = props.getJSONArray("tableColumnList");
+ if (tableColumnList == null || tableColumnList.isEmpty()) return;
+ for (int i = 0; i < tableColumnList.size(); i++) {
+ JSONObject tableColumn = tableColumnList.getJSONObject(i);
+ String showFieldName = tableColumn.getString("showFieldName");
+ if (StringUtils.isNotEmpty(showFieldName)) {
+ tableColumn.put("showFieldName", fn.apply(showFieldName));
+ }
+ // 新增列:字段名临时存在 columnId 里(非数字),见 WidgetJsonUtil.backfillTableColumns 的约定
+ String colIdStr = tableColumn.getString("columnId");
+ if (StringUtils.isNotEmpty(colIdStr) && !colIdStr.matches("\\d+")) {
+ tableColumn.put("columnId", fn.apply(colIdStr));
+ }
+ }
+ }
+
+ /**
+ * 与 {@link WidgetJsonUtil} 的归属判断保持一致:优先 tableId,其次忽略大小写的 tableName。
+ */
+ private static boolean tableMatched(JSONObject bindData, Long tableId, String tableName) {
+ String tableKey = bindData.getString("tableId");
+ if (StringUtils.isEmpty(tableKey)) {
+ tableKey = bindData.getString("tableName");
+ }
+ if (StringUtils.isEmpty(tableKey)) return false;
+ if (tableId != null && tableKey.equals(String.valueOf(tableId))) return true;
+ return StringUtils.isNotEmpty(tableName) && tableKey.equalsIgnoreCase(tableName);
+ }
+
+ private TenantFieldUtil() {
+ }
+}
diff --git a/common/common-openapi/pom.xml b/common/common-openapi/pom.xml
index c02b425..f2ece26 100644
--- a/common/common-openapi/pom.xml
+++ b/common/common-openapi/pom.xml
@@ -31,13 +31,13 @@
apelet
common-online
- 1.0.6
+ 1.0.7
apelet
common-generator
- 1.0.5
+ 1.0.6
diff --git a/common/common-tenant-online/pom.xml b/common/common-tenant-online/pom.xml
index 48ed634..eecc03e 100644
--- a/common/common-tenant-online/pom.xml
+++ b/common/common-tenant-online/pom.xml
@@ -23,7 +23,7 @@
apelet
common-online
- 1.0.6
+ 1.0.7
\ No newline at end of file
diff --git a/common/common-tenant/src/main/java/apelet/common/tenant/dao/SyncLogMapper.java b/common/common-tenant/src/main/java/apelet/common/tenant/dao/SyncLogMapper.java
new file mode 100644
index 0000000..fceac72
--- /dev/null
+++ b/common/common-tenant/src/main/java/apelet/common/tenant/dao/SyncLogMapper.java
@@ -0,0 +1,12 @@
+package apelet.common.tenant.dao;
+
+import apelet.common.core.base.dao.BaseDaoMapper;
+import apelet.common.tenant.model.SyncLog;
+
+/**
+ * 租户同步日志访问接口。
+ *
+ * @author chenchuchuan
+ */
+public interface SyncLogMapper extends BaseDaoMapper {
+}
diff --git a/common/common-tenant/src/main/java/apelet/common/tenant/model/SyncLog.java b/common/common-tenant/src/main/java/apelet/common/tenant/model/SyncLog.java
new file mode 100644
index 0000000..9e8bf98
--- /dev/null
+++ b/common/common-tenant/src/main/java/apelet/common/tenant/model/SyncLog.java
@@ -0,0 +1,55 @@
+package apelet.common.tenant.model;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 租户同步日志。每条同步消息在租户端执行一条记录。
+ *
+ * @author chenchuchuan
+ */
+@Data
+@TableName(value = "zz_sync_log")
+public class SyncLog {
+
+ @TableId(value = "id", type = IdType.INPUT)
+ private Long id;
+
+ /**
+ * 消息流水号,格式:{租户Id}-{雪花Id}-{消息类型}-{命令}-{时间戳}。
+ */
+ @TableField(value = "trans_id")
+ private String transId;
+
+ @TableField(value = "tenant_id")
+ private Long tenantId;
+
+ @TableField(value = "message_type")
+ private String messageType;
+
+ @TableField(value = "message_command")
+ private String messageCommand;
+
+ /**
+ * SUCCESS / FAILED / SKIPPED。
+ */
+ @TableField(value = "status")
+ private String status;
+
+ @TableField(value = "error_msg")
+ private String errorMsg;
+
+ @TableField(value = "content_summary")
+ private String contentSummary;
+
+ @TableField(value = "cost_ms")
+ private Long costMs;
+
+ @TableField(value = "create_time")
+ private Date createTime;
+}
diff --git a/common/common-tenant/src/main/java/apelet/common/tenant/service/SyncLogService.java b/common/common-tenant/src/main/java/apelet/common/tenant/service/SyncLogService.java
new file mode 100644
index 0000000..a28c0cd
--- /dev/null
+++ b/common/common-tenant/src/main/java/apelet/common/tenant/service/SyncLogService.java
@@ -0,0 +1,79 @@
+package apelet.common.tenant.service;
+
+import apelet.common.tenant.dao.SyncLogMapper;
+import apelet.common.tenant.model.SyncLog;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 租户同步日志服务。
+ *
+ * 由 tenant-sync 的切面在每条消息执行结束后写入,记录哪个租户、什么类型、成功失败与原因。
+ * 写日志失败不能影响主同步流程,因此本类内部吞掉异常只打 warn。
+ *
+ * @author chenchuchuan
+ */
+@Slf4j
+@Service
+public class SyncLogService {
+
+ /**
+ * 日志表主键。日志本身是辅助数据,不必走分布式 Id 生成器(common-tenant 也不依赖 common-sequence)。
+ */
+ private static final AtomicLong ID_SEQ = new AtomicLong(System.currentTimeMillis());
+
+ @Autowired
+ private SyncLogMapper syncLogMapper;
+
+ /**
+ * 记录一条同步日志。
+ *
+ * @param transId 消息流水号
+ * @param tenantId 目标租户Id
+ * @param messageType 消息类型
+ * @param messageCommand 消息命令
+ * @param status SUCCESS / FAILED / SKIPPED
+ * @param errorMsg 失败原因
+ * @param contentSummary 消息体摘要
+ * @param costMs 耗时(毫秒)
+ */
+ public void record(String transId, Long tenantId, String messageType, String messageCommand,
+ String status, String errorMsg, String contentSummary, Long costMs) {
+ try {
+ SyncLog syncLog = new SyncLog();
+ syncLog.setId(ID_SEQ.incrementAndGet());
+ syncLog.setTransId(transId);
+ syncLog.setTenantId(tenantId);
+ syncLog.setMessageType(messageType);
+ syncLog.setMessageCommand(messageCommand);
+ syncLog.setStatus(status);
+ syncLog.setErrorMsg(truncate(errorMsg, 2000));
+ syncLog.setContentSummary(truncate(contentSummary, 2000));
+ syncLog.setCostMs(costMs);
+ syncLog.setCreateTime(new Date());
+ syncLogMapper.insert(syncLog);
+ } catch (Exception e) {
+ log.warn("写入同步日志失败,transId={}: {}", transId, e.getMessage());
+ }
+ }
+
+ /**
+ * 按租户 + 时间倒序查询最近的同步记录。
+ */
+ public List listRecent(Long tenantId, int limit) {
+ return syncLogMapper.selectList(new LambdaQueryWrapper()
+ .eq(tenantId != null, SyncLog::getTenantId, tenantId)
+ .orderByDesc(SyncLog::getCreateTime)
+ .last("LIMIT " + Math.max(1, Math.min(limit, 500))));
+ }
+
+ private String truncate(String value, int max) {
+ return value == null || value.length() <= max ? value : value.substring(0, max);
+ }
+}