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/batchDelete、executeCustomQuery、getStats、关联数据操作(getRelationData/createRelations/removeRelations)、validate、getFieldOptions、导入导出与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(filters是QueryCondition[]的 JSON 字符串);进入 Service 层后会被解析成DynamicQueryRequest的conditions/sortFields。请与DynamicController实测核对(红线 §5)。 - 为了"性能"使用裸 JDBC。 会绕过 tenant 过滤、软删与 change-log 写出。绝不要这么做 —— 参见红线 §15(例如
Long.parseLong(ULID)那次事故)。 - 混淆
id与pid。id是数据库 IDENTITY 自增BIGINT;pid是 ULIDString(VARCHAR(32))。Service 的getById/update/delete用的recordId是pid。Long.parseLong(pid)会崩。请使用正确类型。 - 在后台 worker 中无 tenant context 直接写入。
mt_<modelCode>的tenant_id是NOT NULL,而 Kafka consumer / 定时任务运行在请求上下文之外,线程上没有隐式 tenant —— 缺 tenant 会令写入失败。请改用BackgroundDataAccessor,它对每次调用显式接收tenantId参数(如create(long tenantId, String modelCode, Map<String,Object> data));幂等插入用tryCreate,它在唯一约束冲突时返回Optional.empty()。
相关
- REST API —— HTTP 等价入口
- BackgroundDataAccessor SPI
- MetadataRegistry service