DynamicDataService

DynamicDataService 是宿主托管的 Service,用于读写动态 model 表(mt_<modelCode>)。它强制 tenant 隔离、对每一次查询执行参数化(无字符串拼接、无注入面)、应用软删语义并写出 ab_data_change_log 行。插件应调用该 Service,而不是直接使用裸 JDBC 或 MyBatis mapper。

对于到达前端的 list/grid 查询,对应的 HTTP 端点是 GET /api/dynamic/{pageKey}/list —— 两侧使用同一套参数契约以避免漂移(红线 §5)。

概念

每一个 autoPublishModels:true 的 model 都会由 SchemaManagementServiceImpl 创建对应的 mt_<modelCode> 表。每行携带一个数据库 IDENTITY 自增 id(BIGINT,主键)和一个 ULID pid(VARCHAR(32),NOT NULL UNIQUE)—— id 由数据库在 insert 时生成,pid 由平台 UniqueIdGenerator 在 insert 时填充。多 tenant 数据按 tenant_id 隔离;Service 会从 MetaContext 注入该过滤条件。

对于没有 HTTP MetaContext 的后台 worker,参见 BackgroundDataAccessor SPI

接口签名

package com.auraboot.framework.meta.service;

import com.auraboot.framework.meta.dto.*;
import java.util.List;
import java.util.Map;

public interface DynamicDataService {

    /** 分页列表 —— 供 /api/dynamic/{pageKey}/list 使用。 */
    PaginationResult<Map<String, Object>> list(String modelCode, DynamicQueryRequest request);

    /** 按 recordId(pid)读单行;未找到返回 null。 */
    Map<String, Object> getById(String modelCode, String recordId);

    /** 创建;返回创建后的完整记录(含系统字段)。 */
    Map<String, Object> create(String modelCode, Map<String, Object> data);

    /** 按 recordId 更新;返回更新后的完整记录。 */
    Map<String, Object> update(String modelCode, String recordId, Map<String, Object> data);

    /** 按 recordId 删除。 */
    void delete(String modelCode, String recordId);

    /** 分组 / 聚合 —— 供 DataProviderExtension 与 dashboard 使用。 */
    Map<String, Object> aggregate(String modelCode, AggregateRequest aggregateRequest);
}

上面仅列出最常用的方法。完整接口还包含 batchCreate / batchUpdate / batchDeleteexecuteCustomQuerygetStats、关联数据操作(getRelationData / createRelations / removeRelations)、validategetFieldOptions、导入导出与 saveWithRelations 等;以仓库内 DynamicDataService.java 为准。

DynamicQueryRequest 是分页查询的 DTO(@Data @Builder):

public class DynamicQueryRequest {
    private Integer pageNum;                 // 页码
    private Integer pageSize;                // 页面大小
    private List<QueryCondition> conditions; // 查询条件
    private List<SortField> sortFields;      // 排序字段(可多字段)
    private String keyword;                  // 搜索关键词
    private Map<String, Object> extraParams; // 额外参数
    private String viewId;                   // SavedView pid(可选)
    private Long cursor;                     // keyset 分页游标(可选)
}

QueryCondition 使用 Operator 枚举(无 .eq() 这类静态工厂),合法操作符为:EQ / NE / GT / GE / LT / LE / LIKE / NOT_LIKE / IN / NOT_IN / IS_NULL / IS_NOT_NULL / BETWEEN / NOT_BETWEEN

实现示例

一个定时任务,按销售代表汇总每月成单收入,并写入到汇总 model:

package com.acme.crm.rollup;

import com.auraboot.framework.meta.service.DynamicDataService;
import com.auraboot.framework.meta.dto.DynamicQueryRequest;
import com.auraboot.framework.meta.dto.QueryCondition;
import com.auraboot.framework.meta.dto.QueryCondition.Operator;
import com.auraboot.framework.meta.dto.PaginationResult;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;

import java.time.LocalDate;
import java.util.List;
import java.util.Map;

@Component
public class MonthlyRevenueRollup {

    @Autowired private DynamicDataService data;

    @Scheduled(cron = "0 30 0 1 * *") // 每月 1 日 00:30
    public void run() {
        LocalDate firstOfPrev = LocalDate.now().minusMonths(1).withDayOfMonth(1);
        LocalDate firstOfThis = firstOfPrev.plusMonths(1);

        DynamicQueryRequest q = DynamicQueryRequest.builder()
            .pageSize(500)
            .conditions(List.of(
                QueryCondition.builder().fieldName("status").operator(Operator.EQ).value("closed_won").build(),
                QueryCondition.builder().fieldName("close_date").operator(Operator.GE).value(firstOfPrev).build(),
                QueryCondition.builder().fieldName("close_date").operator(Operator.LT).value(firstOfThis).build()
            ))
            .build();

        PaginationResult<Map<String, Object>> res = data.list("crm_deal", q);
        Map<Object, Double> byRep = res.getRecords().stream().collect(
            groupingBy(r -> r.get("owner_id"),
                       summingDouble(r -> ((Number) r.get("amount")).doubleValue())));

        byRep.forEach((rep, total) -> data.create("crm_revenue_rollup", Map.of(
            "month", firstOfPrev,
            "owner_id", rep,
            "total", total
        )));
    }
}

注册

宿主托管的单例 bean —— @Autowired 即可注入。没有 SPI;这是一个纯 Service 接口。实现位于 auraboot/platform(com.auraboot.framework.meta.service.impl.DynamicDataServiceImpl)。

常见陷阱

  • 凭经验猜参数名。 前端 REST 分页(GET /api/dynamic/{pageKey}/list)使用查询参数 pageNum / pageSize / filters / keyword / sortField / sortOrder(filtersQueryCondition[] 的 JSON 字符串);进入 Service 层后会被解析成 DynamicQueryRequestconditions / sortFields。请与 DynamicController 实测核对(红线 §5)。
  • 为了"性能"使用裸 JDBC。 会绕过 tenant 过滤、软删与 change-log 写出。绝不要这么做 —— 参见红线 §15(例如 Long.parseLong(ULID) 那次事故)。
  • 混淆 idpid id 是数据库 IDENTITY 自增 BIGINT;pid 是 ULID String(VARCHAR(32))。Service 的 getById / update / delete 用的 recordIdpidLong.parseLong(pid) 会崩。请使用正确类型。
  • 在后台 worker 中无 tenant context 直接写入。 mt_<modelCode>tenant_idNOT NULL,而 Kafka consumer / 定时任务运行在请求上下文之外,线程上没有隐式 tenant —— 缺 tenant 会令写入失败。请改用 BackgroundDataAccessor,它对每次调用显式接收 tenantId 参数(如 create(long tenantId, String modelCode, Map<String,Object> data));幂等插入用 tryCreate,它在唯一约束冲突时返回 Optional.empty()

相关