diff --git a/.gitignore b/.gitignore index a87db82..23f9046 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,6 @@ build/ .vscode/ ### miss ### +.fonts .DS_Store .codegraph -.fonts \ No newline at end of file diff --git a/.hermes/decisions.md b/.hermes/decisions.md new file mode 100644 index 0000000..71eba76 --- /dev/null +++ b/.hermes/decisions.md @@ -0,0 +1,28 @@ +# Report Engine — 项目级决策原则 + +## 架构决策原则 + +1. **按业务域划分包** — 不按技术层划分,父包要名副其实 +2. **扩展点统一范式** — `supports()` + 注册表,新增 = 新实现 + 注册,零改动接入 +3. **Sealed Types** — 编译期穷尽,确保 switch 覆盖所有子类型 +4. **值层与控制层分离** — CellBinding 模式:值是什么 vs 值怎么铺开 +5. **模板层与语义层分离** — 视觉呈现与数据绑定完全分离 + +## 前端决策原则 + +1. **优先 antd 组件** — 不自造轮子 +2. **import 路径规范** — 同目录 `./`,跨目录 `@/`,跨包用包名 +3. **pnpm monorepo** — packages/ 下是库,apps/ 下是应用 + +## 代码提交纪律 + +1. 完成修改后不立即 commit,先跑测试 +2. 测试通过 → commit → merge to dev +3. 不碰 main + +## 技术栈约束 + +- 后端:Java 17 + Maven + Spring Boot +- 前端:pnpm monorepo + React + Univer + antd +- 测试:后端 `./mvnw test`,前端 `pnpm test` +- 格式化:`./scripts/format.sh` diff --git a/CLAUDE.md b/CLAUDE.md index 9756580..221518e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,9 +100,12 @@ report-engine/ ``` com.codingapi.report ├── data/ 数据域:数据从哪来 -│ ├── datamodel/ DataModel(可复用语义层) -│ ├── datasource/ DataSource + DataExtractor SPI -│ ├── dataset/ Dataset(sealed) → TableDataset / UnionDataset +│ ├── datamodel/ DataModel(领域实体,持久化 + toDTO/fromDTO)/ DataModelStatus 枚举 +│ ├── datasource/ DataSource(聚合根,持有 List)+ DataExtractor SPI +│ │ ├── type/ DataSourceType(sealed interface, type() 返回判别串) → Csv/Excel/Db 三实现 +│ │ ├── extractor/ CsvDataExtractor / DbDataExtractor / ExcelDataExtractor +│ │ └── credential/ CredentialService(敏感字段加解密/脱敏) +│ ├── dataset/ Dataset(sealed) → TableDataset(聚合所属 DataSource)/ UnionDataset │ └── relation/ Relationship(跨数据集,独立成域) ├── operator/ 算子域:作用在 RawTable 行上的运算 │ ├── aggregation/ Aggregation + Aggregator SPI + 注册表 @@ -113,17 +116,21 @@ com.codingapi.report │ ├── eval/ 各节点求值策略 │ └── function/ ValueFunction SPI + 注册表 ├── param/ 参数域:运行时值解析 -├── repository/ 存储抽象域:报表配置存取(零 Spring 依赖) -│ ├── ReportRepository 存储扩展点(使用方提供实现) +├── dto/ DTO 契约域:纯 record/POJO,前端 JSON ↔ 领域对象(无 Jackson 多态注解时的中介) +│ ├── report/ BindingDTO/ValueDTO/ConditionDTO/.../ReportDTO/ParamDTO +│ └── datamodel/ DataModelDTO/DataSourceDTO/DatasetDTO/FieldDTO/UnionMemberDTO/RelationshipDTO +├── repository/ 存储抽象域:领域对象存取(零 Spring 依赖) +│ ├── ReportRepository / DataModelRepository 存储扩展点(使用方提供实现) │ ├── PageQuery 分页入参(归一逻辑下沉到访问器) │ └── PageResult 分页结果 -└── render/ 渲染域:数据如何映射到单元格 - ├── Report 报表定义 +└── core/ 渲染域(原 render 包改名):报表领域 + 渲染引擎 + ├── Report 报表领域实体(引用 DataModel + toDTO()/fromDTO()) + ├── RenderDtoConverter DTO ↔ 领域 双向转换(含 Value 树) ├── grid/ CellBinding(值层 + 控制层) └── engine/ ReportRenderer + Operators ``` -**划分原则**:按真实业务域组织,父包要名副其实。`relation` 跨数据集所以独立;`operator` 是聚合算子 + 条件算子的共享抽象。 +**划分原则**:按真实业务域组织,父包要名副其实。`relation` 跨数据集所以独立;`operator` 是聚合算子 + 条件算子的共享抽象。**领域对象与 DTO 分离**:`DataModel`/`core.Report` 是领域实体(仓库直接存取),`dto/` 是出入站契约,二者经 `toDTO()`/`fromDTO()` 互转,不再有 `config` 包混用实体与 DTO。 #### Expression Engine (统一取值机制) @@ -192,6 +199,7 @@ render(dataModel, report, paramContext, templateWorkbook) - `Dataset` → `TableDataset`(物理表)/ `UnionDataset`(UNION 派生) - `Value` → 8 种节点(见上) - `ParamSource` → `External` / `Cell` / `Constant` +- `DataSourceType` → `CsvDataSourceType` / `ExcelDataSourceType` / `DbDataSourceType`(`type()` 返回判别串) 确保 `switch` / `instanceof` 覆盖所有子类型。 @@ -216,38 +224,41 @@ Spring Boot 自动配置 + **全部通用 REST API**。API 是 Spring Bean(不 - `POST /api/excel/generate` / `POST /api/excel/import` — Excel 导出/导入 **报表引擎 API**(`controller/`): -- `POST /api/report/render`(`ReportRenderController`)— 配置 + 模板快照 → `ReportRenderer.render()` → 填充数据的 `.xlsx` -- `/api/report/configs[...]`(`ReportConfigController`)— 报表配置保存(`@RequestBody ReportConfig`)/加载(附带 `dataModel` 富化)/分页列表(GET,入参 `SearchRequest`,内部转 `PageQuery` 调 `repository.page(PageQuery): PageResult`)/删除 -- `GET /api/datamodels`(`DataModelController`)— 数据模型列表(注入 `List`,供创建报表时选择) +- `POST /api/report/render`(`ReportRenderController`)— 渲染请求 + 模板快照 → `RenderDtoConverter` → `ReportRenderer.render()` → 填充数据的 `.xlsx` +- `/api/report/configs[...]`(`ReportConfigController`)— 报表保存(`@RequestBody ReportDTO` → `Report.fromDTO` 入库)/加载(`Report.toDTO` + `dataModel` 富化)/分页列表(GET,`SearchRequest`→`PageQuery`,`repository.page(PageQuery): PageResult` → brief)/删除 +- `/api/datamodels[...]`(`DataModelMgmtController`)— 数据模型 CRUD(`DataModelDTO` 出入站,出口凭证脱敏 / `***` 回填) +- `/api/datasources[...]`(`DataSourceController`)— 连接测试 + 表/列探查 - `GET /api/datasets[...]`(`DatasetController`)— 数据集列表(含字段)/预览前 N 行 - `GET /api/expression/functions`(`ExpressionController`)— 公式目录(聚合 + 函数元信息) **存储抽象**(接口在 framework,实现由使用方提供): -- `ReportRepository` 接口在 **framework**(`com.codingapi.report.repository`):`save(ReportConfig):String` / `find(id):ReportConfig` / `page(PageQuery):PageResult` / `delete(id)`。分页用 framework 纯类型 `PageQuery`(current/pageSize,归一逻辑下沉到访问器)/`PageResult`(content/total),**不依赖 Spring**,保持 framework 可独立发布。starter **不提供默认实现**(存储交使用方),用 `@ConditionalOnMissingBean` 装配 Controller 允许覆盖。 -- `ReportConfig` 实体在 **framework**(`com.codingapi.report.config`):强类型 POJO,`id/name/dataModelId/createTime/updateTime`(long 时间戳)+ `cellBindings/loopBlocks/summaries/params/template`(引用 DTO record)+ `dataModel`(响应富化字段,`@JsonInclude NON_NULL`,仅 GET 返回不持久化)。example 的 `InMemoryReportRepository` 在 save 时设时间戳、null 列表归一空。 -- Spring 类型仅存在于 starter 的 `ReportConfigController` 边界:入参 `SearchRequest` → `PageQuery`,返回 `PageResult` → `MultiResponse`,转换在 Controller 内完成。 - -**DTO 契约层**(DTO record 在 **framework** `com.codingapi.report.config.dto.ConfigDtos`,转换在 starter `RenderDtoConverter`): -- DTO record(`BindingDTO`/`ValueDTO`/`LoopBlockDTO`/`SummaryRowDTO`/`SummaryCellDTO`/`ConditionDTO`/`PartDTO`/`SourceDTO`/`FieldRefDTO`)同时是 `ReportConfig` 实体的持久化字段类型 + 前端 JSON 契约。 -- 前端 JSON → DTO record(Jackson)→ framework 领域对象(`CellBinding`/`Value`/`LoopBlock`/`SummaryRow`)由 `RenderDtoConverter` 转换。 -- `Value` 等 sealed interface **未加 Jackson 多态注解**,故用 DTO 中间层而非直接反序列化;`RenderDtos` 仅剩 `RenderRequest`(渲染请求,`params` 为运行时值 Map,与实体的 `params` 定义不同)。 -- ⚠️ **给 `CellBinding` 加字段要同步五处**(否则字段会在某条链路被悄悄丢弃):framework `ConfigDtos.BindingDTO`、starter `RenderDtoConverter.convertBindings`、前端 `report-engine` 的 `CellBinding` 类型、**`report-api` 的 `RenderBindingDTO` + `app-pc` 的 `toBindingDTO`**(渲染走显式字段映射,与保存走原始对象是两条独立链路)。`SummaryRow.id` 也在 DTO 中持久化。 +- `ReportRepository` / `DataModelRepository` 接口在 **framework**(`com.codingapi.report.repository`),以**领域对象**存取:`ReportRepository.save(Report)/find:Report/page(PageQuery):PageResult/delete`,`DataModelRepository` 同范式存 `DataModel`。分页用 framework 纯类型 `PageQuery`/`PageResult`,**不依赖 Spring**,保持 framework 可独立发布。starter **不提供默认实现**(存储交使用方),`@ConditionalOnMissingBean` 装配 Controller/Service 允许覆盖。 +- 领域实体均在 framework:`core.Report`(`id/name/dataModelId/createTime/updateTime` + `cellBindings/loopBlocks/summaries/parameters/template` + 运行时 `dataModel` 引用)、`data.datamodel.DataModel`(含 `DataModelStatus` 枚举 + 派生 `datasources()`)。二者自带 `toDTO()`/`fromDTO()` 与 `dto/` 记录互转。example 的 `InMemory*Repository` 在 save 时设时间戳、null 列表归一、默认 status。 +- 凭证(DB 密码等):领域 `DataSource.config` **明文存内存**,仅出口 `toDTO` 由 `CredentialService` 脱敏、保存时 `***` 占位回填旧值;落盘加密交使用方仓库实现。 +- Spring 类型仅存在于 Controller 边界:入参 `SearchRequest` → `PageQuery`,返回 `PageResult` → `MultiResponse`,转换在 Controller 内完成。 + +**DTO 契约层**(DTO record 在 **framework** `com.codingapi.report.dto.{report,datamodel}`,转换在 framework `core.RenderDtoConverter`): +- 报表 DTO(`dto.report.*`:`BindingDTO`/`ValueDTO`/`LoopBlockDTO`/`SummaryRowDTO`/`SummaryCellDTO`/`ConditionDTO`/`PartDTO`/`SourceDTO`/`FieldRefDTO`/`ReportDTO`/`ParamDTO`)是前端 JSON 契约 + `Report.toDTO/fromDTO` 载体;数据模型 DTO 在 `dto.datamodel.*`。 +- 前端 JSON ↔ DTO record(Jackson)↔ framework 领域对象(`CellBinding`/`Value`/`LoopBlock`/`SummaryRow`/`Report`/`DataModel`),由 `RenderDtoConverter`(已下沉 framework `core`)+ 各领域对象 `toDTO()`/`fromDTO()` 双向转换。 +- `Value` 等 sealed interface **未加 Jackson 多态注解**,故用 DTO 中间层而非直接反序列化;starter `RenderDtos` 仅剩 `RenderRequest`(渲染请求,`params` 为运行时值 Map)。 +- ⚠️ **给 `CellBinding` 加字段要同步多处**(否则字段会在某条链路被悄悄丢弃):framework `dto.report.BindingDTO`、`core.RenderDtoConverter`(`convertBindings` 入站 + `toBindingDtos` 出站**两个方向都要管**)、前端 `report-engine` 的 `CellBinding` 类型、**`report-api` 的 `RenderBindingDTO` + `app-pc` 的 `toBindingDTO`**。 +- ⚠️ **纯前端展示字段不持久化**:`BindingDTO.preview` / `ParamDTO.id` / `SummaryRow(DTO).id` 在领域往返中**不保留**(领域对象无对应字段),由前端按需重建。 ### Example Module (`report-engine-example`) 演示应用,只承载**应用级实现**:数据集配置(具体数据)、示例报表预存。通用 API / DTO 转换适配均在 starter,存储抽象接口(`ReportRepository`)在 framework。 -**数据集配置** (`DatasetConfig.java`): -- 扫描 `classpath:data/*.json` 描述文件,每个 JSON 对应一个 CSV 数据集(字段名/别名/类型/主键) -- 每个数据集自动创建独立 `DataSource`(`config.path` 指向 CSV classpath 路径) +**默认数据模型预存** (`DataModelSeeder.java`): +- 启动时(`@EventListener(ApplicationReadyEvent)`)扫描 `classpath:data/*.json` 描述文件,每个 JSON 对应一个 CSV 数据集(字段名/别名/类型/主键) +- 每个数据集创建独立 `DataSource`(type=`CsvDataSourceType`,`config.path` 指向 CSV classpath 路径),`TableDataset` 聚合该 `DataSource` - `data/relationships.json` 定义跨数据集 JOIN(`Relationship` with `JoinType` + `RelationOrigin.MANUAL`) -- 构建唯一 `DataModel` Bean(id=`"default"`)+ `CsvDataExtractor` Bean,注入 starter 的 Controller +- 构建 id=`"default"` 的领域 `DataModel` 写入 `DataModelRepository`(内存实现);CSV/DB/Excel 提取器 Bean 由 starter 自动配置注册 **示例报表预存** (`ReportTemplateSeeder.java`): - `@Component`,启动时(`@EventListener(ApplicationReadyEvent)`)向 `ReportRepository`(example 内存实现)写入 10 个完整报表配置 - 示例报表使用**写死的稳定 id**(`example-simple-list` 等),保证重启后 id 不变、前端引用不失效(内存存储重启即丢,但 seeder 用同一批 id 重写) - 涵盖:简单列表、分组列表、多级分组统计、主从合并、小计+总计、薪资条循环、独立数据带并列、并列双汇总、交叉表(区域季度销售)、横向汇总(商品横向汇总表) -- 配置用 `ReportConfigBuilder`(链式构造器)生成,产物为强类型 `ReportConfig`(引用 framework DTO record + `Workbook` POJO)。示例列表不再有专用端点,统一走 starter 的 `GET /api/report/configs`(示例与用户报表同表)。 +- 配置用 `ReportConfigBuilder`(链式构造器)生成 `ReportDTO`,经 `Report.fromDTO` 转领域 `Report` 入库(`InMemoryReportRepository` 存领域对象)。示例列表统一走 starter 的 `GET /api/report/configs`(示例与用户报表同表)。 **CSV 数据集**(`src/main/resources/data/`):10 个 CSV + 对应 JSON 描述 + `relationships.json`。 @@ -261,7 +272,7 @@ Spring Boot 自动配置 + **全部通用 REST API**。API 是 Spring Bean(不 - **`report-univer`**:Univer 电子表格 React 封装。提供 `UniverSheet` 组件 + `UniverSheetHandle` 命令式句柄(`getSnapshot` / `loadSnapshot` / `setCellValue` / `getActiveSheetId`)。三层属性存储(cellProps / mergeProps / loopBlockProps)通过泛型自定义。 - **`report-api`**:后端 API 客户端。axios 实例(`baseURL: '/api'`)+ 响应拦截器自动解包 `SingleResponse` / `MultiResponse`。暴露 `saveReportConfig` / `loadReportConfig` / `deleteReportConfig` / `listReportConfigs(current,pageSize)` / `listDataModels` / `renderReport` / `previewReport` / `drillReport` / `exportExcel` / `importExcel` / `fetchFonts`。 -- **`report-engine`**:报表设计器组件库(纯 UI,不直接调 API)。核心导出:`ReportEngine`(设计器)、`ReportPreview`(预览能力组件,含参数弹窗+预览抽屉+反查+抽屉内导出)、`useReportPreview` hook。 +- **`report-engine`**:报表设计器 + 数据源管理组件库(纯 UI,不直接调 API;原 `report-datasource` 已并入本包)。核心导出:`ReportEngine`(设计器)、`ReportPreview`(预览能力组件,含参数弹窗+预览抽屉+反查+抽屉内导出)、`useReportPreview` hook;数据源管理组件 `ConnectionForm`/`ExploreTree`/`DatasetManager`/`RelationEditor` + `useDatasource`/`useExplore`(经 `DatasourceService` 注入 report-api 实现)。 #### ReportEngine 组件 @@ -281,7 +292,7 @@ Spring Boot 自动配置 + **全部通用 REST API**。API 是 Spring Bean(不 **配置加载与保存**: - `loadReportConfig(config)` — 加载快照 → 获取 Univer 实际 sheet ID → 重映射所有 cellKey → 回写绑定显示文本(`valueDisplayText`) - `handleSaveReport`(`use-report-io`)— 收集 `getSnapshot()` + cellBindings/loopBlocks/summaries/params + `dataModelId` → 调 `onSaveReport` 回调;保存前剥离 `displayText`、对模板应用 `templateToString` -- `ReportConfig` 持久化结构(framework 实体):`id / name / dataModelId / createTime / updateTime(long 时间戳) / cellBindings / loopBlocks / summaries / params / template(Workbook)` + `dataModel`(响应富化,不持久化)。时间戳由 `InMemoryReportRepository.save` 设置。 +- 出入站结构(前后端同名 `ReportDTO` ↔ 领域 `core.Report`):`id / name / dataModelId / createTime / updateTime(long 时间戳) / cellBindings / loopBlocks / summaries / params / template(Workbook)` + `dataModel`(响应富化,不持久化)。时间戳由 `InMemoryReportRepository.save` 设置(存领域 `Report`)。前端类型已与后端对齐(`ReportConfig`→`ReportDTO`、`ReportParam`→`ParamDTO`)。 **模板预设**(`TemplatePreset` 接口 + `applyTemplate`)仍保留为组件能力,但 app-pc 已改为后端预存示例报表 + 导航加载模式。 @@ -323,7 +334,7 @@ Spring Boot 自动配置 + **全部通用 REST API**。API 是 Spring Bean(不 - 前端 `UniverSheet` 裁剪了大量菜单项,仅保留报表设计所需的最小功能集 - **跨模块修改**:修改 framework/excel/starter 后必须 `./mvnw install -DskipTests` 再启动 example,否则 example 使用的是本地仓库中的旧 JAR - **Univer 默认 sheet ID**:UniverSheet 创建的默认 sheet ID 不一定是 `'sheet1'`(可能是 UUID),需要通过 `getActiveSheetId()` 从快照获取实际 ID -- **Value sealed interface 无 Jackson 注解**:前后端传输/持久化使用 DTO record(在 framework `ConfigDtos`,同时是 `ReportConfig` 实体字段类型)+ starter `RenderDtoConverter` 中间转换,而非直接在 Value 上加 `@JsonTypeInfo`。`ReportRepository` 接口与 `ReportConfig` 实体均放 framework(纯 POJO + 纯分页类型 `PageQuery`/`PageResult`,零 Spring 依赖),保持 framework 可独立发布。 +- **Value sealed interface 无 Jackson 注解**:前后端传输使用 DTO record(framework `dto.report.*`)+ framework `core.RenderDtoConverter` 双向转换,而非直接在 Value 上加 `@JsonTypeInfo`。仓库(`ReportRepository`/`DataModelRepository`)以**领域对象**存取(`core.Report`/`data.datamodel.DataModel` 自带 `toDTO/fromDTO`),接口 + 分页类型 `PageQuery`/`PageResult` 均零 Spring 依赖,保持 framework 可独立发布。 - **模板层样式继承**:`renderLoop` 中后续迭代的合并区域需要显式复制(`seedTemplate` 只载入原始位置的 merge),样式通过 `place()` 从模板源格继承 - **loadReportConfig sheet ID 重映射**:后端存储的 cellKey 中 sheet ID 可能是 `"sheet1"`,但 Univer 运行时活动工作表 ID 可能是 UUID。`loadReportConfig` 必须先 `getActiveSheetId()` 获取实际 ID,再重映射所有 cellKey/parentCell,最后回写绑定显示文本 - **数据模型随配置加载**:`GET /configs/{id}` 返回的 `dataModel` 字段由后端从注入的 `DataModel` Bean 实时解析(当前始终返回唯一的全局模型),配置中仅存 `dataModelId` 引用 @@ -335,4 +346,4 @@ Spring Boot 自动配置 + **全部通用 REST API**。API 是 Spring Bean(不 - **展示/真实值分轨(前端 report-engine)**:单元格「显示别名、传输真实 ID」。`value.payload` 是真实 ID(权威,用于传输/导出);`CellBinding`/`SummaryCell.displayText` 是别名展示文本(**transient**,由 `valueDisplayText(value)` 正向派生,用于①写进单元格显示 ②回声判别)。**后端完全不接收、不存储 `displayText`**(与需同步五处的渲染契约字段不同),保存出口(`use-report-io` 的 `handleSaveReport` / `getReportConfig`)剥离。展示永远由 `value` **正向**派生,**绝不反解 `displayText` → `value`**——别名→ID 是多对一(可重名),反向有歧义 - **回声判别替代 isLoadingRef(`handleCellValueChange`)**:往单元格写显示文本会触发 `set-range-values` mutation,需区分「程序回写」与「用户手敲」。用 `displayText` 作基准:新文本 `=== displayText` ⇒ 回声,忽略;`=== ''` ⇒ 清空,移除绑定;否则用户手敲 → 纯文本/模板格退化为 `Literal`(**不反解别名、不构造引用洞**),字段/聚合/参数等引用格保护不覆盖(改表达式走属性面板/拖拽)。基于数据基准而非时序标志,不依赖 mutation 是否同步派发 - **预览能力下沉(report-engine `ReportPreview` 组件)**:参数弹窗→渲染→预览抽屉→反查→抽屉内导出全流程封装为 `ReportPreview`(`components/preview/preview.tsx`),设计器与独立预览页共用。`useReportPreview` hook 只管逻辑/状态,JSX 在组件层渲染(含私有 `WorkbookTable` 画表格,不单独导出)。**纯 UI 不调 API**:通过 `renderService` prop 注入 report-api 的 `previewReport`/`renderReport`/`drillReport`(只导类型,不导函数)。设计器点「预览」→ `setPreviewConfig(newConfig)`(引用变化触发);预览页加载配置后 `setPreviewConfig(loaded)`。`ReportPreview` 用 `lastPreviewedRef` 去重避免 strict-mode 重复触发;预览页加载 effect 用局部 `active` 标志(**勿用跨渲染 `startedRef`**,否则 strict-mode 双调用会让预览永不执行)。抽屉 `onClose` 回调(`ReportPreview` 的 `onClose` prop):设计器不传(停在设计器),预览页传 `navigate('/reports')`。 -- **已保存配置即渲染就绪态**:`handleSaveReport` 保存时已对模板应用 `templateToString` 并剥离 `displayText`,故 `loadReportConfig` 返回的配置可直接喂给 `ReportPreview`(无需 `collectRenderArgs` 预处理;`preview` 字段可选,后端仅存储不渲染)。 +- **已保存配置即渲染就绪态**:`handleSaveReport` 保存时已对模板应用 `templateToString` 并剥离 `displayText`,故 `loadReportConfig` 返回的配置可直接喂给 `ReportPreview`(无需 `collectRenderArgs` 预处理;`preview` 字段为纯前端展示缓存,后端不再持久化、由前端重建)。 diff --git a/README.md b/README.md index 35798f3..03b692a 100644 --- a/README.md +++ b/README.md @@ -46,13 +46,13 @@ - [x] **字体管理系统**: - 后端:双目录扫描(内置 + 自定义)、文件名前缀排序、JVM 注册 - 前端:`onFontRequest` 回调机制、localStorage 缓存、@font-face 动态注入 -- [x] **Spring Boot Starter**(`report-engine-starter`):自动装配 FontRegistry + 全部通用 REST API(渲染 / 数据集 / 公式目录 / 报表配置 CRUD / 数据模型列表 / 字体 / Excel);`ReportRepository` 接口(`save/find/page(PageQuery):PageResult/delete`)+ 强类型 `ReportConfig` 实体均在 **framework**(零 Spring 依赖,分页用 framework 纯类型 `PageQuery`/`PageResult`),starter 仅在 Controller 边界做 `SearchRequest↔PageQuery` / `PageResult↔MultiResponse` 适配,`@ConditionalOnMissingBean` 允许使用方覆盖实现 +- [x] **Spring Boot Starter**(`report-engine-starter`):自动装配 FontRegistry + 全部通用 REST API(渲染 / 数据集 / 公式目录 / 报表配置 CRUD / 数据模型列表 / 字体 / Excel);`ReportRepository` 接口(`save/find/page(PageQuery):PageResult/delete`)+ 领域实体 `core.Report` / `data.datamodel.DataModel`(自带 `toDTO/fromDTO`)均在 **framework**(零 Spring 依赖,分页用 framework 纯类型 `PageQuery`/`PageResult`),starter 仅在 Controller 边界做 `SearchRequest↔PageQuery` / `PageResult↔MultiResponse` 适配,`@ConditionalOnMissingBean` 允许使用方覆盖实现 - [x] **API 响应标准化**:统一使用 `SingleResponse` / `MultiResponse` 包装,前端 axios 拦截器自动解包 - [x] **报表设计器布局**(`report-engine`):三栏式布局(数据模型 / 表格 / 属性),可拖拽调整宽度,面板可收缩 - [x] **循环块管理**:右键菜单创建/删除,Tab 化多循环块管理,蓝色虚线高亮,循环字段级联选择 - [x] **单元格操作句柄**(`CellHandle`):样式读写、值设置、富文本支持 - [x] **声明式数据模型**(`report-engine-framework`): - - 数据域:DataSource(`DataSourceType` 枚举:CSV/JSON/DB/API/EXCEL)/ Dataset(sealed → TableDataset / UnionDataset)/ Field / Relationship + - 数据域:DataSource(聚合根,持有 List;`DataSourceType` **sealed interface**:CSV/EXCEL/DB 三实现)/ Dataset(sealed → TableDataset 聚合 DataSource / UnionDataset)/ Field / Relationship - 算子域:Aggregation(SUM/COUNT/AVG/MAX/MIN/COUNT_DISTINCT)/ Condition(已实现 13 种比较算子:EQ/NE/GT/GE/LT/LE/CONTAINS/NOT_CONTAINS/IN/NOT_IN/IS_NULL/IS_NOT_NULL/BETWEEN + SPI 扩展) - 表达式域:Value(sealed,8 种节点:Literal / FieldValue / ParamValue / LoopFieldValue / NameRef / Template / Aggregate / FunctionCall)/ ExpressionEngine 注册表分发 / ValueFunction SPI - 参数域:ParamSource(External / Cell / Constant) @@ -65,12 +65,12 @@ - [x] **样式/布局适配**:模板静态内容(标题/页脚)随带扩展下移、汇总行继承模板样式、合并区边框铺满整个区域、模板行高/列宽随渲染带出 - [x] **跨数据源 JOIN**:所有计算在 Java 内存完成,支持异构数据源关联;JOIN 类型 INNER/LEFT/RIGHT/FULL(hash join,LEFT/RIGHT 保留侧相对 join 参数位置,无匹配侧补 null) - [x] **数据模型面板**(`DataModelPanel`):三 tab 布局(数据集 / 数据关系 / 报表参数),始终显示数量徽标 -- [x] **数据集树**(`DatasetTree`):数据源类型彩色标签(CSV/JSON/DB/API/EXCEL)、字段拖拽、字段级关系双侧标注(→ FK / ← PK) +- [x] **数据集树**(`DatasetTree`):数据源类型彩色标签(DB/EXCEL/CSV)、字段拖拽、字段级关系双侧标注(→ FK / ← PK) - [x] **数据关系与分组**:上半区关系列表 + 下半区数据分组树(union-find 连通分量,仅展示有关系的数据集) - [x] **表达式构建器**(`ExpressionBuilder`):计算器式统一值编辑,支持字段插入、聚合、函数调用、模板插值,实时预览 -- [x] **报表配置实体化**:`ReportConfig` 强类型实体(framework,含 id/name/dataModelId/createTime/updateTime/cellBindings/loopBlocks/summaries/params/template),DTO record(`ConfigDtos`)同时作为持久化字段类型 + 前端 JSON 契约;`ReportRepository.page(PageQuery):PageResult` 分页查询(接口在 framework) +- [x] **报表领域实体化**:`core.Report` 领域实体(framework,含 id/name/dataModelId/createTime/updateTime/cellBindings/loopBlocks/summaries/parameters/template + 运行时 dataModel 引用),DTO record 在 framework `dto.report.*`(前端 JSON 契约),经 `Report.toDTO()`/`fromDTO()` 互转;仓库以领域对象存取,`ReportRepository.page(PageQuery):PageResult` 分页(接口在 framework) - [x] **报表配置持久化**:`ReportConfigController`(starter)保存/加载/分页列表/删除 API,数据模型随配置加载附带返回;example 用 `ReportConfigBuilder` 链式预存 10 个示例报表(含交叉表「区域季度销售交叉表」、横向汇总「商品横向汇总表」,写死稳定 id,重启不变) -- [x] **报表渲染导出**:`POST /api/report/render`(starter)→ 填充数据的 .xlsx 下载,DTO record(framework `ConfigDtos`)+ `RenderDtoConverter` 匹配前端 JSON 格式 +- [x] **报表渲染导出**:`POST /api/report/render`(starter)→ 填充数据的 .xlsx 下载,DTO record(framework `dto.report.*`)+ framework `core.RenderDtoConverter` 匹配前端 JSON 格式 - [x] **网页预览能力**:`ReportPreview` 组件(report-engine,参数弹窗→渲染→预览抽屉→反查→抽屉内导出),设计器与独立预览页共用;报表参数运行时输入表单(必填参数弹窗) - [x] **报表管理界面**:app-pc 报表管理页(antd Table 分页、新建/编辑/预览/删除),首页 + 报表管理两个入口 - [x] **动态报表标题**:标题栏显示当前报表名称(从配置加载),保存时同步更新 @@ -84,7 +84,7 @@ #### 数据与存储 -- [ ] **多数据源提取器**:`DataSourceType` 有 DB/API/EXCEL/CSV/JSON 五种,`DataExtractor` 仅实现 `CsvDataExtractor`。DB/API/EXCEL/JSON 提取器未实现,运行时会抛异常(需先补 `JdbcDataExtractor` 等,前端数据源管理才有意义) +- [ ] **数据源管理全流程**:后端提取器 CSV/EXCEL/DB 三种均已实现(`CsvDataExtractor`/`ExcelDataExtractor`/`DbDataExtractor`,API/JSON 已移除),`DataSourceType` 为 sealed interface(Csv/Excel/Db);前端数据源管理组件已并入 report-engine(`ConnectionForm`/`DatasetManager`/`RelationEditor`/`ExploreTree`)。**仍待打通**:DB 驱动 jar 上传 + 驱动类解析、Excel 文件上传、数据源/数据模型 CRUD 的端到端落地 - [ ] **数据源管理面板**:数据库连接配置、元数据扫描、前端数据源 CRUD(后端提取器是前置依赖) - [ ] **多数据模型支持**:支持多个 DataModel 并存,报表绑定指定数据模型(`GET /api/datamodels` 已支持多模型列举,仅 example 注册了 `"default"` 一个) - [ ] **持久化存储实现**:`ReportRepository` 接口齐全,starter 不提供默认实现(`@ConditionalOnMissingBean` 交使用方),example 为 `InMemoryReportRepository`(重启丢失)。生产接入需自实现 JPA/文件等持久化 diff --git a/report-engine-example/src/main/java/com/example/report/config/DatasetConfig.java b/report-engine-example/src/main/java/com/example/report/config/DataModelSeeder.java similarity index 74% rename from report-engine-example/src/main/java/com/example/report/config/DatasetConfig.java rename to report-engine-example/src/main/java/com/example/report/config/DataModelSeeder.java index 3ca23a2..387666c 100644 --- a/report-engine-example/src/main/java/com/example/report/config/DatasetConfig.java +++ b/report-engine-example/src/main/java/com/example/report/config/DataModelSeeder.java @@ -1,17 +1,18 @@ package com.example.report.config; import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.datamodel.DataModelStatus; import com.codingapi.report.data.dataset.DataType; import com.codingapi.report.data.dataset.Dataset; import com.codingapi.report.data.dataset.Field; import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; import com.codingapi.report.data.relation.JoinType; import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; +import com.codingapi.report.repository.DataModelRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.InputStream; @@ -20,13 +21,18 @@ import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Component; /** - * CSV 数据集自动配置:扫描 classpath 下的 JSON 描述文件,构建 DataModel。 + * 默认数据模型 seeder:启动时扫描 classpath 下的 CSV 数据集描述,构建 {@code id="default"} 的数据模型配置写入 {@link + * DataModelRepository}。范式同 {@link ReportTemplateSeeder}(稳定 id,重启幂等)。 + * + *

取代原 {@code DatasetConfig} 的 {@code @Bean DataModel}:数据模型不再以单例 Bean 提供, 改走仓库按 id 加载(多模型)。CSV + * 提取器 Bean 由 datasource 模块自动配置注册,不再在此声明。 * *

每个数据集由一对文件定义: * @@ -36,64 +42,44 @@ * */ @Slf4j -@Configuration -public class DatasetConfig { +@Component +public class DataModelSeeder { @Value("${report.datasets.dir:data/}") private String datasetsDir; - @Bean - public CsvDataExtractor csvDataExtractor() { - return new CsvDataExtractor(); + private final DataModelRepository repository; + + public DataModelSeeder(DataModelRepository repository) { + this.repository = repository; } - private List loadRelationships(ObjectMapper mapper) { - String path = "classpath:" + datasetsDir + "relationships.json"; + @EventListener(ApplicationReadyEvent.class) + public void seed() { + if (repository.find("default") != null) { + log.info("默认数据模型已存在,跳过 seeder"); + return; + } try { - PathMatchingResourcePatternResolver resolver = - new PathMatchingResourcePatternResolver(); - Resource resource = resolver.getResource(path); - if (!resource.exists()) return List.of(); - - try (InputStream is = resource.getInputStream()) { - JsonNode arr = mapper.readTree(is); - List result = new ArrayList<>(); - int idx = 0; - for (JsonNode node : arr) { - JsonNode l = node.get("left"); - JsonNode r = node.get("right"); - result.add( - Relationship.builder() - .id("rel-" + (++idx)) - .left( - new FieldRef( - l.get("datasetId").asText(), - l.get("field").asText())) - .right( - new FieldRef( - r.get("datasetId").asText(), - r.get("field").asText())) - .joinType(JoinType.valueOf(node.get("joinType").asText())) - .origin(RelationOrigin.MANUAL) - .build()); - } - log.info("加载关系: {} 条", result.size()); - return result; - } + DataModel dm = buildDefaultModel(); + dm.setId("default"); + dm.setStatus(DataModelStatus.PUBLISHED); + repository.save(dm); + log.info( + "已预存默认数据模型: {} 个数据集, {} 条关系", + dm.getDatasets().size(), + dm.getRelationships() != null ? dm.getRelationships().size() : 0); } catch (Exception e) { - log.warn("加载关系文件失败: {}", e.getMessage()); - return List.of(); + log.error("默认数据模型 seeder 失败", e); } } - @Bean - public DataModel dataModel() throws Exception { + private DataModel buildDefaultModel() throws Exception { String pattern = "classpath:" + datasetsDir + "*.json"; PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); Resource[] resources = resolver.getResources(pattern); ObjectMapper mapper = new ObjectMapper(); - List datasources = new ArrayList<>(); List datasets = new ArrayList<>(); for (Resource resource : resources) { @@ -125,33 +111,70 @@ public DataModel dataModel() throws Exception { DataSource.builder() .id("csv_" + id) .name(alias) - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", csvPath)) .build(); - datasources.add(source); - TableDataset ds = TableDataset.builder() .id(id) + .datasource(source) .datasourceId(source.getId()) .sourceTable(id + ".csv") .alias(alias) .fields(fields) .build(); + source.setDatasets(List.of(ds)); datasets.add(ds); log.info("加载数据集: {} ({}) - {} 个字段, path={}", id, alias, fields.size(), csvPath); } } - // 加载关系 List relationships = loadRelationships(mapper); return DataModel.builder() .id("default") .name("默认数据模型") - .datasources(datasources) .datasets(new ArrayList<>(datasets)) .relationships(relationships) .build(); } + + private List loadRelationships(ObjectMapper mapper) { + String path = "classpath:" + datasetsDir + "relationships.json"; + try { + PathMatchingResourcePatternResolver resolver = + new PathMatchingResourcePatternResolver(); + Resource resource = resolver.getResource(path); + if (!resource.exists()) return List.of(); + + try (InputStream is = resource.getInputStream()) { + JsonNode arr = mapper.readTree(is); + List result = new ArrayList<>(); + int idx = 0; + for (JsonNode node : arr) { + JsonNode l = node.get("left"); + JsonNode r = node.get("right"); + result.add( + Relationship.builder() + .id("rel-" + (++idx)) + .left( + new FieldRef( + l.get("datasetId").asText(), + l.get("field").asText())) + .right( + new FieldRef( + r.get("datasetId").asText(), + r.get("field").asText())) + .joinType(JoinType.valueOf(node.get("joinType").asText())) + .origin(RelationOrigin.MANUAL) + .build()); + } + log.info("加载关系: {} 条", result.size()); + return result; + } + } catch (Exception e) { + log.warn("加载关系文件失败: {}", e.getMessage()); + return List.of(); + } + } } diff --git a/report-engine-example/src/main/java/com/example/report/config/ReportConfigBuilder.java b/report-engine-example/src/main/java/com/example/report/config/ReportConfigBuilder.java index d632721..0bf7a6d 100644 --- a/report-engine-example/src/main/java/com/example/report/config/ReportConfigBuilder.java +++ b/report-engine-example/src/main/java/com/example/report/config/ReportConfigBuilder.java @@ -1,14 +1,14 @@ package com.example.report.config; -import com.codingapi.report.config.ReportConfig; -import com.codingapi.report.config.dto.ConfigDtos.BindingDTO; -import com.codingapi.report.config.dto.ConfigDtos.ConditionDTO; -import com.codingapi.report.config.dto.ConfigDtos.FieldRefDTO; -import com.codingapi.report.config.dto.ConfigDtos.LoopBlockDTO; -import com.codingapi.report.config.dto.ConfigDtos.PartDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryCellDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryRowDTO; -import com.codingapi.report.config.dto.ConfigDtos.ValueDTO; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.dto.report.BindingDTO; +import com.codingapi.report.dto.report.ConditionDTO; +import com.codingapi.report.dto.report.FieldRefDTO; +import com.codingapi.report.dto.report.LoopBlockDTO; +import com.codingapi.report.dto.report.PartDTO; +import com.codingapi.report.dto.report.SummaryCellDTO; +import com.codingapi.report.dto.report.SummaryRowDTO; +import com.codingapi.report.dto.report.ValueDTO; import com.codingapi.report.excel.CellRefs; import com.codingapi.report.excel.pojo.Cell; import com.codingapi.report.excel.pojo.Sheet; @@ -173,8 +173,8 @@ public ReportConfigBuilder template(int colCount, int rowCount, CellData... cell // ─── 构建 ─── - public ReportConfig build() { - ReportConfig rc = new ReportConfig(); + public ReportDTO build() { + ReportDTO rc = new ReportDTO(); rc.setName(name); rc.setDataModelId("default"); rc.setCellBindings(List.copyOf(bindings)); diff --git a/report-engine-example/src/main/java/com/example/report/config/ReportTemplateSeeder.java b/report-engine-example/src/main/java/com/example/report/config/ReportTemplateSeeder.java index ee3f937..bd4d604 100644 --- a/report-engine-example/src/main/java/com/example/report/config/ReportTemplateSeeder.java +++ b/report-engine-example/src/main/java/com/example/report/config/ReportTemplateSeeder.java @@ -8,13 +8,14 @@ import static com.example.report.config.ReportConfigBuilder.labelCell; import static com.example.report.config.ReportConfigBuilder.literal; -import com.codingapi.report.config.ReportConfig; -import com.codingapi.report.config.dto.ConfigDtos.ConditionDTO; -import com.codingapi.report.config.dto.ConfigDtos.FieldRefDTO; -import com.codingapi.report.config.dto.ConfigDtos.LoopBlockDTO; -import com.codingapi.report.config.dto.ConfigDtos.PartDTO; -import com.codingapi.report.config.dto.ConfigDtos.SourceDTO; -import com.codingapi.report.config.dto.ConfigDtos.ValueDTO; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.core.Report; +import com.codingapi.report.dto.report.ConditionDTO; +import com.codingapi.report.dto.report.FieldRefDTO; +import com.codingapi.report.dto.report.LoopBlockDTO; +import com.codingapi.report.dto.report.PartDTO; +import com.codingapi.report.dto.report.SourceDTO; +import com.codingapi.report.dto.report.ValueDTO; import com.codingapi.report.repository.ReportRepository; import java.util.List; import lombok.extern.slf4j.Slf4j; @@ -513,10 +514,10 @@ private void seedHorizontalSummary() { // 辅助 // ============================================================ - /** 保存配置并指定稳定 id(重启后不变)。 */ - private void save(String id, ReportConfig config) { + /** 保存配置并指定稳定 id(重启后不变)。DTO → 领域 Report 入库。 */ + private void save(String id, ReportDTO config) { config.setId(id); - repository.save(config); + repository.save(Report.fromDTO(config)); count++; } } diff --git a/report-engine-example/src/main/java/com/example/report/config/RepositoryConfig.java b/report-engine-example/src/main/java/com/example/report/config/RepositoryConfig.java index b49afab..e275d05 100644 --- a/report-engine-example/src/main/java/com/example/report/config/RepositoryConfig.java +++ b/report-engine-example/src/main/java/com/example/report/config/RepositoryConfig.java @@ -1,6 +1,8 @@ package com.example.report.config; +import com.codingapi.report.repository.DataModelRepository; import com.codingapi.report.repository.ReportRepository; +import com.example.report.repository.InMemoryDataModelRepository; import com.example.report.repository.InMemoryReportRepository; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -17,4 +19,9 @@ public class RepositoryConfig { public ReportRepository reportRepository() { return new InMemoryReportRepository(); } + + @Bean + public DataModelRepository dataModelRepository() { + return new InMemoryDataModelRepository(); + } } diff --git a/report-engine-example/src/main/java/com/example/report/repository/InMemoryDataModelRepository.java b/report-engine-example/src/main/java/com/example/report/repository/InMemoryDataModelRepository.java new file mode 100644 index 0000000..fc7e647 --- /dev/null +++ b/report-engine-example/src/main/java/com/example/report/repository/InMemoryDataModelRepository.java @@ -0,0 +1,64 @@ +package com.example.report.repository; + +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.datamodel.DataModelStatus; +import com.codingapi.report.repository.DataModelRepository; +import com.codingapi.report.repository.PageQuery; +import com.codingapi.report.repository.PageResult; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link DataModelRepository} 的内存实现(example 演示用)。 + * + *

以领域 {@link DataModel} 保存;进程内存储,重启丢失。config 明文存内存(演示不落盘,故不加密)。 生产环境应由使用方提供持久化实现(落盘加密在该层处理)。 + */ +public class InMemoryDataModelRepository implements DataModelRepository { + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + @Override + public String save(DataModel dataModel) { + String id = + dataModel.getId() != null && !dataModel.getId().isBlank() + ? dataModel.getId() + : UUID.randomUUID().toString(); + dataModel.setId(id); + + long now = System.currentTimeMillis(); + DataModel existing = store.get(id); + dataModel.setCreateTime(existing != null ? existing.getCreateTime() : now); + dataModel.setUpdateTime(now); + if (dataModel.getStatus() == null) dataModel.setStatus(DataModelStatus.DRAFT); + if (dataModel.getDatasets() == null) dataModel.setDatasets(List.of()); + if (dataModel.getRelationships() == null) dataModel.setRelationships(List.of()); + + store.put(id, dataModel); + return id; + } + + @Override + public DataModel find(String id) { + return store.get(id); + } + + @Override + public PageResult page(PageQuery query) { + int current = query.current(); + int pageSize = query.pageSize(); + List all = new ArrayList<>(store.values()); + all.sort((a, b) -> Long.compare(b.getCreateTime(), a.getCreateTime())); + long total = all.size(); + int from = (int) Math.min((long) (current - 1) * pageSize, total); + int to = (int) Math.min((long) from + pageSize, total); + List pageList = all.subList(from, to); + return new PageResult<>(pageList, total); + } + + @Override + public void delete(String id) { + store.remove(id); + } +} diff --git a/report-engine-example/src/main/java/com/example/report/repository/InMemoryReportRepository.java b/report-engine-example/src/main/java/com/example/report/repository/InMemoryReportRepository.java index aaa79be..d0d86c1 100644 --- a/report-engine-example/src/main/java/com/example/report/repository/InMemoryReportRepository.java +++ b/report-engine-example/src/main/java/com/example/report/repository/InMemoryReportRepository.java @@ -1,61 +1,60 @@ package com.example.report.repository; -import com.codingapi.report.config.ReportConfig; +import com.codingapi.report.core.Report; import com.codingapi.report.repository.PageQuery; import com.codingapi.report.repository.PageResult; import com.codingapi.report.repository.ReportRepository; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** * {@link ReportRepository} 的内存实现(example 演示用)。 * - *

报表配置以强类型 {@link ReportConfig} 实体保存;进程内存储,重启丢失。 生产环境应由使用方提供持久化实现。 + *

以领域 {@link Report} 保存;进程内存储,重启丢失。生产环境应由使用方提供持久化实现。 */ public class InMemoryReportRepository implements ReportRepository { - private final Map store = new ConcurrentHashMap<>(); + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); @Override - public String save(ReportConfig report) { - Object idObj = report.getId(); - String id = (idObj instanceof String s && !s.isBlank()) ? s : UUID.randomUUID().toString(); + public String save(Report report) { + String id = + report.getId() != null && !report.getId().isBlank() + ? report.getId() + : UUID.randomUUID().toString(); report.setId(id); long now = System.currentTimeMillis(); - ReportConfig existing = store.get(id); + Report existing = store.get(id); report.setCreateTime(existing != null ? existing.getCreateTime() : now); report.setUpdateTime(now); - // null 列表归一为空,避免前端拿到 null if (report.getCellBindings() == null) report.setCellBindings(List.of()); if (report.getLoopBlocks() == null) report.setLoopBlocks(List.of()); if (report.getSummaries() == null) report.setSummaries(List.of()); - if (report.getParams() == null) report.setParams(List.of()); + if (report.getParameters() == null) report.setParameters(List.of()); store.put(id, report); return id; } @Override - public ReportConfig find(String id) { + public Report find(String id) { return store.get(id); } @Override - public PageResult page(PageQuery query) { + public PageResult page(PageQuery query) { int current = query.current(); int pageSize = query.pageSize(); - List all = new ArrayList<>(store.values()); - // 按创建时间倒序排列 + List all = new ArrayList<>(store.values()); all.sort((a, b) -> Long.compare(b.getCreateTime(), a.getCreateTime())); long total = all.size(); int from = (int) Math.min((long) (current - 1) * pageSize, total); int to = (int) Math.min((long) from + pageSize, total); - List pageList = all.subList(from, to); + List pageList = all.subList(from, to); return new PageResult<>(pageList, total); } diff --git a/report-engine-framework/pom.xml b/report-engine-framework/pom.xml index 468514d..1ba405c 100644 --- a/report-engine-framework/pom.xml +++ b/report-engine-framework/pom.xml @@ -26,5 +26,11 @@ report-engine-excel + + com.h2database + h2 + test + + \ No newline at end of file diff --git a/report-engine-framework/src/main/java/com/codingapi/report/config/ReportConfig.java b/report-engine-framework/src/main/java/com/codingapi/report/config/ReportConfig.java deleted file mode 100644 index fb50c67..0000000 --- a/report-engine-framework/src/main/java/com/codingapi/report/config/ReportConfig.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.codingapi.report.config; - -import com.codingapi.report.config.dto.ConfigDtos.BindingDTO; -import com.codingapi.report.config.dto.ConfigDtos.LoopBlockDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryRowDTO; -import com.codingapi.report.config.dto.ReportParam; -import com.codingapi.report.excel.pojo.Workbook; -import com.fasterxml.jackson.annotation.JsonInclude; -import java.util.List; -import lombok.Data; - -/** - * 报表配置实体(持久化契约):强类型 POJO,替代原 {@code Map} 存取。 - * - *

字段包含报表元数据(id/name/dataModelId/createTime/updateTime)与配置内容 - * (cellBindings/loopBlocks/summaries/params/template)。配置内容引用 {@link ConfigDtos} 的 DTO record - * (Jackson 可序列化,不依赖无注解的 {@code Value} sealed interface),便于落库 JSON。 - * - *

{@code dataModel} 为响应富化字段:仅 {@code GET /api/report/configs/{id}} 返回时由 starter 填充, - * 不参与持久化({@link JsonInclude.Include#NON_NULL} 省略空值)。 - */ -@Data -public class ReportConfig { - - private String id; - - private String name; - - /** 引用的数据模型 id */ - private String dataModelId; - - /** 创建时间(epoch 毫秒) */ - private long createTime; - - /** 修改时间(epoch 毫秒) */ - private long updateTime; - - private List cellBindings; - - private List loopBlocks; - - private List summaries; - - private List params; - - /** 模板工作簿快照(Jackson 友好的 Excel POJO) */ - private Workbook template; - - /** 响应富化字段:仅加载配置时填充,不持久化 */ - @JsonInclude(JsonInclude.Include.NON_NULL) - private Object dataModel; -} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/config/dto/ConfigDtos.java b/report-engine-framework/src/main/java/com/codingapi/report/config/dto/ConfigDtos.java deleted file mode 100644 index 8f0a9c7..0000000 --- a/report-engine-framework/src/main/java/com/codingapi/report/config/dto/ConfigDtos.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.codingapi.report.config.dto; - -import java.util.List; - -/** - * 报表配置的 DTO 契约容器(前端 JSON ↔ 这些 record ↔ framework 领域对象)。 - * - *

{@code Value} 等 sealed interface 未加 Jackson 多态注解,故用这些 record 承接前端 JSON, 再由 starter 的 {@code - * RenderDtoConverter} 转为 framework 领域对象。 - * - *

这些 record 同时是 {@code ReportConfig} 实体的字段类型(持久化契约), 保证实体全字段强类型且 Jackson 可序列化(便于落库 JSON)。 - */ -public final class ConfigDtos { - - private ConfigDtos() {} - - public record BindingDTO( - String cellKey, - ValueDTO value, - String expansion, - String expandMode, - boolean mergeRepeated, - String parentCell, - List conditions, - boolean independent, - String preview, - boolean drillEnabled, - String drillView) {} - - public record ValueDTO( - String type, - String payload, - String aggregation, - ValueDTO operand, - String funcName, - List args, - List parts) {} - - public record PartDTO(String kind, String text, ValueDTO value) {} - - public record ConditionDTO(String id, ValueDTO left, String operator, ValueDTO right) {} - - public record LoopBlockDTO( - String id, - String label, - String sheetId, - int startRow, - int startColumn, - int endRow, - int endColumn, - SourceDTO source) {} - - public record SourceDTO( - String datasetId, - List filters, - List groupBy, - List orderBy) {} - - /** - * 汇总持久化契约。{@code axis} 为 "VERTICAL"/"HORIZONTAL",null 视为 VERTICAL(向后兼容)。 坐标按轴转置:{@code mainPos} - * 主轴声明位置(纵向=行/横向=列)、{@code crossFrom/crossTo} 交叉区间(纵向=列/横向=行)。 - */ - public record SummaryRowDTO( - String id, - String axis, - FieldRefDTO groupBy, - int crossFrom, - int crossTo, - List cells, - Integer mainPos) {} - - public record FieldRefDTO(String datasetId, String field) {} - - public record SummaryCellDTO( - int crossPos, - ValueDTO value, - String kind, - String payload, - String aggregation, - String preview, - boolean drillEnabled, - String drillView) {} -} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/core/RenderDtoConverter.java b/report-engine-framework/src/main/java/com/codingapi/report/core/RenderDtoConverter.java new file mode 100644 index 0000000..3d975bf --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/RenderDtoConverter.java @@ -0,0 +1,446 @@ +package com.codingapi.report.core; + +import com.codingapi.report.dto.report.BindingDTO; +import com.codingapi.report.dto.report.ConditionDTO; +import com.codingapi.report.dto.report.LoopBlockDTO; +import com.codingapi.report.dto.report.PartDTO; +import com.codingapi.report.dto.report.SummaryCellDTO; +import com.codingapi.report.dto.report.SummaryRowDTO; +import com.codingapi.report.dto.report.FieldRefDTO; +import com.codingapi.report.dto.report.SourceDTO; +import com.codingapi.report.dto.report.ValueDTO; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.dto.report.ParamDTO; +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.FieldRef; +import com.codingapi.report.param.ParamSource; +import com.codingapi.report.param.Parameter; +import com.codingapi.report.data.dataset.Query; +import com.codingapi.report.expression.Value; +import com.codingapi.report.operator.condition.CompareOperator; +import com.codingapi.report.operator.condition.Condition; +import com.codingapi.report.core.grid.Axis; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; +import com.codingapi.report.core.grid.LoopBlock; +import com.codingapi.report.core.grid.SummaryCell; +import com.codingapi.report.core.grid.SummaryRow; +import java.util.ArrayList; +import java.util.List; + +/** + * 渲染请求 DTO → framework 领域对象的转换器。 + * + *

因 {@code Value} 等 sealed interface 无 Jackson 多态注解,前端 JSON 经 {@link + * com.codingapi.report.starter.dto.RenderDtos} 承接后由此处统一转换。所有方法无状态(static)。 + */ +public final class RenderDtoConverter { + + private RenderDtoConverter() {} + + public static List convertBindings(List dtos) { + if (dtos == null) return List.of(); + List result = new ArrayList<>(); + for (BindingDTO dto : dtos) { + result.add( + CellBinding.builder() + .cell(CellRef.parse(dto.cellKey())) + .value(convertValue(dto.value())) + .expansion( + dto.expansion() != null + ? Expansion.valueOf(dto.expansion()) + : null) + .expandMode( + dto.expandMode() != null + ? ExpandMode.valueOf(dto.expandMode()) + : null) + .mergeRepeated(dto.mergeRepeated()) + .parentCell( + dto.parentCell() != null + ? CellRef.parse(dto.parentCell()) + : null) + .conditions(convertConditions(dto.conditions())) + .independent(dto.independent()) + .drillEnabled(dto.drillEnabled()) + .drillView(dto.drillView()) + .build()); + } + return result; + } + + public static Value convertValue(ValueDTO dto) { + if (dto == null) return new Value.Literal(null); + return switch (dto.type()) { + case "FieldValue" -> { + String[] parts = dto.payload().split("\\.", 2); + yield new Value.FieldValue(new FieldRef(parts[0], parts[1])); + } + case "Literal" -> new Value.Literal(dto.payload()); + case "NameRef" -> new Value.NameRef(dto.payload()); + case "ParamValue" -> new Value.ParamValue(dto.payload()); + case "LoopFieldValue" -> { + String[] parts = dto.payload().split("\\.", 2); + yield new Value.LoopFieldValue(parts[0], parts[1]); + } + case "Aggregate" -> new Value.Aggregate(dto.aggregation(), convertValue(dto.operand())); + case "FunctionCall" -> + new Value.FunctionCall( + dto.funcName(), + dto.args() != null + ? dto.args().stream() + .map(RenderDtoConverter::convertValue) + .toList() + : List.of()); + case "Template" -> { + List parts = new ArrayList<>(); + if (dto.parts() != null) { + for (PartDTO p : dto.parts()) { + if ("text".equals(p.kind())) { + parts.add(new Value.Template.Text(p.text())); + } else { + parts.add(new Value.Template.Hole(convertValue(p.value()))); + } + } + } + yield new Value.Template(parts); + } + default -> new Value.Literal(dto.payload()); + }; + } + + public static List convertConditions(List dtos) { + if (dtos == null) return List.of(); + List result = new ArrayList<>(); + for (ConditionDTO dto : dtos) { + result.add( + Condition.builder() + .left(convertValue(dto.left())) + .operator(CompareOperator.valueOf(dto.operator())) + .right(dto.right() != null ? convertValue(dto.right()) : null) + .build()); + } + return result; + } + + public static List convertLoops(List dtos) { + if (dtos == null) return List.of(); + List result = new ArrayList<>(); + for (LoopBlockDTO dto : dtos) { + Query query = + Query.builder() + .datasetId(dto.source().datasetId()) + .filters(convertConditions(dto.source().filters())) + .groupBy( + dto.source().groupBy() != null + ? dto.source().groupBy() + : List.of()) + .orderBy( + dto.source().orderBy() != null + ? dto.source().orderBy() + : List.of()) + .build(); + result.add( + LoopBlock.builder() + .id(dto.id()) + .label(dto.label()) + .start(new CellRef(dto.sheetId(), dto.startRow(), dto.startColumn())) + .end(new CellRef(dto.sheetId(), dto.endRow(), dto.endColumn())) + .source(query) + .build()); + } + return result; + } + + public static List convertSummaries(List dtos) { + if (dtos == null) return List.of(); + List result = new ArrayList<>(); + for (SummaryRowDTO dto : dtos) { + FieldRef groupBy = + dto.groupBy() != null + ? new FieldRef(dto.groupBy().datasetId(), dto.groupBy().field()) + : null; + List cells = new ArrayList<>(); + if (dto.cells() != null) { + for (SummaryCellDTO c : dto.cells()) { + if (c.value() != null) { + // 新格式:直接使用 ValueDTO + 反查配置 + cells.add( + new SummaryCell( + c.crossPos(), + convertValue(c.value()), + c.drillEnabled(), + c.drillView())); + } else if ("label".equals(c.kind())) { + // 旧格式兼容 + cells.add(SummaryCell.label(c.crossPos(), c.payload())); + } else { + // 旧格式 agg 兼容 + String[] parts = c.payload().split("\\.", 2); + cells.add( + SummaryCell.agg( + c.crossPos(), + new FieldRef(parts[0], parts[1]), + c.aggregation())); + } + } + } + Axis axis = "HORIZONTAL".equals(dto.axis()) ? Axis.HORIZONTAL : Axis.VERTICAL; + result.add( + SummaryRow.builder() + .axis(axis) + .groupBy(groupBy) + .crossFrom(dto.crossFrom()) + .crossTo(dto.crossTo()) + .cells(cells) + .mainPos(dto.mainPos()) + .build()); + } + return result; + } + + public static List toParameters(List params) { + if (params == null) return List.of(); + List out = new ArrayList<>(); + for (ParamDTO p : params) { + out.add( + Parameter.builder() + .name(p.getName()) + .alias(p.getAlias()) + .dataType( + p.getDataType() != null + ? DataType.valueOf(p.getDataType()) + : DataType.STRING) + .source(new ParamSource.External(false, p.getDefaultValue())) + .build()); + } + return out; + } + + // ============================================================ + // 领域 → DTO(出站)。前端展示用字段(preview/param.id/summary.id)不反向产出。 + // ============================================================ + + public static ReportDTO toDTO(Report r) { + if (r == null) return null; + ReportDTO dto = new ReportDTO(); + dto.setId(r.getId()); + dto.setName(r.getName()); + dto.setDataModelId(r.getDataModelId()); + dto.setCreateTime(r.getCreateTime()); + dto.setUpdateTime(r.getUpdateTime()); + dto.setCellBindings(toBindingDtos(r.getCellBindings())); + dto.setLoopBlocks(toLoopDtos(r.getLoopBlocks())); + dto.setSummaries(toSummaryDtos(r.getSummaries())); + dto.setParams(toParamDtos(r.getParameters())); + dto.setTemplate(r.getTemplate()); + return dto; + } + + public static Report fromDTO(ReportDTO dto) { + if (dto == null) return null; + return Report.builder() + .id(dto.getId()) + .name(dto.getName()) + .dataModelId(dto.getDataModelId()) + .createTime(dto.getCreateTime()) + .updateTime(dto.getUpdateTime()) + .cellBindings(convertBindings(dto.getCellBindings())) + .loopBlocks(convertLoops(dto.getLoopBlocks())) + .summaries(convertSummaries(dto.getSummaries())) + .parameters(toParameters(dto.getParams())) + .template(dto.getTemplate()) + .build(); + } + + private static String key(CellRef c) { + return c == null ? null : c.sheetId() + ":" + c.row() + ":" + c.column(); + } + + public static List toBindingDtos(List bindings) { + if (bindings == null) return List.of(); + List out = new ArrayList<>(); + for (CellBinding b : bindings) { + out.add( + new BindingDTO( + key(b.getCell()), + toValueDto(b.getValue()), + b.getExpansion() != null ? b.getExpansion().name() : null, + b.getExpandMode() != null ? b.getExpandMode().name() : null, + b.isMergeRepeated(), + b.getParentCell() != null ? key(b.getParentCell()) : null, + toConditionDtos(b.getConditions()), + b.isIndependent(), + null, + b.isDrillEnabled(), + b.getDrillView())); + } + return out; + } + + public static ValueDTO toValueDto(Value v) { + if (v == null) return null; + if (v instanceof Value.Literal l) { + return new ValueDTO( + "Literal", + l.value() != null ? l.value().toString() : null, + null, + null, + null, + null, + null); + } + if (v instanceof Value.FieldValue f) { + return new ValueDTO( + "FieldValue", + f.ref().datasetId() + "." + f.ref().field(), + null, + null, + null, + null, + null); + } + if (v instanceof Value.ParamValue p) { + return new ValueDTO("ParamValue", p.name(), null, null, null, null, null); + } + if (v instanceof Value.LoopFieldValue lf) { + return new ValueDTO( + "LoopFieldValue", + lf.loopBlockId() + "." + lf.field(), + null, + null, + null, + null, + null); + } + if (v instanceof Value.NameRef n) { + return new ValueDTO("NameRef", n.name(), null, null, null, null, null); + } + if (v instanceof Value.Aggregate a) { + return new ValueDTO( + "Aggregate", null, a.aggregation(), toValueDto(a.operand()), null, null, null); + } + if (v instanceof Value.FunctionCall fc) { + return new ValueDTO( + "FunctionCall", + null, + null, + null, + fc.name(), + fc.args() != null + ? fc.args().stream().map(RenderDtoConverter::toValueDto).toList() + : List.of(), + null); + } + if (v instanceof Value.Template t) { + List parts = new ArrayList<>(); + if (t.parts() != null) { + for (Value.Template.Part p : t.parts()) { + if (p instanceof Value.Template.Text txt) { + parts.add(new PartDTO("text", txt.text(), null)); + } else if (p instanceof Value.Template.Hole h) { + parts.add(new PartDTO("hole", null, toValueDto(h.value()))); + } + } + } + return new ValueDTO("Template", null, null, null, null, null, parts); + } + throw new IllegalStateException("未知 Value 类型: " + v.getClass().getName()); + } + + public static List toConditionDtos(List conditions) { + if (conditions == null) return List.of(); + List out = new ArrayList<>(); + for (Condition c : conditions) { + out.add( + new ConditionDTO( + null, + toValueDto(c.getLeft()), + c.getOperator() != null ? c.getOperator().name() : null, + c.getRight() != null ? toValueDto(c.getRight()) : null)); + } + return out; + } + + public static List toLoopDtos(List loops) { + if (loops == null) return List.of(); + List out = new ArrayList<>(); + for (LoopBlock lb : loops) { + Query q = lb.getSource(); + SourceDTO src = + q == null + ? null + : new SourceDTO( + q.getDatasetId(), + toConditionDtos(q.getFilters()), + q.getGroupBy(), + q.getOrderBy()); + CellRef s = lb.getStart(); + CellRef e = lb.getEnd(); + out.add( + new LoopBlockDTO( + lb.getId(), + lb.getLabel(), + s != null ? s.sheetId() : null, + s != null ? s.row() : 0, + s != null ? s.column() : 0, + e != null ? e.row() : 0, + e != null ? e.column() : 0, + src)); + } + return out; + } + + public static List toSummaryDtos(List rows) { + if (rows == null) return List.of(); + List out = new ArrayList<>(); + for (SummaryRow sr : rows) { + FieldRefDTO gb = + sr.getGroupBy() != null + ? new FieldRefDTO(sr.getGroupBy().datasetId(), sr.getGroupBy().field()) + : null; + List cells = new ArrayList<>(); + if (sr.getCells() != null) { + for (SummaryCell c : sr.getCells()) { + cells.add( + new SummaryCellDTO( + c.getCrossPos(), + toValueDto(c.getValue()), + null, + null, + null, + null, + c.isDrillEnabled(), + c.getDrillView())); + } + } + out.add( + new SummaryRowDTO( + null, + sr.getAxis() != null ? sr.getAxis().name() : null, + gb, + sr.getCrossFrom(), + sr.getCrossTo(), + cells, + sr.getMainPos())); + } + return out; + } + + public static List toParamDtos(List params) { + if (params == null) return List.of(); + List out = new ArrayList<>(); + for (Parameter p : params) { + ParamDTO rp = new ParamDTO(); + rp.setName(p.getName()); + rp.setAlias(p.getAlias()); + rp.setDataType(p.getDataType() != null ? p.getDataType().name() : null); + if (p.getSource() instanceof ParamSource.External e && e.defaultValue() != null) { + rp.setDefaultValue(e.defaultValue().toString()); + } + out.add(rp); + } + return out; + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/Report.java b/report-engine-framework/src/main/java/com/codingapi/report/core/Report.java similarity index 78% rename from report-engine-framework/src/main/java/com/codingapi/report/render/Report.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/Report.java index 22e9579..738cd84 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/Report.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/Report.java @@ -1,10 +1,13 @@ -package com.codingapi.report.render; +package com.codingapi.report.core; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.LoopBlock; +import com.codingapi.report.core.grid.SummaryRow; +import com.codingapi.report.data.datamodel.DataModel; import com.codingapi.report.data.relation.Relationship; +import com.codingapi.report.excel.pojo.Workbook; import com.codingapi.report.param.Parameter; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.LoopBlock; -import com.codingapi.report.render.grid.SummaryRow; import java.util.List; import lombok.Builder; import lombok.Data; @@ -125,4 +128,34 @@ public class Report { *

例如"按单位分组"的员工报表,可以在每个单位结束后插入一行"XX单位小计", 全表末尾插入一行"总计"。行位置随数据量自适应。 */ private List summaries; + + /** 创建时间(epoch 毫秒)。 */ + private long createTime; + + /** 修改时间(epoch 毫秒)。 */ + private long updateTime; + + /** 模板画布快照(Univer/Excel 工作簿 POJO);承载静态文本/样式/合并/行列尺寸。 */ + private Workbook template; + + /** + * 引用的数据模型(运行时解析填充,持久化只存 {@link #dataModelId})。 + * + *

对应 point 4「Report 引用 DataModel 而非 id」:取数/渲染时直接用解析后的模型,由服务层在加载时 set。 + */ + private DataModel dataModel; + + // ============================================================ + // 领域 ↔ DTO(委托 RenderDtoConverter,同包无需 import) + // ============================================================ + + /** 领域 → 出入站 DTO(前端展示用字段如 preview/param.id 不反向产出,由前端重建)。 */ + public ReportDTO toDTO() { + return RenderDtoConverter.toDTO(this); + } + + /** 出入站 DTO → 领域(构建 sealed {@code Value} 树等)。 */ + public static Report fromDTO(ReportDTO dto) { + return RenderDtoConverter.fromDTO(dto); + } } diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/DrillCollector.java b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/DrillCollector.java similarity index 98% rename from report-engine-framework/src/main/java/com/codingapi/report/render/engine/DrillCollector.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/engine/DrillCollector.java index 2f1b1e3..cd7fd9b 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/DrillCollector.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/DrillCollector.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import java.util.HashMap; import java.util.List; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/Operators.java b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/Operators.java similarity index 99% rename from report-engine-framework/src/main/java/com/codingapi/report/render/engine/Operators.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/engine/Operators.java index 49324c8..90df90a 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/Operators.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/Operators.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import com.codingapi.report.data.datasource.RawTable; import com.codingapi.report.data.relation.JoinType; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/ReportRenderer.java b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/ReportRenderer.java similarity index 98% rename from report-engine-framework/src/main/java/com/codingapi/report/render/engine/ReportRenderer.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/engine/ReportRenderer.java index 700aa99..17d1493 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/engine/ReportRenderer.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/engine/ReportRenderer.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import com.codingapi.report.data.datamodel.DataModel; import com.codingapi.report.data.dataset.Dataset; @@ -24,15 +24,15 @@ import com.codingapi.report.expression.Value; import com.codingapi.report.operator.condition.Condition; import com.codingapi.report.param.ParamContext; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.Axis; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; -import com.codingapi.report.render.grid.LoopBlock; -import com.codingapi.report.render.grid.SummaryCell; -import com.codingapi.report.render.grid.SummaryRow; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.Axis; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; +import com.codingapi.report.core.grid.LoopBlock; +import com.codingapi.report.core.grid.SummaryCell; +import com.codingapi.report.core.grid.SummaryRow; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.util.ArrayList; @@ -1674,24 +1674,19 @@ private RawTable extract(String datasetId) { if (ds instanceof UnionDataset u) { result = extractUnion(u); } else if (ds instanceof TableDataset t) { - DataSource src = - dm.getDatasources().stream() - .filter(s -> s.getId().equals(t.getDatasourceId())) - .findFirst() - .orElseThrow( - () -> - new IllegalStateException( - "数据源不存在: " - + t.getDatasourceId() - + " (数据集: " - + datasetId - + ")")); + DataSource src = t.getDatasource(); + if (src == null) { + throw new IllegalStateException( + "数据集未绑定数据源: " + t.getDatasourceId() + " (数据集: " + datasetId + ")"); + } DataExtractor extractor = extractors.stream() - .filter(e -> e.supports(src.getType())) + .filter(e -> e.supports(src.getType().type())) .findFirst() .orElseThrow( - () -> new IllegalStateException("无提取器支持类型: " + src.getType())); + () -> + new IllegalStateException( + "无提取器支持类型: " + src.getType().type())); result = extractor.extract(src, t); } else { throw new IllegalStateException("未知数据集类型: " + ds.getClass().getName()); diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/Axis.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/Axis.java similarity index 98% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/Axis.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/Axis.java index 6e9bb6f..d9a4676 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/Axis.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/Axis.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; import com.codingapi.report.excel.pojo.Merge; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellBinding.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellBinding.java similarity index 99% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellBinding.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellBinding.java index 50958c7..1c63acc 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellBinding.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellBinding.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; import com.codingapi.report.expression.Value; import com.codingapi.report.operator.condition.Condition; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellRef.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellRef.java similarity index 97% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellRef.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellRef.java index d0deef9..e3d7e00 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/CellRef.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/CellRef.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; /** * 单元格坐标引用:定位模板工作簿中的一个具体格子。 diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/ExpandMode.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/ExpandMode.java similarity index 96% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/ExpandMode.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/ExpandMode.java index e5cee2f..d3de77b 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/ExpandMode.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/ExpandMode.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; /** * 扩展模式:决定扩展时是否对数据去重。仅当 {@link Expansion} 不为 NONE 时有意义。 diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/Expansion.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/Expansion.java similarity index 98% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/Expansion.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/Expansion.java index 059a8ac..7a858fa 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/Expansion.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/Expansion.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; /** * 扩展方向:数据在格子上的铺开方式。这是类 Excel 报表的本质机制。 diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/LoopBlock.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/LoopBlock.java similarity index 96% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/LoopBlock.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/LoopBlock.java index 942b3b3..384e011 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/LoopBlock.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/LoopBlock.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; import com.codingapi.report.data.dataset.Query; import lombok.Builder; @@ -43,7 +43,7 @@ *

循环字段免登记

* *

循环把"当前迭代行"发布进作用域,块内格子通过 {@code Value.LoopFieldValue}{@code (loopId, field)} 直接引用驱动数据集的字段, - * 无需预先在 {@link com.codingapi.report.render.Report#getParameters()} 里登记。 + * 无需预先在 {@link com.codingapi.report.core.Report#getParameters()} 里登记。 * *

属性面板枚举可选值时,沿作用域链向上收集: * diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryCell.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryCell.java similarity index 98% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryCell.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryCell.java index da4e314..e514ae8 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryCell.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryCell.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.expression.Templates; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryRow.java b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryRow.java similarity index 99% rename from report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryRow.java rename to report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryRow.java index 6dc019d..384608a 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/render/grid/SummaryRow.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/core/grid/SummaryRow.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.grid; +package com.codingapi.report.core.grid; import com.codingapi.report.data.dataset.FieldRef; import java.util.List; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModel.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModel.java index 2103898..72e34ed 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModel.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModel.java @@ -1,54 +1,42 @@ package com.codingapi.report.data.datamodel; +import com.codingapi.report.dto.report.FieldRefDTO; +import com.codingapi.report.dto.datamodel.DataModelDTO; +import com.codingapi.report.dto.datamodel.DataSourceDTO; +import com.codingapi.report.dto.datamodel.DatasetDTO; +import com.codingapi.report.dto.datamodel.FieldDTO; +import com.codingapi.report.dto.datamodel.RelationshipDTO; +import com.codingapi.report.dto.datamodel.UnionMemberDTO; +import com.codingapi.report.data.dataset.DataType; import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.dataset.FieldRef; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.dataset.UnionDataset; +import com.codingapi.report.data.dataset.UnionMember; import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.type.DataSourceType; +import com.codingapi.report.data.relation.JoinType; +import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import lombok.Builder; import lombok.Data; /** * 数据模型(可复用的语义层):描述"数据长什么样、表之间怎么关联",与具体报表无关。 * - *

为什么要把数据模型从报表里独立出来?

+ *

这是领域对象,也是 {@code DataModelRepository} 的持久化实体(不再有单独的 {@code DataModelConfig})。 面向前端时通过 + * {@link #toDTO()} 转成 {@link DataModelDTO}(出口脱敏由上层做),前端 JSON 通过 {@link #fromDTO(DataModelDTO)} 还原。 * - *

如果不分离,每张报表都要各自维护一份"连哪些库、用哪些表、表间怎么关联"——当 10 张报表 都用同一批表和关系时,改一张表名或加一条外键就要改 10 处。把连接/数据集/关系上提到 - * DataModel 后,建一次,多个 {@link Report} 引用,维护点收敛到一处。 + *

分层

* - *

这个分层是业界共识: - * - *

    - *
  • Power BI 的 Tabular Model——连接 + 表 + 关系,多个报表共用 - *
  • Tableau 的 Data Source——独立于 Worksheet 发布和引用 - *
  • 帆软的 服务器数据集——全局定义、报表引用 - *
- * - *

三层结构一览

- * - *
- *   DataModel(建一次,复用)            Report A ─┐
- *   ├── datasources (连接)              Report B ─┼─ 都引用同一个 DataModel
- *   ├── datasets    (表/查询)           Report C ─┘
- *   └── relationships (跨表关联)
- * 
- * - *

计算在哪发生?

- * - *

数据计算全部在 Java 内存完成,连接({@link DataSource})只负责"提取规整表" ({@code RawTable})。因此这里的 {@link - * Relationship} 是喂给 Java 内存 join 算子 的 JoinSpec,而非下推到 SQL 的 JOIN——这样才能支持跨源(如 MySQL 表 JOIN CSV 文件) - * 的关联。 - * - *

为什么不直接嵌在 Report 里?

- * - *

技术上可以把 datasources/datasets/relationships 全部放进 Report,但代价是: - * - *

    - *
  • 数据定义随报表扩散,同一张表在 N 个报表里有 N 份副本 - *
  • 改字段类型/加关系需要逐报表修改,容易漏改导致不一致 - *
  • 无法做"数据模型级别"的权限管理和版本控制 - *
- * - * 独立出来后,DataModel 可以有自己的生命周期(创建、审核、发布),报表只引用 id。 + *

建一次、多个 {@code Report} 引用(对齐 Power BI Tabular Model / Tableau Data Source / 帆软服务器数据集)。 数据计算全在 Java + * 内存完成,连接({@link DataSource})只负责取规整表,故 {@link Relationship} 是内存 join 的 JoinSpec, 支持跨源关联(MySQL 表 JOIN + * CSV 文件)。 */ @Data @Builder @@ -56,28 +44,277 @@ public class DataModel { private String id; private String name; - /** - * 连接列表(库/API/Excel/CSV/JSON)。 - * - *

每个 DataSource 封装一个物理连接的配置(host、端口、凭证等), 报表模板不直接引用连接,而是通过 {@link Dataset} 间接使用。 - */ - private List datasources; + /** 状态(草稿/已发布)。 */ + private DataModelStatus status; + + /** 创建时间(epoch 毫秒)。 */ + private long createTime; + + /** 修改时间(epoch 毫秒)。 */ + private long updateTime; /** - * 数据集列表:从连接中选出的单表/单查询,是报表绑定的最小粒度。 + * 数据集列表:本模型选用的数据集,是报表绑定的最小粒度。 * - *

一个 DataSource 下可以有多个 Dataset(比如一个库里选了 5 张表就建 5 个 Dataset), 也可以是 UNION 派生数据集(多个 Dataset - * 纵向合并为一个统一视图)。 + *

    + *
  • {@link TableDataset} —— 物理表,自带所属 {@link DataSource}({@code dataset.getDatasource()}), + * 故本模型不再单独持有 datasources 列表 + *
  • {@link UnionDataset} —— UNION 派生(可跨源合并),定义在本模型内,无单一连接 + *
*/ private List datasets; - /** - * 跨数据集关联关系,所有引用本模型的报表共享。 - * - *

可以来自数据库外键自动扫描({@code RelationOrigin.AUTO}), 也可以来自用户在界面上手动连线({@code - * RelationOrigin.MANUAL},支持跨源)。 - * - *

如果某条关系只被一张报表需要,可以放在 {@link Report#getExtraRelationships()} 里, 不污染共享模型。 - */ + /** 跨数据集关联关系,所有引用本模型的报表共享({@code RelationOrigin.AUTO} 自动扫描 / {@code MANUAL} 手动连线)。 */ private List relationships; + + // ============================================================ + // 派生视图 + // ============================================================ + + /** 去重收集本模型用到的连接(由各 TableDataset 自带的 datasource 聚合)。 */ + public List datasources() { + Map byId = new LinkedHashMap<>(); + if (datasets != null) { + for (Dataset ds : datasets) { + if (ds instanceof TableDataset t && t.getDatasource() != null) { + byId.putIfAbsent(t.getDatasource().getId(), t.getDatasource()); + } + } + } + return new ArrayList<>(byId.values()); + } + + // ============================================================ + // 领域 → DTO(出口;敏感字段脱敏由上层处理) + // ============================================================ + + public DataModelDTO toDTO() { + return new DataModelDTO( + id, + name, + status != null ? status.name() : null, + createTime, + updateTime, + toDatasourceDtos(), + toDatasetDtos(), + toRelationshipDtos()); + } + + private List toDatasourceDtos() { + List out = new ArrayList<>(); + for (DataSource s : datasources()) { + out.add( + new DataSourceDTO( + s.getId(), + s.getName(), + s.getType() != null ? s.getType().type() : null, + s.getConfig())); + } + return out; + } + + private List toDatasetDtos() { + List out = new ArrayList<>(); + if (datasets == null) return out; + for (Dataset ds : datasets) { + if (ds instanceof TableDataset t) { + out.add( + new DatasetDTO( + t.getId(), + t.getAlias(), + "TABLE", + t.getDatasourceId(), + t.getSourceTable(), + toFieldDtos(t.getFields()), + null)); + } else if (ds instanceof UnionDataset u) { + out.add( + new DatasetDTO( + u.getId(), + u.getAlias(), + "UNION", + null, + null, + toFieldDtos(u.getFields()), + toUnionMemberDtos(u.getMembers()))); + } + } + return out; + } + + private static List toFieldDtos(List fields) { + List out = new ArrayList<>(); + if (fields == null) return out; + for (Field f : fields) { + out.add( + new FieldDTO( + f.getName(), + f.getAlias(), + f.getDataType() != null ? f.getDataType().name() : null, + f.isPrimaryKey())); + } + return out; + } + + private static List toUnionMemberDtos(List members) { + List out = new ArrayList<>(); + if (members == null) return out; + for (UnionMember m : members) { + out.add(new UnionMemberDTO(m.datasetId(), m.mapping())); + } + return out; + } + + private List toRelationshipDtos() { + List out = new ArrayList<>(); + if (relationships == null) return out; + for (Relationship r : relationships) { + out.add( + new RelationshipDTO( + r.getId(), + toFieldRefDto(r.getLeft()), + toFieldRefDto(r.getRight()), + r.getJoinType() != null ? r.getJoinType().name() : null, + r.getOrigin() != null ? r.getOrigin().name() : null)); + } + return out; + } + + private static FieldRefDTO toFieldRefDto(FieldRef ref) { + return ref == null ? null : new FieldRefDTO(ref.datasetId(), ref.field()); + } + + // ============================================================ + // DTO → 领域(入口) + // ============================================================ + + public static DataModel fromDTO(DataModelDTO dto) { + if (dto == null) return null; + Map sources = buildSources(dto.datasources()); + List datasets = buildDatasets(dto.datasets(), sources); + attachDatasetsToSources(datasets, sources); + return DataModel.builder() + .id(dto.id()) + .name(dto.name()) + .status(dto.status() != null ? DataModelStatus.valueOf(dto.status()) : null) + .createTime(dto.createTime()) + .updateTime(dto.updateTime()) + .datasets(datasets) + .relationships(buildRelationships(dto.relationships())) + .build(); + } + + private static Map buildSources(List sources) { + Map out = new LinkedHashMap<>(); + if (sources == null) return out; + for (DataSourceDTO s : sources) { + DataSourceType type = + s.type() != null ? DataSourceType.of(s.type(), s.config()) : null; + out.put( + s.id(), + DataSource.builder() + .id(s.id()) + .name(s.name()) + .type(type) + .config(s.config()) + .build()); + } + return out; + } + + private static List buildDatasets( + List datasets, Map sources) { + List out = new ArrayList<>(); + if (datasets == null) return out; + for (DatasetDTO d : datasets) { + if ("UNION".equals(d.kind())) { + out.add( + UnionDataset.builder() + .id(d.id()) + .alias(d.alias()) + .fields(buildFields(d.fields())) + .members(buildUnionMembers(d.members())) + .build()); + } else { + out.add( + TableDataset.builder() + .id(d.id()) + .alias(d.alias()) + .datasource(sources.get(d.datasourceId())) + .datasourceId(d.datasourceId()) + .sourceTable(d.sourceTable()) + .fields(buildFields(d.fields())) + .build()); + } + } + return out; + } + + private static void attachDatasetsToSources( + List datasets, Map sources) { + Map> grouped = new LinkedHashMap<>(); + for (Dataset ds : datasets) { + if (ds instanceof TableDataset t && t.getDatasource() != null) { + grouped.computeIfAbsent(t.getDatasource().getId(), k -> new ArrayList<>()).add(t); + } + } + grouped.forEach( + (id, list) -> { + DataSource s = sources.get(id); + if (s != null) s.setDatasets(list); + }); + } + + private static List buildFields(List fields) { + List out = new ArrayList<>(); + if (fields == null) return out; + for (FieldDTO f : fields) { + out.add( + Field.builder() + .name(f.name()) + .alias(f.alias()) + .dataType( + f.dataType() != null + ? DataType.valueOf(f.dataType()) + : DataType.STRING) + .primaryKey(f.primaryKey()) + .build()); + } + return out; + } + + private static List buildUnionMembers(List members) { + List out = new ArrayList<>(); + if (members == null) return out; + for (UnionMemberDTO m : members) { + out.add(new UnionMember(m.datasetId(), m.mapping() != null ? m.mapping() : Map.of())); + } + return out; + } + + public static List buildRelationships(List rels) { + List out = new ArrayList<>(); + if (rels == null) return out; + for (RelationshipDTO r : rels) { + out.add( + Relationship.builder() + .id(r.id()) + .left(toFieldRef(r.left())) + .right(toFieldRef(r.right())) + .joinType( + r.joinType() != null + ? JoinType.valueOf(r.joinType()) + : JoinType.INNER) + .origin( + r.origin() != null + ? RelationOrigin.valueOf(r.origin()) + : RelationOrigin.MANUAL) + .build()); + } + return out; + } + + private static FieldRef toFieldRef(FieldRefDTO ref) { + return ref == null ? null : new FieldRef(ref.datasetId(), ref.field()); + } } diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModelStatus.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModelStatus.java new file mode 100644 index 0000000..2e5f613 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datamodel/DataModelStatus.java @@ -0,0 +1,14 @@ +package com.codingapi.report.data.datamodel; + +/** + * 数据模型状态。 + * + *

    + *
  • {@link #DRAFT} —— 草稿,编辑中,未发布 + *
  • {@link #PUBLISHED} —— 已发布,可被报表引用 + *
+ */ +public enum DataModelStatus { + DRAFT, + PUBLISHED +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/FieldRef.java b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/FieldRef.java index 232d57d..eeb73d2 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/FieldRef.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/FieldRef.java @@ -8,8 +8,8 @@ *
    *
  • {@link com.codingapi.report.expression.Value.FieldValue} — 格子绑定的字段 *
  • {@link com.codingapi.report.operator.condition.Condition} — 过滤条件的左值 - *
  • {@link com.codingapi.report.render.grid.SummaryRow} — 小计/总计的分组键 - *
  • {@link com.codingapi.report.render.grid.SummaryCell} — 汇总行的聚合字段 + *
  • {@link com.codingapi.report.core.grid.SummaryRow} — 小计/总计的分组键 + *
  • {@link com.codingapi.report.core.grid.SummaryCell} — 汇总行的聚合字段 *
  • {@link com.codingapi.report.data.relation.Relationship} — 跨数据集关联的左右端点 *
* diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/Query.java b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/Query.java index dae8087..b00e1b1 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/Query.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/Query.java @@ -5,7 +5,7 @@ import lombok.Data; /** - * 查询定义:从一个数据集提取数据的意图。主要用作 {@link com.codingapi.report.render.grid.LoopBlock} + * 查询定义:从一个数据集提取数据的意图。主要用作 {@link com.codingapi.report.core.grid.LoopBlock} * 的驱动源(决定循环迭代的范围和顺序)。 * *

在架构中的位置

@@ -24,7 +24,7 @@ *
  • {@link #filters} 中"只引用本数据集列 + 常量/参数"的条件 → 可下推到源 SELECT 的 WHERE,减少入内存的行数 (如 DB * 类型可直接拼成 SQL WHERE 子句) *
  • 跨数据集 / join 之后的条件 → 留在 Java 加工层(不在 Query 里, 而是放在 {@link - * com.codingapi.report.render.grid.CellBinding#getConditions()} 里) + * com.codingapi.report.core.grid.CellBinding#getConditions()} 里) * * *

    分组与迭代的关系

    diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/TableDataset.java b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/TableDataset.java index b023361..c92aaf8 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/TableDataset.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/dataset/TableDataset.java @@ -1,5 +1,6 @@ package com.codingapi.report.data.dataset; +import com.codingapi.report.data.datasource.DataSource; import java.util.List; import lombok.Builder; import lombok.Data; @@ -13,8 +14,9 @@ * DataSource(连接)→ TableDataset(表/查询)→ Report(报表) * * - * 库里 50 张表,报表只用 3 张 → 只建 3 个 TableDataset。{@link #datasourceId} 指向连接, {@link #sourceTable} 是表名(或一段 - * SQL 查询)。跨表关联交给 {@link com.codingapi.report.data.relation.Relationship},数据集本身永远是单表单查询。 + * 库里 50 张表,报表只用 3 张 → 只建 3 个 TableDataset。{@link #datasource} 是它所属的连接(取数时直接用, + * 无需再到 DataModel 里按 id 查找),{@link #sourceTable} 是表名(或一段 SQL 查询)。跨表关联交给 {@link + * com.codingapi.report.data.relation.Relationship},数据集本身永远是单表单查询。 */ @Data @Builder @@ -23,7 +25,14 @@ public final class TableDataset implements Dataset { /** 数据集唯一标识。 */ private String id; - /** 来自哪个连接,指向 {@code DataSource.id}。 */ + /** + * 所属连接(聚合):TableDataset 自带取数所需的 {@link DataSource},由它自己负责"从哪取数"。 + * + *

    这样 {@code DataModel} 不再持有 datasources 列表——要拿连接直接 {@code dataset.getDatasource()}。 + */ + private DataSource datasource; + + /** 来自哪个连接,冗余存连接 id(= {@code datasource.getId()}),便于 DTO/展示。 */ private String datasourceId; /** 对应库里的表名(或一段 SQL 查询)。 */ diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/ColumnMeta.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/ColumnMeta.java new file mode 100644 index 0000000..cdf85f1 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/ColumnMeta.java @@ -0,0 +1,14 @@ +package com.codingapi.report.data.datasource; + +/** + * 列元数据:{@link DataExtractor#listColumns(DataSource, String)} 的返回项。 + * + *

    用于数据集建模时"选表 → 探查列":从物理数据源读出列名/原生类型/主键标记, 供前端展示并映射到 {@link + * com.codingapi.report.data.dataset.DataType}。 + * + * @param name 物理列名(与数据库/文件表头一致) + * @param dataType 数据源原生类型名(如 {@code VARCHAR}/{@code INTEGER}/{@code TIMESTAMP}), 由提取器返回,映射到业务 + * {@link com.codingapi.report.data.dataset.DataType} 由建模层完成 + * @param primaryKey 是否主键(来自 JDBC {@code getPrimaryKeys} 或外键元数据) + */ +public record ColumnMeta(String name, String dataType, boolean primaryKey) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataExtractor.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataExtractor.java index 05890fa..e3bf46b 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataExtractor.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataExtractor.java @@ -1,6 +1,7 @@ package com.codingapi.report.data.datasource; import com.codingapi.report.data.dataset.Dataset; +import java.util.List; /** * 数据提取器接口(SPI):每种 {@link DataSourceType} 对应一个实现, 唯一职责是把数据取成规整的 {@link RawTable}。 @@ -18,7 +19,7 @@ * 这是"提取 / 加工"的分界线: * *

      - *
    • 提取(本接口的职责):从物理数据源读取数据,转为统一的内存表格式。 每种连接类型(DB/CSV/API/Excel/JSON)各一个实现类 + *
    • 提取(本接口的职责):从物理数据源读取数据,转为统一的内存表格式。 每种连接类型(DB/EXCEL/CSV)各一个实现类 *
    • 加工({@link Operators} 的范畴):filter/join/aggregate 全在 Java 完成, 与数据源类型无关。这使得跨源计算(如 MySQL * 表 JOIN CSV 文件)成为可能 *
    @@ -38,8 +39,8 @@ *

    新增一个数据源类型只需三步: * *

      - *
    1. 在 {@link DataSourceType} 枚举中新增值(如 {@code MONGO}) - *
    2. 实现本接口:{@code supports()} 返回 true 当 type == MONGO,{@code extract()} 实现具体读取逻辑 + *
    3. 在 {@link DataSourceKind} 中新增判别码(如 {@code MONGO}),并实现 {@link DataSourceType} 承载类型级配置 + *
    4. 实现本接口:{@code supports()} 返回 true 当 kind == MONGO,{@code extract()} 实现具体读取逻辑 *
    5. 注册到 {@link ReportRenderer} 的 extractors 列表中 *
    * @@ -48,11 +49,11 @@ public interface DataExtractor { /** - * 是否支持指定的数据源类型。 + * 是否支持指定的数据源类型(按 {@code DataSourceType.type()} 判别串匹配,如 {@code "DB"})。 * *

    ReportRenderer 遍历 extractors 列表,调用此方法找到匹配的提取器。 */ - boolean supports(DataSourceType type); + boolean supports(String type); /** * 从数据源提取一个数据集的全部数据,返回规整的内存表。 @@ -62,4 +63,33 @@ public interface DataExtractor { * @return 内存表,列名为限定名 {@code datasetId.field},值已按 DataType 归一化 */ RawTable extract(DataSource source, Dataset dataset); + + // ============================================================ + // 管理能力(连接测试 + 元数据探查) + // ============================================================ + // + // 这些方法服务于"数据源管理"而非渲染:测试连接可达性、探查表/列元数据, 供建模界面选表、推断字段。提取器按能力实现—— + // 文件类(CSV/EXCEL)通常无需探查(表即文件、列即表头),可不实现走默认抛异常; DB/API 类应实现。 + // 用 default 方法而非另立 SPI,避免多接口装配;未实现的能力显式抛 UnsupportedOperationException, + // 不会静默放行(与本项目算子/聚合/函数的注册表范式一致)。 + + /** + * 测试连接是否可达且凭证有效。 + * + *

    不落库、不影响现有数据,仅建连 + 最小探测(如 {@code SELECT 1})后立即关闭。 默认抛 {@link + * UnsupportedOperationException},表示该提取器不支持连接测试(文件类无此概念)。 + */ + default TestResult test(DataSource source) { + throw new UnsupportedOperationException("提取器不支持连接测试"); + } + + /** 探查连接下可用的表/集合列表(DB/API 类实现)。 默认抛 {@link UnsupportedOperationException}。 */ + default List listTables(DataSource source) { + throw new UnsupportedOperationException("提取器不支持表探查"); + } + + /** 探查指定表的列元数据(DB/API 类实现)。 默认抛 {@link UnsupportedOperationException}。 */ + default List listColumns(DataSource source, String table) { + throw new UnsupportedOperationException("提取器不支持列探查"); + } } diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSource.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSource.java index 75ef8de..8e1fbd4 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSource.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSource.java @@ -1,5 +1,8 @@ package com.codingapi.report.data.datasource; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.datasource.type.DataSourceType; +import java.util.List; import java.util.Map; import lombok.Builder; import lombok.Data; @@ -40,9 +43,15 @@ public class DataSource { private String id; private String name; - /** 连接类型 = 提取器种类(DB/API/EXCEL/CSV/JSON)。 按"怎么取数"划分,不区分具体数据库厂商(MySQL/PostgreSQL 都是 DB)。 */ + /** 连接类型(DB/EXCEL/CSV)+ 类型级配置。 按"怎么取数"划分,不区分具体数据库厂商(MySQL/PostgreSQL 都是 DB)。 */ private DataSourceType type; + /** + * 该连接下的数据集(聚合根):构建连接时一次性把可见的表/sheet 建成 {@link + * com.codingapi.report.data.dataset.TableDataset}。{@code DataModel} 引用其中被选中的若干数据集。 + */ + private List datasets; + /** * 连接配置(host/库名/密码/文件路径等),不进报表模板。 * diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSourceType.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSourceType.java deleted file mode 100644 index f82621a..0000000 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/DataSourceType.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.codingapi.report.data.datasource; - -/** - * 数据源类型 = 提取器种类,按"怎么取数"划分,不区分具体数据库厂商。 - * - *

    因为计算全在 Java、连接只负责提取,关系型数据库一律走 JDBC,语法层面没有区别, 所以不需要区分 MySQL / Postgres——具体厂商(驱动 / URL / 方言)只是 - * {@link DataSource#getConfig()} 里的连接配置,不构成类型差异。 - * - *

    每个类型对应一种提取器实现(见执行层 {@code DataExtractor}),后续可扩展 ES、MONGO 等。 - */ -public enum DataSourceType { - /** 关系型数据库,统一走 JDBC(MySQL / Postgres / Oracle… 厂商差异落在连接配置里) */ - DB, - /** HTTP 接口(通常返回 JSON) */ - API, - /** Excel 文件 */ - EXCEL, - /** CSV 文件 */ - CSV, - /** JSON 文档 / 文件 */ - JSON - // 后续可扩展:ES, MONGO, ... -} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/TestResult.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/TestResult.java new file mode 100644 index 0000000..d2dee51 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/TestResult.java @@ -0,0 +1,13 @@ +package com.codingapi.report.data.datasource; + +/** + * 连接测试结果:{@link DataExtractor#test(DataSource)} 的返回值。 + * + *

    {@code ok=true} 表示连接可达且凭证有效;{@code ok=false} 时 {@code message} 携带失败原因 + * (驱动缺失、认证失败、网络不通等),供前端"测试连接"按钮直接展示。 + * + * @param ok 是否连通 + * @param message 结果说明(成功为提示语,失败为原因) + * @param latencyMs 建连 + 探测耗时(毫秒),用于前端展示连接速度 + */ +public record TestResult(boolean ok, String message, long latencyMs) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/credential/CredentialService.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/credential/CredentialService.java new file mode 100644 index 0000000..4338899 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/credential/CredentialService.java @@ -0,0 +1,151 @@ +package com.codingapi.report.data.datasource.credential; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.slf4j.Slf4j; + +/** + * 凭证服务:对 {@code DataSource.config} 中的敏感字段做对称加密 / 解密 / 脱敏。 + * + *

    三态

    + * + *
      + *
    • 持久态:敏感值为 {@code "enc:" + Base64(IV + ciphertext)}(加密) + *
    • 运行态:渲染/探查时解密回明文 + *
    • 出口态:API 返回时敏感值替 {@code "***"}(脱敏),无论是否已加密 + *
    + * + *

    密钥

    + * + *

    密钥从配置 {@code report.datasource.crypto.key} 注入;缺省用开发期默认值并告警(生产必须覆盖)。 用 SHA-256 派生 32 字节 AES-256 + * 密钥。{@code "enc:"} 前缀标记避免重复加密 / 误判明文。 + * + *

    用 JDK {@code javax.crypto}(AES/GCM/NoPadding)而非 commons-crypto:后者是 JNA 加速的低层 API, 需自行管理 IV/Key + * 派生,对本场景过重;JDK 自带零依赖、足够安全。 + */ +@Slf4j +public class CredentialService { + + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_BITS = 128; + private static final int IV_LEN = 12; + private static final String PREFIX = "enc:"; + private static final String MASK = "***"; + + /** 敏感 key 名集合(小写匹配)。新增连接类型若有别的命名约定,扩展此集即可。 */ + private static final Set SENSITIVE_KEYS = + Set.of( + "password", + "pwd", + "secret", + "token", + "apikey", + "apiKey", + "credential", + "credentials"); + + private final SecretKeySpec key; + + public CredentialService(String secret) { + String s = + (secret == null || secret.isBlank()) ? "report-engine-default-crypto-key" : secret; + if (secret == null || secret.isBlank()) { + log.warn("未配置 report.datasource.crypto.key,凭证加密使用开发期默认密钥,生产环境必须覆盖"); + } + this.key = new SecretKeySpec(sha256(s), "AES"); + } + + /** 加密 config 中的敏感 String 值(已带 {@code enc:} 前缀的不重复加密)。 */ + public Map encryptConfig(Map config) { + if (config == null) return null; + Map out = new LinkedHashMap<>(config); + for (Map.Entry e : out.entrySet()) { + if (isSensitive(e.getKey()) + && e.getValue() instanceof String v + && !v.startsWith(PREFIX) + && !v.isEmpty()) { + e.setValue(encrypt(v)); + } + } + return out; + } + + /** 解密 config 中带 {@code enc:} 前缀的值;非加密值原样返回。 */ + public Map decryptConfig(Map config) { + if (config == null) return null; + Map out = new LinkedHashMap<>(config); + for (Map.Entry e : out.entrySet()) { + if (e.getValue() instanceof String v && v.startsWith(PREFIX)) { + e.setValue(decrypt(v)); + } + } + return out; + } + + /** 脱敏:敏感 key 的值一律替 {@code "***"}(不论是否加密)。 */ + public Map maskConfig(Map config) { + if (config == null) return null; + Map out = new LinkedHashMap<>(config); + for (Map.Entry e : out.entrySet()) { + if (isSensitive(e.getKey()) && e.getValue() != null) { + e.setValue(MASK); + } + } + return out; + } + + /** 判断值是否为脱敏占位({@code "***"}),用于 save 时回填旧值。 */ + public boolean isMasked(Object value) { + return MASK.equals(value); + } + + public boolean isSensitive(String keyName) { + return keyName != null && SENSITIVE_KEYS.contains(keyName.toLowerCase()); + } + + private String encrypt(String plain) { + try { + byte[] iv = new byte[IV_LEN]; + java.security.SecureRandom.getInstanceStrong().nextBytes(iv); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return PREFIX + Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + throw new IllegalStateException("凭证加密失败", e); + } + } + + private String decrypt(String value) { + try { + byte[] all = Base64.getDecoder().decode(value.substring(PREFIX.length())); + byte[] iv = new byte[IV_LEN]; + byte[] ct = new byte[all.length - IV_LEN]; + System.arraycopy(all, 0, iv, 0, IV_LEN); + System.arraycopy(all, IV_LEN, ct, 0, ct.length); + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ct), StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("凭证解密失败", e); + } + } + + private static byte[] sha256(String s) { + try { + return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/csv/CsvDataExtractor.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/CsvDataExtractor.java similarity index 95% rename from report-engine-framework/src/main/java/com/codingapi/report/data/datasource/csv/CsvDataExtractor.java rename to report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/CsvDataExtractor.java index 9fc5ef0..b5c12fa 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/csv/CsvDataExtractor.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/CsvDataExtractor.java @@ -1,11 +1,10 @@ -package com.codingapi.report.data.datasource.csv; +package com.codingapi.report.data.datasource.extractor; import com.codingapi.report.data.dataset.DataType; import com.codingapi.report.data.dataset.Dataset; import com.codingapi.report.data.dataset.Field; import com.codingapi.report.data.datasource.DataExtractor; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; import com.codingapi.report.data.datasource.RawTable; import java.io.BufferedReader; import java.io.InputStream; @@ -27,7 +26,7 @@ *

      *   DataSource.builder()
      *     .id("csv_employees")
    - *     .type(DataSourceType.CSV)
    + *     .type(new CsvDataSourceType(null))
      *     .config(Map.of("path", "/data/employees.csv"))  // classpath 路径
      *     .build()
      * 
    @@ -55,8 +54,8 @@ public class CsvDataExtractor implements DataExtractor { @Override - public boolean supports(DataSourceType type) { - return type == DataSourceType.CSV; + public boolean supports(String type) { + return "CSV".equals(type); } @Override diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/DbDataExtractor.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/DbDataExtractor.java new file mode 100644 index 0000000..6bc81dc --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/DbDataExtractor.java @@ -0,0 +1,205 @@ +package com.codingapi.report.data.datasource.extractor; + +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.datasource.ColumnMeta; +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; + +/** + * 关系型数据库提取器:统一走 JDBC,厂商差异(MySQL/Postgres/Oracle/H2)落在 {@code DataSource.config} 的 url/driver + * 里,不构成类型差异(与 {@code DB} 类型"按取数方式划分"一致)。 + * + *

    连接配置约定

    + * + *
      + *
    • {@code url}:完整 JDBC URL(如 {@code jdbc:h2:mem:test}),优先使用 + *
    • 否则用 {@code host}/{@code port}/{@code database} 拼接(driver 必填或从 url 推断) + *
    • {@code driver}:JDBC 驱动类名(如 {@code org.h2.Driver}) + *
    • {@code username}/{@code password}:凭证(password 在持久态为加密值,由 converter 解密后传入) + *
    + * + *

    连接用 {@code DriverManager},用完即关(一期不做连接池)。{@code sourceTable} 可为表名或一段 SELECT SQL (以 {@code + * SELECT} 开头当 SQL 直接执行,否则 {@code SELECT * FROM })。 + */ +@Slf4j +public class DbDataExtractor implements DataExtractor { + + @Override + public boolean supports(String type) { + return "DB".equals(type); + } + + @Override + public RawTable extract(DataSource source, Dataset dataset) { + String sql = buildQuery(source, dataset); + log.debug("DB 提取: {} sql={}", dataset.getId(), sql); + try (Connection conn = openConnection(source); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + return readRows(rs, dataset); + } catch (SQLException e) { + throw new IllegalStateException("DB 提取失败: " + dataset.getId(), e); + } + } + + @Override + public TestResult test(DataSource source) { + long start = System.currentTimeMillis(); + try (Connection conn = openConnection(source); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT 1")) { + rs.next(); + long latency = System.currentTimeMillis() - start; + return new TestResult(true, "连接成功", latency); + } catch (Exception e) { + long latency = System.currentTimeMillis() - start; + return new TestResult(false, "连接失败: " + e.getMessage(), latency); + } + } + + @Override + public List listTables(DataSource source) { + List tables = new ArrayList<>(); + try (Connection conn = openConnection(source)) { + DatabaseMetaData md = conn.getMetaData(); + try (ResultSet rs = md.getTables(null, null, "%", new String[] {"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + } catch (SQLException e) { + throw new IllegalStateException("表探查失败", e); + } + return tables; + } + + @Override + public List listColumns(DataSource source, String table) { + Set pk = new HashSet<>(); + List columns = new ArrayList<>(); + try (Connection conn = openConnection(source)) { + DatabaseMetaData md = conn.getMetaData(); + try (ResultSet rs = md.getPrimaryKeys(null, null, table)) { + while (rs.next()) { + pk.add(rs.getString("COLUMN_NAME")); + } + } + try (ResultSet rs = md.getColumns(null, null, table, "%")) { + while (rs.next()) { + String name = rs.getString("COLUMN_NAME"); + String type = rs.getString("TYPE_NAME"); + columns.add(new ColumnMeta(name, type, pk.contains(name))); + } + } + } catch (SQLException e) { + throw new IllegalStateException("列探查失败: " + table, e); + } + return columns; + } + + // ============================================================ + // 内部 + // ============================================================ + + private String buildQuery(DataSource source, Dataset dataset) { + if (dataset instanceof com.codingapi.report.data.dataset.TableDataset t) { + String table = t.getSourceTable(); + if (table == null || table.isBlank()) { + throw new IllegalStateException("数据集缺少 sourceTable: " + dataset.getId()); + } + String trimmed = table.trim(); + if (trimmed.toUpperCase().startsWith("SELECT")) { + return trimmed; + } + return "SELECT * FROM " + table; + } + throw new IllegalStateException("DB 提取只支持物理表数据集: " + dataset.getId()); + } + + private RawTable readRows(ResultSet rs, Dataset dataset) throws SQLException { + List columns = new ArrayList<>(); + for (Field f : dataset.getFields()) { + columns.add(dataset.getId() + "." + f.getName()); + } + List> rows = new ArrayList<>(); + while (rs.next()) { + Map row = new LinkedHashMap<>(); + for (Field f : dataset.getFields()) { + Object raw = rs.getObject(f.getName()); + row.put(dataset.getId() + "." + f.getName(), coerce(raw, f.getDataType())); + } + rows.add(row); + } + return new RawTable(columns, rows); + } + + private static Object coerce(Object value, DataType type) { + if (value == null) return null; + return switch (type) { + case NUMBER -> + value instanceof Number n + ? n.doubleValue() + : Double.parseDouble(String.valueOf(value)); + case BOOLEAN -> + value instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(value)); + default -> String.valueOf(value); + }; + } + + private Connection openConnection(DataSource source) throws SQLException { + Map config = + source.getConfig() != null ? source.getConfig() : new HashMap<>(); + String url = resolveUrl(config); + String driver = String.valueOf(config.getOrDefault("driver", "")); + if (!driver.isBlank() && !"null".equals(driver)) { + try { + Class.forName(driver); + } catch (ClassNotFoundException e) { + throw new SQLException("驱动未找到: " + driver, e); + } + } + String user = config.get("username") instanceof String u ? u : null; + String pass = config.get("password") instanceof String p ? p : null; + return DriverManager.getConnection(url, user, pass); + } + + /** 优先用 {@code url};否则 host/port/database 拼接(driver 用于推断方言)。 */ + private String resolveUrl(Map config) { + Object urlObj = config.get("url"); + if (urlObj instanceof String u && !u.isBlank()) { + return u; + } + String host = String.valueOf(config.getOrDefault("host", "localhost")); + String port = config.get("port") == null ? "" : String.valueOf(config.get("port")); + String db = String.valueOf(config.getOrDefault("database", "")); + String driver = String.valueOf(config.getOrDefault("driver", "")).toLowerCase(); + if (driver.contains("mysql")) { + return "jdbc:mysql://" + host + (port.isBlank() ? "" : ":" + port) + "/" + db; + } + if (driver.contains("postgresql")) { + return "jdbc:postgresql://" + host + (port.isBlank() ? "" : ":" + port) + "/" + db; + } + if (driver.contains("h2")) { + return "jdbc:h2:" + (db.isBlank() ? "mem:test" : db); + } + throw new IllegalStateException("无法解析 JDBC URL:请直接配置 config.url"); + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelConfig.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelConfig.java new file mode 100644 index 0000000..f39ed28 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelConfig.java @@ -0,0 +1,44 @@ +package com.codingapi.report.data.datasource.extractor; + +import com.codingapi.report.data.datasource.DataSource; +import java.util.HashMap; +import java.util.Map; + +/** + * Excel 数据源连接配置:从 {@link DataSource#getConfig()} 解析而来。 + * + *

    配置项约定

    + * + *
      + *
    • {@code path}(必填):.xlsx 文件路径 + *
    • {@code sheetIndex}:工作表索引(0-based),默认 0 + *
    • {@code headerRow}:表头所在行索引(0-based),默认 0 + *
    + */ +public record ExcelConfig(String path, int sheetIndex, int headerRow) { + + public static ExcelConfig from(DataSource source) { + Map raw = source.getConfig() != null ? source.getConfig() : new HashMap<>(); + String path = str(raw.get("path")); + if (path == null || path.isBlank()) { + throw new IllegalStateException("Excel 数据源缺少 config.path"); + } + int sheetIndex = intOr(raw.get("sheetIndex"), 0); + int headerRow = intOr(raw.get("headerRow"), 0); + return new ExcelConfig(path, sheetIndex, headerRow); + } + + private static String str(Object v) { + return v == null ? null : String.valueOf(v); + } + + private static int intOr(Object v, int def) { + if (v == null) return def; + if (v instanceof Number n) return n.intValue(); + try { + return Integer.parseInt(String.valueOf(v).trim()); + } catch (NumberFormatException e) { + return def; + } + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractor.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractor.java new file mode 100644 index 0000000..dff59cc --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractor.java @@ -0,0 +1,174 @@ +package com.codingapi.report.data.datasource.extractor; + +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import com.codingapi.report.excel.ExcelImporter; +import com.codingapi.report.excel.pojo.Cell; +import com.codingapi.report.excel.pojo.Sheet; +import com.codingapi.report.excel.pojo.Workbook; +import com.fasterxml.jackson.databind.JsonNode; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * Excel 文件数据提取器:用 {@link ExcelImporter} 解析 .xlsx,首行当表头、后续行当数据, 按 {@link + * Dataset#getFields()} 映射为 {@link RawTable}。 + * + *

    设计要点

    + * + *
      + *
    • 复用 {@code report-engine-excel} 的 {@link ExcelImporter}(POI 封装),不重复造轮子 + *
    • {@code Workbook → Sheet → Cell} POJO 模型,{@link Cell#getValue()} 是 Jackson {@link JsonNode} + * (保留原始类型:字符串/数字/布尔/空值) + *
    • 列名用限定名 {@code datasetId.field}(与 {@code DbDataExtractor} 一致) + *
    • 类型归一:{@code NUMBER→Double}、{@code BOOLEAN→Boolean}、其余 → {@code String} + *
    • {@code config}:{@code path}(必填)、{@code sheetIndex}(默认 0)、{@code headerRow}(默认 0) + *
    • {@link #test(DataSource)} 校验文件可读且能被 ExcelImporter 解析 + *
    + */ +@Slf4j +public class ExcelDataExtractor implements DataExtractor { + + private final ExcelImporter excelImporter = new ExcelImporter(); + + @Override + public boolean supports(String type) { + return "EXCEL".equals(type); + } + + @Override + public RawTable extract(DataSource source, Dataset dataset) { + ExcelConfig config = ExcelConfig.from(source); + log.debug("Excel 提取: {} path={} sheetIndex={}", dataset.getId(), config.path(), config.sheetIndex()); + try (InputStream input = openStream(config.path())) { + Workbook workbook = excelImporter.importFrom(input); + Sheet sheet = pickSheet(workbook, config.sheetIndex()); + return readRows(sheet, dataset, config.headerRow()); + } catch (IOException e) { + throw new IllegalStateException("Excel 提取失败: " + dataset.getId(), e); + } + } + + @Override + public TestResult test(DataSource source) { + long start = System.currentTimeMillis(); + try { + ExcelConfig config = ExcelConfig.from(source); + try (InputStream input = openStream(config.path())) { + excelImporter.importFrom(input); + } + long latency = System.currentTimeMillis() - start; + return new TestResult(true, "文件读取成功", latency); + } catch (Exception e) { + long latency = System.currentTimeMillis() - start; + return new TestResult(false, "文件读取失败: " + e.getMessage(), latency); + } + } + + // ============================================================ + // 内部 + // ============================================================ + + private RawTable readRows(Sheet sheet, Dataset dataset, int headerRow) { + List cells = sheet.getCells() != null ? sheet.getCells() : new ArrayList<>(); + + // 按行分组:row → (col → Cell) + Map> byRow = new LinkedHashMap<>(); + for (Cell c : cells) { + byRow.computeIfAbsent(c.getRow(), k -> new HashMap<>()).put(c.getCol(), c); + } + + // 表头:headerRow 的 col → 列名 + Map headerByCol = new HashMap<>(); + Map headerRowCells = byRow.getOrDefault(headerRow, new HashMap<>()); + for (Map.Entry e : headerRowCells.entrySet()) { + String name = textOf(e.getValue().getValue()); + if (name != null && !name.isBlank()) { + headerByCol.put(e.getKey(), name.trim()); + } + } + + // 字段名 → Field(按名匹配表头列) + Map fieldByName = new HashMap<>(); + for (Field f : dataset.getFields()) { + fieldByName.put(f.getName(), f); + } + + // 限定列名顺序 + List columns = new ArrayList<>(); + for (Field f : dataset.getFields()) { + columns.add(dataset.getId() + "." + f.getName()); + } + + // 数据行:row > headerRow + List> rows = new ArrayList<>(); + for (Map.Entry> rowEntry : byRow.entrySet()) { + int rowIdx = rowEntry.getKey(); + if (rowIdx <= headerRow) continue; + Map rowCells = rowEntry.getValue(); + Map row = new LinkedHashMap<>(); + // 初始化所有字段为 null + for (Field f : dataset.getFields()) { + row.put(dataset.getId() + "." + f.getName(), null); + } + for (Map.Entry e : rowCells.entrySet()) { + String colName = headerByCol.get(e.getKey()); + if (colName == null) continue; + Field f = fieldByName.get(colName); + if (f == null) continue; + row.put(dataset.getId() + "." + f.getName(), coerce(e.getValue().getValue(), f.getDataType())); + } + rows.add(row); + } + return new RawTable(columns, rows); + } + + private Sheet pickSheet(Workbook workbook, int sheetIndex) { + List sheets = workbook.getSheets(); + if (sheets == null || sheets.isEmpty()) { + throw new IllegalStateException("Excel 工作簿无工作表"); + } + if (sheetIndex < 0 || sheetIndex >= sheets.size()) { + throw new IllegalStateException("sheetIndex 越界: " + sheetIndex + " / " + sheets.size()); + } + return sheets.get(sheetIndex); + } + + private InputStream openStream(String path) throws IOException { + Path p = Paths.get(path); + if (!Files.exists(p)) { + throw new IOException("文件不存在: " + path); + } + return new FileInputStream(p.toFile()); + } + + private static Object coerce(JsonNode value, DataType type) { + if (value == null || value.isNull()) return null; + return switch (type) { + case NUMBER -> value.isNumber() ? value.doubleValue() : Double.parseDouble(value.asText()); + case BOOLEAN -> value.isBoolean() ? value.booleanValue() : Boolean.parseBoolean(value.asText()); + default -> value.isTextual() ? value.asText() : value.toString(); + }; + } + + private static String textOf(JsonNode value) { + if (value == null || value.isNull()) return null; + if (value.isTextual()) return value.asText(); + return value.asText(); + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/CsvDataSourceType.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/CsvDataSourceType.java new file mode 100644 index 0000000..35a385f --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/CsvDataSourceType.java @@ -0,0 +1,15 @@ +package com.codingapi.report.data.datasource.type; + +/** + * CSV 数据源类型。 + * + *

    {@code storagePath} 为可选的文件存储根目录;经典 classpath 用法下,具体 CSV 路径放在 {@code DataSource.config} 的 + * {@code path} 键,{@code storagePath} 可为 null。 + */ +public record CsvDataSourceType(String storagePath) implements DataSourceType { + + @Override + public String type() { + return "CSV"; + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DataSourceType.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DataSourceType.java new file mode 100644 index 0000000..28eb089 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DataSourceType.java @@ -0,0 +1,48 @@ +package com.codingapi.report.data.datasource.type; + +import java.util.Map; + +/** + * 数据源类型:约定每种类型的 类型级 metadata 配置,由枚举升级为 sealed interface。 + * + *

    不同类型携带不同的类型级配置("这一类数据源本身需要什么"): + * + *

      + *
    • {@link DbDataSourceType} —— 驱动 jar + 驱动类 + *
    • {@link ExcelDataSourceType} —— 上传文件的存储路径 + *
    • {@link CsvDataSourceType} —— 文件存储路径(测试/示例内置) + *
    + * + *

    连接实例级配置(DB 的 url/账号/密码/schema、文件的具体路径)放在 {@code DataSource.config}。 + * + *

    {@link #type()} 返回判别串({@code "DB"}/{@code "EXCEL"}/{@code "CSV"}),供提取器匹配与持久化序列化—— 实现固定,无需额外枚举。 + * + *

    三个实现都在本包内:classpath(unnamed module)下 sealed 类型的许可子类必须与之同包。 + */ +public sealed interface DataSourceType + permits CsvDataSourceType, ExcelDataSourceType, DbDataSourceType { + + /** 类型判别串:"DB"/"EXCEL"/"CSV"。 */ + String type(); + + /** + * 由判别串 + 配置 Map 还原类型对象(持久化/DTO 入站用)。 + * + *

    过渡期:类型级配置(jarFile/driverClass/storagePath)与连接实例级配置共存于同一 {@code config} Map, + * 后续 DTO 重构会把类型级配置独立出来。 + */ + static DataSourceType of(String type, Map config) { + Map c = config != null ? config : Map.of(); + return switch (type) { + case "CSV" -> new CsvDataSourceType(str(c, "storagePath")); + case "EXCEL" -> new ExcelDataSourceType(str(c, "storagePath")); + case "DB" -> new DbDataSourceType(str(c, "jarFile"), str(c, "driverClass")); + default -> throw new IllegalArgumentException("未知数据源类型: " + type); + }; + } + + private static String str(Map config, String key) { + Object v = config.get(key); + return v != null ? v.toString() : null; + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DbDataSourceType.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DbDataSourceType.java new file mode 100644 index 0000000..8a741e2 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/DbDataSourceType.java @@ -0,0 +1,15 @@ +package com.codingapi.report.data.datasource.type; + +/** + * 关系型数据库数据源类型。 + * + *

    {@code jarFile} 为上传的驱动 jar(落在系统配置的 driver 目录下),{@code driverClass} 为从 jar 解析后由用户选定的驱动类。 + * 连接实例级的 url/账号/密码/schema 放在 {@code DataSource.config}。 + */ +public record DbDataSourceType(String jarFile, String driverClass) implements DataSourceType { + + @Override + public String type() { + return "DB"; + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/ExcelDataSourceType.java b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/ExcelDataSourceType.java new file mode 100644 index 0000000..cf42fe5 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/datasource/type/ExcelDataSourceType.java @@ -0,0 +1,15 @@ +package com.codingapi.report.data.datasource.type; + +/** + * Excel 数据源类型。 + * + *

    {@code storagePath} 为上传 Excel 文件的存储路径;具体文件名/sheet 由 {@code DataSource.config} 与 {@code + * TableDataset.sourceTable} 描述。 + */ +public record ExcelDataSourceType(String storagePath) implements DataSourceType { + + @Override + public String type() { + return "EXCEL"; + } +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/data/relation/Relationship.java b/report-engine-framework/src/main/java/com/codingapi/report/data/relation/Relationship.java index d2f0e6a..987785f 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/data/relation/Relationship.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/data/relation/Relationship.java @@ -14,7 +14,7 @@ * *

      *
    • 通用关系 → 挂在 {@link DataModel#getRelationships()},所有引用该模型的报表共享 - *
    • 报表专有关系 → 挂在 {@link com.codingapi.report.render.Report#getExtraRelationships()}, 仅本报表可见 + *
    • 报表专有关系 → 挂在 {@link com.codingapi.report.core.Report#getExtraRelationships()}, 仅本报表可见 *
    * *

    它不是 SQL JOIN

    diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataModelDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataModelDTO.java new file mode 100644 index 0000000..fce0167 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataModelDTO.java @@ -0,0 +1,17 @@ +package com.codingapi.report.dto.datamodel; + +import java.util.List; + +/** + * 数据模型出入站契约(GET 返回 / POST 保存)。{@code status} 存枚举名;{@code datasources} 为前端展示用的连接视图 + * (由各 TableDataset 自带的连接去重收集,出口脱敏)。 + */ +public record DataModelDTO( + String id, + String name, + String status, + long createTime, + long updateTime, + List datasources, + List datasets, + List relationships) {} \ No newline at end of file diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataSourceDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataSourceDTO.java new file mode 100644 index 0000000..62c2f9e --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DataSourceDTO.java @@ -0,0 +1,6 @@ +package com.codingapi.report.dto.datamodel; + +import java.util.Map; + +/** 数据源(连接)持久化契约。{@code config} 含加密后的敏感字段。 */ +public record DataSourceDTO(String id, String name, String type, Map config) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DatasetDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DatasetDTO.java new file mode 100644 index 0000000..ede8997 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/DatasetDTO.java @@ -0,0 +1,20 @@ +package com.codingapi.report.dto.datamodel; + +import java.util.List; + +/** + * 数据集持久化契约。用 {@code kind} 区分两种形态(sealed {@code Dataset} 的扁平表达): + * + *
      + *
    • {@code "TABLE"}:物理表,用 {@code datasourceId}/{@code sourceTable}/{@code fields} + *
    • {@code "UNION"}:UNION 派生,用 {@code fields}/{@code members}(无 datasourceId/sourceTable) + *
    + */ +public record DatasetDTO( + String id, + String alias, + String kind, + String datasourceId, + String sourceTable, + List fields, + List members) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/FieldDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/FieldDTO.java new file mode 100644 index 0000000..185c724 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/FieldDTO.java @@ -0,0 +1,4 @@ +package com.codingapi.report.dto.datamodel; + +/** 字段定义持久化契约。 */ +public record FieldDTO(String name, String alias, String dataType, boolean primaryKey) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/RelationshipDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/RelationshipDTO.java new file mode 100644 index 0000000..8f1ec5d --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/RelationshipDTO.java @@ -0,0 +1,11 @@ +package com.codingapi.report.dto.datamodel; + +import com.codingapi.report.dto.report.FieldRefDTO; + +/** 跨数据集关系持久化契约。{@code left}/{@code right} 复用 {@link FieldRefDTO}。 */ +public record RelationshipDTO( + String id, + FieldRefDTO left, + FieldRefDTO right, + String joinType, + String origin) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/UnionMemberDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/UnionMemberDTO.java new file mode 100644 index 0000000..47db885 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/datamodel/UnionMemberDTO.java @@ -0,0 +1,6 @@ +package com.codingapi.report.dto.datamodel; + +import java.util.Map; + +/** UNION 成员契约:{@code mapping} 为"统一列名 → 成员实际字段名"。 */ +public record UnionMemberDTO(String datasetId, Map mapping) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/BindingDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/BindingDTO.java new file mode 100644 index 0000000..23f07d8 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/BindingDTO.java @@ -0,0 +1,17 @@ +package com.codingapi.report.dto.report; + + +import java.util.List; + +public record BindingDTO( + String cellKey, + ValueDTO value, + String expansion, + String expandMode, + boolean mergeRepeated, + String parentCell, + List conditions, + boolean independent, + String preview, + boolean drillEnabled, + String drillView) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ConditionDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ConditionDTO.java new file mode 100644 index 0000000..04eee31 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ConditionDTO.java @@ -0,0 +1,3 @@ +package com.codingapi.report.dto.report; + +public record ConditionDTO(String id, ValueDTO left, String operator, ValueDTO right) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/FieldRefDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/FieldRefDTO.java new file mode 100644 index 0000000..08464ce --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/FieldRefDTO.java @@ -0,0 +1,5 @@ +package com.codingapi.report.dto.report; + +public record FieldRefDTO(String datasetId, String field) { + +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/LoopBlockDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/LoopBlockDTO.java new file mode 100644 index 0000000..d1f74f5 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/LoopBlockDTO.java @@ -0,0 +1,11 @@ +package com.codingapi.report.dto.report; + +public record LoopBlockDTO( + String id, + String label, + String sheetId, + int startRow, + int startColumn, + int endRow, + int endColumn, + SourceDTO source) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/config/dto/ReportParam.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ParamDTO.java similarity index 91% rename from report-engine-framework/src/main/java/com/codingapi/report/config/dto/ReportParam.java rename to report-engine-framework/src/main/java/com/codingapi/report/dto/report/ParamDTO.java index 9f74aaa..3275acd 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/config/dto/ReportParam.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ParamDTO.java @@ -1,4 +1,4 @@ -package com.codingapi.report.config.dto; +package com.codingapi.report.dto.report; import lombok.Data; @@ -9,7 +9,7 @@ * sealed (后者无 Jackson 注解,不便于持久化)。dataType 用 String 存储枚举名。 */ @Data -public class ReportParam { +public class ParamDTO { private String id; diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/PartDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/PartDTO.java new file mode 100644 index 0000000..fbc17e6 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/PartDTO.java @@ -0,0 +1,3 @@ +package com.codingapi.report.dto.report; + +public record PartDTO(String kind, String text, ValueDTO value) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ReportDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ReportDTO.java new file mode 100644 index 0000000..448a728 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ReportDTO.java @@ -0,0 +1,48 @@ +package com.codingapi.report.dto.report; + +import com.codingapi.report.excel.pojo.Workbook; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.Data; + +/** + * 报表出入站契约(GET 返回 / POST 保存)。纯 DTO——前端 JSON ↔ 领域 {@code core.Report}(经 {@code + * RenderDtoConverter.fromDTO/toDTO} 互转)。 + * + *

    {@code Value} 等 sealed interface 未加 Jackson 多态注解,故配置内容用 {@link ReportDtos} 的 record 承接。 {@code + * dataModel} 为响应富化字段:仅 {@code GET /api/report/configs/{id}} 返回时由 starter 填充,不持久化({@link + * JsonInclude.Include#NON_NULL} 省略空值)。 + * + *

    注意:前端展示用字段({@code BindingDTO.preview}、{@code ReportParam.id} 等)不进领域对象、不持久化,由前端按需重建。 + */ +@Data +public class ReportDTO { + + private String id; + + private String name; + + /** 引用的数据模型 id */ + private String dataModelId; + + /** 创建时间(epoch 毫秒) */ + private long createTime; + + /** 修改时间(epoch 毫秒) */ + private long updateTime; + + private List cellBindings; + + private List loopBlocks; + + private List summaries; + + private List params; + + /** 模板工作簿快照(Jackson 友好的 Excel POJO) */ + private Workbook template; + + /** 响应富化字段:仅加载配置时填充,不持久化 */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private Object dataModel; +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SourceDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SourceDTO.java new file mode 100644 index 0000000..d5ca851 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SourceDTO.java @@ -0,0 +1,10 @@ +package com.codingapi.report.dto.report; + + +import java.util.List; + +public record SourceDTO( + String datasetId, + List filters, + List groupBy, + List orderBy) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryCellDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryCellDTO.java new file mode 100644 index 0000000..1d3cec6 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryCellDTO.java @@ -0,0 +1,11 @@ +package com.codingapi.report.dto.report; + +public record SummaryCellDTO( + int crossPos, + ValueDTO value, + String kind, + String payload, + String aggregation, + String preview, + boolean drillEnabled, + String drillView) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryRowDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryRowDTO.java new file mode 100644 index 0000000..f79e86e --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/SummaryRowDTO.java @@ -0,0 +1,17 @@ +package com.codingapi.report.dto.report; + + +import java.util.List; + +/** + * 汇总持久化契约。{@code axis} 为 "VERTICAL"/"HORIZONTAL",null 视为 VERTICAL(向后兼容)。 坐标按轴转置:{@code mainPos} + * 主轴声明位置(纵向=行/横向=列)、{@code crossFrom/crossTo} 交叉区间(纵向=列/横向=行)。 + */ +public record SummaryRowDTO( + String id, + String axis, + FieldRefDTO groupBy, + int crossFrom, + int crossTo, + List cells, + Integer mainPos) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ValueDTO.java b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ValueDTO.java new file mode 100644 index 0000000..ae8f26c --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/dto/report/ValueDTO.java @@ -0,0 +1,13 @@ +package com.codingapi.report.dto.report; + + +import java.util.List; + +public record ValueDTO( + String type, + String payload, + String aggregation, + ValueDTO operand, + String funcName, + List args, + List parts) {} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/param/ParamSource.java b/report-engine-framework/src/main/java/com/codingapi/report/param/ParamSource.java index 2456842..984d1e0 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/param/ParamSource.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/param/ParamSource.java @@ -1,6 +1,6 @@ package com.codingapi.report.param; -import com.codingapi.report.render.grid.CellRef; +import com.codingapi.report.core.grid.CellRef; /** * 参数来源(密封接口):决定一个 {@link Parameter} 的"值从哪来、什么时候绑定"。 diff --git a/report-engine-framework/src/main/java/com/codingapi/report/repository/DataModelRepository.java b/report-engine-framework/src/main/java/com/codingapi/report/repository/DataModelRepository.java new file mode 100644 index 0000000..94f2220 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/repository/DataModelRepository.java @@ -0,0 +1,26 @@ +package com.codingapi.report.repository; + +import com.codingapi.report.data.datamodel.DataModel; + +/** + * 数据模型仓库:以领域对象 {@link DataModel} 存取可复用的数据模型。 + * + *

    framework 层的存储抽象扩展点,分页用 {@link PageQuery}/{@link PageResult},不依赖任何 Spring 类型,保持 framework + * 可独立发布。由使用方提供实现(example 提供内存实现作为演示;生产环境由使用方提供持久化实现,落盘加密等亦在该层处理)。 + * + *

    与 {@link ReportRepository} 同范式:报表只存 {@code dataModelId} 引用,数据模型本身独立存取、多处复用。 + */ +public interface DataModelRepository { + + /** 保存(无 id 则生成),返回数据模型 id。 */ + String save(DataModel dataModel); + + /** 按 id 加载,不存在返回 null。 */ + DataModel find(String id); + + /** 分页查询数据模型(按 {@link PageQuery})。 */ + PageResult page(PageQuery query); + + /** 删除指定数据模型。 */ + void delete(String id); +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/repository/ReportRepository.java b/report-engine-framework/src/main/java/com/codingapi/report/repository/ReportRepository.java index 91803f3..6c14ddb 100644 --- a/report-engine-framework/src/main/java/com/codingapi/report/repository/ReportRepository.java +++ b/report-engine-framework/src/main/java/com/codingapi/report/repository/ReportRepository.java @@ -1,24 +1,24 @@ package com.codingapi.report.repository; -import com.codingapi.report.config.ReportConfig; +import com.codingapi.report.core.Report; /** - * 报表配置仓库:以强类型 {@link ReportConfig} 实体存取报表配置。 + * 报表仓库:以领域对象 {@link Report} 存取报表。 * - *

    framework 层的存储抽象扩展点,分页用 {@link PageQuery}/{@link PageResult}, 不依赖任何 Spring 类型,保持 framework - * 可独立发布。由使用方提供实现 (example 提供内存实现作为演示;生产环境由使用方提供持久化实现)。 + *

    framework 层的存储抽象扩展点,分页用 {@link PageQuery}/{@link PageResult},不依赖任何 Spring 类型,保持 framework + * 可独立发布。由使用方提供实现(example 提供内存实现作为演示;生产环境由使用方提供持久化实现)。 */ public interface ReportRepository { /** 保存(无 id 则生成),返回报表 id。 */ - String save(ReportConfig report); + String save(Report report); - /** 按 id 加载完整配置,不存在返回 null。 */ - ReportConfig find(String id); + /** 按 id 加载,不存在返回 null。 */ + Report find(String id); /** 分页查询报表(按 {@link PageQuery})。 */ - PageResult page(PageQuery query); + PageResult page(PageQuery query); - /** 删除指定报表配置。 */ + /** 删除指定报表。 */ void delete(String id); } diff --git a/report-engine-framework/src/test/java/com/codingapi/report/core/ReportDtoRoundTripTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/ReportDtoRoundTripTest.java new file mode 100644 index 0000000..71c467e --- /dev/null +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/ReportDtoRoundTripTest.java @@ -0,0 +1,188 @@ +package com.codingapi.report.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.codingapi.report.dto.report.BindingDTO; +import com.codingapi.report.dto.report.ConditionDTO; +import com.codingapi.report.dto.report.PartDTO; +import com.codingapi.report.dto.report.SummaryCellDTO; +import com.codingapi.report.dto.report.SummaryRowDTO; +import com.codingapi.report.dto.report.ValueDTO; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.dto.report.ParamDTO; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.expression.Value; +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * {@code ReportDTO} → {@code Report.fromDTO} → {@code Report.toDTO} 往返保真。 + * + *

    验证 sealed {@code Value} 树(含 Template/Aggregate/FunctionCall 嵌套)、单元格控制层、条件、汇总、参数 不在领域往返中丢失语义。前端展示字段(preview/param.id/summary.id)按设计不持久化。 + */ +class ReportDtoRoundTripTest { + + @Test + void roundTripPreservesSemantics() { + // 值表达式:Template([Text, Hole(Aggregate(SUM, FieldValue d.amount))]) + ValueDTO agg = + new ValueDTO( + "Aggregate", + null, + "SUM", + new ValueDTO("FieldValue", "d.amount", null, null, null, null, null), + null, + null, + null); + ValueDTO template = + new ValueDTO( + "Template", + null, + null, + null, + null, + null, + List.of( + new PartDTO("text", "合计 ", null), + new PartDTO("hole", null, agg))); + + BindingDTO binding = + new BindingDTO( + "sheet1:2:1", + template, + "VERTICAL", + "LIST", + true, + "sheet1:1:0", + List.of( + new ConditionDTO( + null, + new ValueDTO( + "FieldValue", "d.dept", null, null, null, null, + null), + "EQ", + new ValueDTO( + "ParamValue", "deptId", null, null, null, null, + null))), + false, + "设计期预览文本", + true, + "d"); + + SummaryRowDTO summary = + new SummaryRowDTO( + "sumRow-1", + "VERTICAL", + null, + 0, + 1, + List.of( + new SummaryCellDTO( + 1, + new ValueDTO( + "Aggregate", + null, + "SUM", + new ValueDTO( + "FieldValue", "d.amount", null, null, null, + null, null), + null, + null, + null), + null, + null, + null, + null, + true, + "d")), + 0); + + ParamDTO param = new ParamDTO(); + param.setId("p1"); + param.setName("deptId"); + param.setAlias("部门"); + param.setDataType("NUMBER"); + param.setDefaultValue("5"); + + ReportDTO dto = new ReportDTO(); + dto.setName("测试报表"); + dto.setDataModelId("default"); + dto.setCellBindings(List.of(binding)); + dto.setSummaries(List.of(summary)); + dto.setParams(List.of(param)); + + // 往返 + ReportDTO back = Report.fromDTO(dto).toDTO(); + + assertEquals("测试报表", back.getName()); + assertEquals("default", back.getDataModelId()); + + // 单元格控制层 + 值树 + BindingDTO b = back.getCellBindings().get(0); + assertEquals("sheet1:2:1", b.cellKey()); + assertEquals("VERTICAL", b.expansion()); + assertEquals("LIST", b.expandMode()); + assertEquals(true, b.mergeRepeated()); + assertEquals("sheet1:1:0", b.parentCell()); + assertEquals(true, b.drillEnabled()); + assertEquals("d", b.drillView()); + assertNull(b.preview(), "preview 前端字段不持久化"); + + // 值树:Template → Hole(Aggregate(FieldValue)) + assertEquals("Template", b.value().type()); + assertEquals("合计 ", b.value().parts().get(0).text()); + ValueDTO hole = b.value().parts().get(1).value(); + assertEquals("Aggregate", hole.type()); + assertEquals("SUM", hole.aggregation()); + assertEquals("d.amount", hole.operand().payload()); + + // 条件 + ConditionDTO c = b.conditions().get(0); + assertEquals("d.dept", c.left().payload()); + assertEquals("EQ", c.operator()); + assertEquals("deptId", c.right().payload()); + + // 汇总 + SummaryRowDTO s = back.getSummaries().get(0); + assertEquals("VERTICAL", s.axis()); + assertEquals("SUM", s.cells().get(0).value().aggregation()); + assertEquals("d", s.cells().get(0).drillView()); + + // 参数(语义保真;id 前端字段不持久化) + ParamDTO rp = back.getParams().get(0); + assertEquals("deptId", rp.getName()); + assertEquals("部门", rp.getAlias()); + assertEquals("NUMBER", rp.getDataType()); + assertEquals("5", rp.getDefaultValue()); + assertNull(rp.getId(), "param.id 前端字段不持久化"); + } + + @Test + void literalAndFieldValueRoundTrip() { + Value lit = RenderDtoConverter.convertValue(new ValueDTO("Literal", "hello", null, null, null, null, null)); + assertInstanceOf(Value.Literal.class, lit); + assertEquals("hello", RenderDtoConverter.toValueDto(lit).payload()); + + CellBinding fieldCell = + RenderDtoConverter.convertBindings( + List.of( + new BindingDTO( + "sheet1:0:0", + new ValueDTO( + "FieldValue", "ds.name", null, null, null, + null, null), + "NONE", + null, + false, + null, + List.of(), + false, + null, + false, + null))) + .get(0); + assertEquals("ds.name", RenderDtoConverter.toValueDto(fieldCell.getValue()).payload()); + } +} diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/FullChainTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/FullChainTest.java similarity index 93% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/FullChainTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/FullChainTest.java index 348ead0..941e015 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/FullChainTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/FullChainTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -11,8 +11,8 @@ import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; import com.codingapi.report.data.relation.JoinType; import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; @@ -28,11 +28,11 @@ import com.codingapi.report.param.ParamContext; import com.codingapi.report.param.ParamSource; import com.codingapi.report.param.Parameter; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; import java.util.List; import java.util.Map; import org.junit.jupiter.api.DisplayName; @@ -90,21 +90,21 @@ private static DataModel scoreDataModel() { DataSource.builder() .id("ds_student") .name("学生CSV") - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", "/data/students.csv")) .build(); DataSource scoreSrc = DataSource.builder() .id("ds_score") .name("成绩CSV") - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", "/data/scores.csv")) .build(); Dataset student = TableDataset.builder() .id("d_student") - .datasourceId("ds_student") + .datasource(studentSrc).datasourceId("ds_student") .sourceTable("students.csv") .alias("学生") .fields( @@ -126,7 +126,7 @@ private static DataModel scoreDataModel() { Dataset score = TableDataset.builder() .id("d_score") - .datasourceId("ds_score") + .datasource(scoreSrc).datasourceId("ds_score") .sourceTable("scores.csv") .alias("成绩") .fields( @@ -154,7 +154,6 @@ private static DataModel scoreDataModel() { return DataModel.builder() .id("dm_score") .name("学生成绩模型") - .datasources(List.of(studentSrc, scoreSrc)) .datasets(List.of(student, score)) .relationships(List.of(rel)) .build(); diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/MergeBorderDimensionTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/MergeBorderDimensionTest.java similarity index 94% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/MergeBorderDimensionTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/MergeBorderDimensionTest.java index 5995079..74c60b5 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/MergeBorderDimensionTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/MergeBorderDimensionTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -10,8 +10,8 @@ import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; import com.codingapi.report.excel.ExcelExporter; import com.codingapi.report.excel.ExcelImporter; import com.codingapi.report.excel.pojo.Border; @@ -25,13 +25,13 @@ import com.codingapi.report.excel.pojo.Workbook; import com.codingapi.report.expression.Value; import com.codingapi.report.param.ParamContext; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; -import com.codingapi.report.render.grid.SummaryCell; -import com.codingapi.report.render.grid.SummaryRow; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; +import com.codingapi.report.core.grid.SummaryCell; +import com.codingapi.report.core.grid.SummaryRow; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.util.ArrayList; import java.util.List; @@ -58,13 +58,13 @@ void mergeBordersAndDimensionsSurvive() throws Exception { DataSource.builder() .id("ds_a") .name("a") - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", "/data/dept_a.csv")) .build(); Dataset a = TableDataset.builder() .id("dept_a") - .datasourceId("ds_a") + .datasource(src).datasourceId("ds_a") .sourceTable("dept_a.csv") .fields( List.of( @@ -85,7 +85,6 @@ void mergeBordersAndDimensionsSurvive() throws Exception { DataModel.builder() .id("default") .name("m") - .datasources(List.of(src)) .datasets(List.of(a)) .relationships(List.of()) .build(); diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/OperatorsTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/OperatorsTest.java similarity index 99% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/OperatorsTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/OperatorsTest.java index 1a2619f..3b6243e 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/OperatorsTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/OperatorsTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.*; diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportParamTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/ParamDTOTest.java similarity index 95% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportParamTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/ParamDTOTest.java index 227a1d5..ebae159 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportParamTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/ParamDTOTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -12,8 +12,8 @@ import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; import com.codingapi.report.excel.ExcelExporter; import com.codingapi.report.excel.ExcelImporter; import com.codingapi.report.excel.pojo.Cell; @@ -26,11 +26,11 @@ import com.codingapi.report.param.ParamContext; import com.codingapi.report.param.ParamSource; import com.codingapi.report.param.Parameter; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -49,7 +49,7 @@ * * 文件输出到 {@code target/reports/param-*.xlsx}。 */ -class ReportParamTest { +class ParamDTOTest { private final ReportRenderer renderer = new ReportRenderer(List.of(new CsvDataExtractor())); @@ -65,7 +65,7 @@ void paramDisplay() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -82,7 +82,6 @@ void paramDisplay() throws Exception { DataModel.builder() .id("dm_param_display") .name("参数显示测试") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -159,7 +158,7 @@ void paramFilter() throws Exception { Dataset emp = TableDataset.builder() .id("d_emp") - .datasourceId("ds_emp") + .datasource(src).datasourceId("ds_emp") .sourceTable("employees.csv") .fields( List.of( @@ -180,7 +179,6 @@ void paramFilter() throws Exception { DataModel.builder() .id("dm_param_filter") .name("参数过滤测试") - .datasources(List.of(src)) .datasets(List.of(emp)) .relationships(List.of()) .build(); @@ -256,7 +254,7 @@ void paramMissing() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -273,7 +271,6 @@ void paramMissing() throws Exception { DataModel.builder() .id("dm_param_missing") .name("参数缺失测试") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -324,7 +321,7 @@ private static DataSource csv(String id, String path) { return DataSource.builder() .id(id) .name(id) - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", path)) .build(); } diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportScenarioTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/ReportScenarioTest.java similarity index 97% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportScenarioTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/ReportScenarioTest.java index 2a10bba..bab7d89 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/ReportScenarioTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/ReportScenarioTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -15,8 +15,8 @@ import com.codingapi.report.data.dataset.UnionDataset; import com.codingapi.report.data.dataset.UnionMember; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; import com.codingapi.report.data.relation.JoinType; import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; @@ -32,15 +32,15 @@ import com.codingapi.report.operator.condition.Condition; import com.codingapi.report.param.ParamContext; import com.codingapi.report.param.Parameter; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.Axis; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; -import com.codingapi.report.render.grid.LoopBlock; -import com.codingapi.report.render.grid.SummaryCell; -import com.codingapi.report.render.grid.SummaryRow; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.Axis; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; +import com.codingapi.report.core.grid.LoopBlock; +import com.codingapi.report.core.grid.SummaryCell; +import com.codingapi.report.core.grid.SummaryRow; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -75,7 +75,7 @@ void simpleList() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -92,7 +92,6 @@ void simpleList() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -148,7 +147,7 @@ void mergedList() throws Exception { Dataset sales = TableDataset.builder() .id("d_sales") - .datasourceId("ds_sales") + .datasource(src).datasourceId("ds_sales") .sourceTable("sales.csv") .fields( List.of( @@ -169,7 +168,6 @@ void mergedList() throws Exception { DataModel.builder() .id("dm_sales") .name("销售模型") - .datasources(List.of(src)) .datasets(List.of(sales)) .relationships(List.of()) .build(); @@ -221,7 +219,7 @@ void statistics() throws Exception { Dataset staff = TableDataset.builder() .id("d_staff") - .datasourceId("ds_staff") + .datasource(src).datasourceId("ds_staff") .sourceTable("staff.csv") .fields( List.of( @@ -242,7 +240,6 @@ void statistics() throws Exception { DataModel.builder() .id("dm_staff") .name("人员模型") - .datasources(List.of(src)) .datasets(List.of(staff)) .relationships(List.of()) .build(); @@ -334,7 +331,7 @@ void payslipLoop() throws Exception { Dataset emp = TableDataset.builder() .id("d_emp") - .datasourceId("ds_emp") + .datasource(empSrc).datasourceId("ds_emp") .sourceTable("employees.csv") .fields( List.of( @@ -355,7 +352,7 @@ void payslipLoop() throws Exception { Dataset sal = TableDataset.builder() .id("d_sal") - .datasourceId("ds_sal") + .datasource(salSrc).datasourceId("ds_sal") .sourceTable("salaries.csv") .fields( List.of( @@ -388,7 +385,6 @@ void payslipLoop() throws Exception { DataModel.builder() .id("dm_pay") .name("薪资模型") - .datasources(List.of(empSrc, salSrc)) .datasets(List.of(emp, sal)) .relationships(List.of(rel)) .build(); @@ -472,7 +468,7 @@ void masterDetailMergedList() throws Exception { Dataset emp = TableDataset.builder() .id("d_emp2") - .datasourceId("ds_emp2") + .datasource(empSrc).datasourceId("ds_emp2") .sourceTable("emp_basic.csv") .fields( List.of( @@ -497,7 +493,7 @@ void masterDetailMergedList() throws Exception { Dataset edu = TableDataset.builder() .id("d_edu") - .datasourceId("ds_edu") + .datasource(eduSrc).datasourceId("ds_edu") .sourceTable("emp_education.csv") .fields( List.of( @@ -531,7 +527,6 @@ void masterDetailMergedList() throws Exception { DataModel.builder() .id("dm_edu") .name("员工学历模型") - .datasources(List.of(empSrc, eduSrc)) .datasets(List.of(emp, edu)) .relationships(List.of(rel)) .build(); @@ -601,7 +596,7 @@ void salarySubtotalAndGrandTotal() throws Exception { Dataset sal = TableDataset.builder() .id("d_sd") - .datasourceId("ds_sal_detail") + .datasource(src).datasourceId("ds_sal_detail") .sourceTable("salary_detail.csv") .fields( List.of( @@ -626,7 +621,6 @@ void salarySubtotalAndGrandTotal() throws Exception { DataModel.builder() .id("dm_sd") .name("薪资明细模型") - .datasources(List.of(src)) .datasets(List.of(sal)) .relationships(List.of()) .build(); @@ -721,7 +715,7 @@ void unionTwoDepartments() throws Exception { Dataset a = TableDataset.builder() .id("d_a") - .datasourceId("ds_a") + .datasource(aSrc).datasourceId("ds_a") .sourceTable("dept_a.csv") .fields( List.of( @@ -742,7 +736,7 @@ void unionTwoDepartments() throws Exception { Dataset b = TableDataset.builder() .id("d_b") - .datasourceId("ds_b") + .datasource(bSrc).datasourceId("ds_b") .sourceTable("dept_b.csv") .fields( List.of( @@ -790,7 +784,6 @@ void unionTwoDepartments() throws Exception { DataModel.builder() .id("dm_people") .name("人员合集模型") - .datasources(List.of(aSrc, bSrc)) .datasets(List.of(a, b, people)) .relationships(List.of()) .build(); @@ -838,7 +831,7 @@ void independentBands() throws Exception { Dataset staff = TableDataset.builder() .id("d_staff") - .datasourceId("ds_staff") + .datasource(srcStaff).datasourceId("ds_staff") .sourceTable("staff.csv") .fields( List.of( @@ -855,7 +848,7 @@ void independentBands() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(srcProd).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -872,7 +865,6 @@ void independentBands() throws Exception { DataModel.builder() .id("dm_indep") .name("独立模型") - .datasources(List.of(srcStaff, srcProd)) .datasets(List.of(staff, prod)) .relationships(List.of()) // 无关系 .build(); @@ -959,7 +951,7 @@ void independentBandsEachWithSummary() throws Exception { Dataset staff = TableDataset.builder() .id("d_staff") - .datasourceId("ds_staff") + .datasource(srcStaff).datasourceId("ds_staff") .sourceTable("staff.csv") .fields( List.of( @@ -976,7 +968,7 @@ void independentBandsEachWithSummary() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(srcProd).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -993,7 +985,6 @@ void independentBandsEachWithSummary() throws Exception { DataModel.builder() .id("dm_indep2") .name("独立模型-双汇总") - .datasources(List.of(srcStaff, srcProd)) .datasets(List.of(staff, prod)) .relationships(List.of()) .build(); @@ -1086,7 +1077,7 @@ void templateMergeShiftedAfterBandExpansion() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1103,7 +1094,6 @@ void templateMergeShiftedAfterBandExpansion() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1186,7 +1176,7 @@ void summaryTemplateMergeFollowsSummary() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1203,7 +1193,6 @@ void summaryTemplateMergeFollowsSummary() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1286,7 +1275,7 @@ void staticFooterFollowsBandExpansion() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1303,7 +1292,6 @@ void staticFooterFollowsBandExpansion() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1369,7 +1357,7 @@ void bandDeclCellsAtDifferentRowsAlignAndNoStalePlaceholder() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1386,7 +1374,6 @@ void bandDeclCellsAtDifferentRowsAlignAndNoStalePlaceholder() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1427,7 +1414,7 @@ void explicitIndependentBandStaggers() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1444,7 +1431,6 @@ void explicitIndependentBandStaggers() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1489,7 +1475,7 @@ void horizontalBand() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1506,7 +1492,6 @@ void horizontalBand() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1554,7 +1539,7 @@ void horizontalSummary() throws Exception { Dataset prod = TableDataset.builder() .id("d_prod") - .datasourceId("ds_prod") + .datasource(src).datasourceId("ds_prod") .sourceTable("products.csv") .fields( List.of( @@ -1571,7 +1556,6 @@ void horizontalSummary() throws Exception { DataModel.builder() .id("dm_prod") .name("商品模型") - .datasources(List.of(src)) .datasets(List.of(prod)) .relationships(List.of()) .build(); @@ -1623,7 +1607,7 @@ void horizontalBandGroupMerge() throws Exception { Dataset sales = TableDataset.builder() .id("d_sales") - .datasourceId("ds_sales") + .datasource(src).datasourceId("ds_sales") .sourceTable("sales.csv") .fields( List.of( @@ -1644,7 +1628,6 @@ void horizontalBandGroupMerge() throws Exception { DataModel.builder() .id("dm_sales") .name("销售模型") - .datasources(List.of(src)) .datasets(List.of(sales)) .relationships(List.of()) .build(); @@ -1681,7 +1664,7 @@ void crossTabMatrix() throws Exception { Dataset ms = TableDataset.builder() .id("d_ms") - .datasourceId("ds_ms") + .datasource(src).datasourceId("ds_ms") .sourceTable("matrix_sales.csv") .fields( List.of( @@ -1702,7 +1685,6 @@ void crossTabMatrix() throws Exception { DataModel.builder() .id("dm_ms") .name("交叉表模型") - .datasources(List.of(src)) .datasets(List.of(ms)) .relationships(List.of()) .build(); @@ -1773,7 +1755,7 @@ void crossTabMatrixWithTotals() throws Exception { Dataset ms = TableDataset.builder() .id("d_ms") - .datasourceId("ds_ms") + .datasource(src).datasourceId("ds_ms") .sourceTable("matrix_sales.csv") .fields( List.of( @@ -1794,7 +1776,6 @@ void crossTabMatrixWithTotals() throws Exception { DataModel.builder() .id("dm_ms") .name("交叉表模型") - .datasources(List.of(src)) .datasets(List.of(ms)) .relationships(List.of()) .build(); @@ -1905,7 +1886,7 @@ private static DataSource csv(String id, String path) { return DataSource.builder() .id(id) .name(id) - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", path)) .build(); } diff --git a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/StyleAdaptationTest.java b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/StyleAdaptationTest.java similarity index 95% rename from report-engine-framework/src/test/java/com/codingapi/report/render/engine/StyleAdaptationTest.java rename to report-engine-framework/src/test/java/com/codingapi/report/core/engine/StyleAdaptationTest.java index ea5572f..8a9794e 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/render/engine/StyleAdaptationTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/core/engine/StyleAdaptationTest.java @@ -1,4 +1,4 @@ -package com.codingapi.report.render.engine; +package com.codingapi.report.core.engine; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -11,8 +11,8 @@ import com.codingapi.report.data.dataset.FieldRef; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.type.CsvDataSourceType; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; import com.codingapi.report.excel.ExcelExporter; import com.codingapi.report.excel.ExcelImporter; import com.codingapi.report.excel.pojo.Border; @@ -26,11 +26,11 @@ import com.codingapi.report.excel.pojo.Workbook; import com.codingapi.report.expression.Value; import com.codingapi.report.param.ParamContext; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; import com.fasterxml.jackson.databind.node.JsonNodeFactory; import java.nio.file.Files; import java.nio.file.Path; @@ -112,13 +112,13 @@ private static DataModel staffModel() { DataSource.builder() .id("ds") .name("员工CSV") - .type(DataSourceType.CSV) + .type(new CsvDataSourceType(null)) .config(Map.of("path", "/data/styled_staff.csv")) .build(); Dataset staff = TableDataset.builder() .id("d_staff") - .datasourceId("ds") + .datasource(src).datasourceId("ds") .sourceTable("styled_staff.csv") .fields( List.of( @@ -138,7 +138,6 @@ private static DataModel staffModel() { return DataModel.builder() .id("dm") .name("员工模型") - .datasources(List.of(src)) .datasets(List.of(staff)) .relationships(List.of()) .build(); diff --git a/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/DataModelDtoRoundTripTest.java b/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/DataModelDtoRoundTripTest.java new file mode 100644 index 0000000..2f3851b --- /dev/null +++ b/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/DataModelDtoRoundTripTest.java @@ -0,0 +1,101 @@ +package com.codingapi.report.data.datamodel; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codingapi.report.dto.datamodel.DataModelDTO; +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.dataset.FieldRef; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.dataset.UnionDataset; +import com.codingapi.report.data.dataset.UnionMember; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.type.DbDataSourceType; +import com.codingapi.report.data.relation.JoinType; +import com.codingapi.report.data.relation.RelationOrigin; +import com.codingapi.report.data.relation.Relationship; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** {@code DataModel.toDTO()} ↔ {@code DataModel.fromDTO()} 往返保真,连接由 TableDataset 自带、去重收集。 */ +class DataModelDtoRoundTripTest { + + @Test + void roundTripPreservesSemantics() { + DataSource source = + DataSource.builder() + .id("ds") + .name("db") + .type(new DbDataSourceType(null, null)) + .config(Map.of("url", "jdbc:h2:mem:x", "password", "s3cret")) + .build(); + TableDataset emp = + TableDataset.builder() + .id("emp") + .datasource(source) + .datasourceId("ds") + .sourceTable("EMP") + .alias("员工") + .fields( + List.of( + Field.builder() + .name("id") + .dataType(DataType.NUMBER) + .primaryKey(true) + .build())) + .build(); + UnionDataset all = + UnionDataset.builder() + .id("all") + .alias("全员") + .fields(List.of(Field.builder().name("name").dataType(DataType.STRING).build())) + .members(List.of(new UnionMember("emp", Map.of("name", "id")))) + .build(); + DataModel dm = + DataModel.builder() + .id("m1") + .name("模型") + .status(DataModelStatus.PUBLISHED) + .datasets(List.of(emp, all)) + .relationships( + List.of( + Relationship.builder() + .id("r1") + .left(new FieldRef("emp", "id")) + .right(new FieldRef("all", "name")) + .joinType(JoinType.LEFT) + .origin(RelationOrigin.MANUAL) + .build())) + .build(); + + DataModelDTO dto = dm.toDTO(); + // 连接由 TableDataset 去重收集 + assertEquals(1, dto.datasources().size()); + assertEquals("DB", dto.datasources().get(0).type()); + // 明文配置(落盘加密交使用方仓库) + assertEquals("s3cret", dto.datasources().get(0).config().get("password")); + assertEquals("PUBLISHED", dto.status()); + assertEquals("TABLE", dto.datasets().get(0).kind()); + assertEquals("UNION", dto.datasets().get(1).kind()); + assertEquals("LEFT", dto.relationships().get(0).joinType()); + + // 还原 + DataModel back = DataModel.fromDTO(dto); + assertEquals("m1", back.getId()); + assertEquals(DataModelStatus.PUBLISHED, back.getStatus()); + assertEquals(2, back.getDatasets().size()); + assertTrue(back.getDatasets().get(0) instanceof TableDataset); + assertTrue(back.getDatasets().get(1) instanceof UnionDataset); + TableDataset backEmp = (TableDataset) back.getDatasets().get(0); + assertEquals("s3cret", backEmp.getDatasource().getConfig().get("password")); + assertEquals(JoinType.LEFT, back.getRelationships().get(0).getJoinType()); + } + + @Test + void fromDtoNull() { + assertNull(DataModel.fromDTO(null)); + } +} diff --git a/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/ReportModelTest.java b/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/ReportModelTest.java index 4e245c5..b70c7a1 100644 --- a/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/ReportModelTest.java +++ b/report-engine-framework/src/test/java/com/codingapi/report/data/datamodel/ReportModelTest.java @@ -14,7 +14,7 @@ import com.codingapi.report.data.dataset.Query; import com.codingapi.report.data.dataset.TableDataset; import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; +import com.codingapi.report.data.datasource.type.DbDataSourceType; import com.codingapi.report.data.relation.JoinType; import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; @@ -24,12 +24,12 @@ import com.codingapi.report.operator.condition.Condition; import com.codingapi.report.param.ParamSource; import com.codingapi.report.param.Parameter; -import com.codingapi.report.render.Report; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; -import com.codingapi.report.render.grid.LoopBlock; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.CellRef; +import com.codingapi.report.core.grid.ExpandMode; +import com.codingapi.report.core.grid.Expansion; +import com.codingapi.report.core.grid.LoopBlock; import java.util.HashSet; import java.util.List; import java.util.Set; @@ -88,8 +88,10 @@ void dataModel_crossSourceRelationship() { TableDataset right = (TableDataset) findDataset(hr, rel.getRight().datasetId()); assertNotEquals(left.getDatasourceId(), right.getDatasourceId(), "薪资库与人事库是两个不同连接"); // 两个连接都是 DB 类型(JDBC),不区分厂商 - assertEquals(DataSourceType.DB, findDatasource(hr, left.getDatasourceId()).getType()); - assertEquals(DataSourceType.DB, findDatasource(hr, right.getDatasourceId()).getType()); + assertEquals( + "DB", findDatasource(hr, left.getDatasourceId()).getType().type()); + assertEquals( + "DB", findDatasource(hr, right.getDatasourceId()).getType().type()); } // ============================================================ @@ -250,14 +252,14 @@ void multiLevelGrouping_parentChainGroupMerge() { /** 人事数据模型:员工(人事库) + 薪资(薪资库) + 跨库关系。被部门报表与薪资条共享。 */ private static DataModel hrDataModel() { DataSource hrDb = - DataSource.builder().id("ds_hr").name("人事库").type(DataSourceType.DB).build(); + DataSource.builder().id("ds_hr").name("人事库").type(new DbDataSourceType(null, null)).build(); DataSource payDb = - DataSource.builder().id("ds_pay").name("薪资库").type(DataSourceType.DB).build(); + DataSource.builder().id("ds_pay").name("薪资库").type(new DbDataSourceType(null, null)).build(); Dataset emp = TableDataset.builder() .id("d_emp") - .datasourceId("ds_hr") + .datasource(hrDb).datasourceId("ds_hr") .sourceTable("employee") .alias("员工") .fields( @@ -288,7 +290,7 @@ private static DataModel hrDataModel() { Dataset salary = TableDataset.builder() .id("d_salary") - .datasourceId("ds_pay") + .datasource(payDb).datasourceId("ds_pay") .sourceTable("salary") .alias("薪资") .fields( @@ -322,7 +324,6 @@ private static DataModel hrDataModel() { return DataModel.builder() .id("dm_hr") .name("人事数据模型") - .datasources(List.of(hrDb, payDb)) .datasets(List.of(emp, salary)) .relationships(List.of(rel)) .build(); @@ -331,11 +332,11 @@ private static DataModel hrDataModel() { /** 教务数据模型:成绩宽表(单数据集,无需关系) */ private static DataModel eduDataModel() { DataSource edu = - DataSource.builder().id("ds_edu").name("教务库").type(DataSourceType.DB).build(); + DataSource.builder().id("ds_edu").name("教务库").type(new DbDataSourceType(null, null)).build(); Dataset score = TableDataset.builder() .id("d_score") - .datasourceId("ds_edu") + .datasource(edu).datasourceId("ds_edu") .sourceTable("score_view") .alias("成绩") .fields( @@ -359,7 +360,6 @@ private static DataModel eduDataModel() { return DataModel.builder() .id("dm_edu") .name("教务数据模型") - .datasources(List.of(edu)) .datasets(List.of(score)) .relationships(List.of()) .build(); @@ -368,11 +368,11 @@ private static DataModel eduDataModel() { /** 统计数据模型:单位/部门/明细 宽表 */ private static DataModel statDataModel() { DataSource db = - DataSource.builder().id("ds_stat").name("统计库").type(DataSourceType.DB).build(); + DataSource.builder().id("ds_stat").name("统计库").type(new DbDataSourceType(null, null)).build(); Dataset stat = TableDataset.builder() .id("d_stat") - .datasourceId("ds_stat") + .datasource(db).datasourceId("ds_stat") .sourceTable("stat_view") .alias("统计") .fields( @@ -401,7 +401,6 @@ private static DataModel statDataModel() { return DataModel.builder() .id("dm_stat") .name("统计数据模型") - .datasources(List.of(db)) .datasets(List.of(stat)) .relationships(List.of()) .build(); @@ -628,8 +627,13 @@ private static Dataset findDataset(DataModel dm, String id) { } private static DataSource findDatasource(DataModel dm, String id) { - return dm.getDatasources().stream() - .filter(d -> d.getId().equals(id)) + return dm.getDatasets().stream() + .filter( + d -> + d instanceof TableDataset t + && t.getDatasource() != null + && id.equals(t.getDatasource().getId())) + .map(d -> ((TableDataset) d).getDatasource()) .findFirst() .orElseThrow(); } diff --git a/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/credential/CredentialServiceTest.java b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/credential/CredentialServiceTest.java new file mode 100644 index 0000000..43aa69c --- /dev/null +++ b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/credential/CredentialServiceTest.java @@ -0,0 +1,62 @@ +package com.codingapi.report.data.datasource.credential; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class CredentialServiceTest { + + private final CredentialService service = new CredentialService("test-key"); + + @Test + void encryptThenDecryptRestoresPlainText() { + Map config = new LinkedHashMap<>(); + config.put("host", "localhost"); + config.put("password", "s3cret"); + + Map encrypted = service.encryptConfig(config); + assertEquals("localhost", encrypted.get("host")); + assertNotEquals("s3cret", encrypted.get("password")); + assertTrue(((String) encrypted.get("password")).startsWith("enc:")); + + Map decrypted = service.decryptConfig(encrypted); + assertEquals("s3cret", decrypted.get("password")); + assertEquals("localhost", decrypted.get("host")); + } + + @Test + void encryptIsIdempotent() { + Map config = Map.of("password", "abc"); + Map once = service.encryptConfig(config); + Map twice = service.encryptConfig(once); + assertEquals(once.get("password"), twice.get("password")); + } + + @Test + void maskReplacesAllSensitiveValues() { + Map config = new LinkedHashMap<>(); + config.put("username", "admin"); + config.put("password", "enc:something"); + config.put("token", "xyz"); + Map masked = service.maskConfig(config); + assertEquals("admin", masked.get("username")); + assertEquals("***", masked.get("password")); + assertEquals("***", masked.get("token")); + } + + @Test + void isMaskedDetectsPlaceholder() { + assertTrue(service.isMasked("***")); + assertFalse(service.isMasked("real")); + } + + @Test + void emptyPasswordNotEncrypted() { + Map config = new LinkedHashMap<>(); + config.put("password", ""); + Map encrypted = service.encryptConfig(config); + assertEquals("", encrypted.get("password")); + } +} diff --git a/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/DbDataExtractorTest.java b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/DbDataExtractorTest.java new file mode 100644 index 0000000..771dd26 --- /dev/null +++ b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/DbDataExtractorTest.java @@ -0,0 +1,124 @@ +package com.codingapi.report.data.datasource.extractor; + +import static org.junit.jupiter.api.Assertions.*; + +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.datasource.ColumnMeta; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.type.DbDataSourceType; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.Statement; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class DbDataExtractorTest { + + private static final String URL = "jdbc:h2:mem:dbextractor;DB_CLOSE_DELAY=-1"; + private final DbDataExtractor extractor = new DbDataExtractor(); + + private DataSource dbSource() { + return DataSource.builder() + .id("ds") + .name("h2") + .type(new DbDataSourceType(null, null)) + .config( + Map.of( + "url", URL, + "driver", "org.h2.Driver", + "username", "sa", + "password", "")) + .build(); + } + + @BeforeAll + static void setup() throws Exception { + try (Connection conn = DriverManager.getConnection(URL, "sa", ""); + Statement stmt = conn.createStatement()) { + stmt.execute( + "CREATE TABLE IF NOT EXISTS TEST_TABLE (ID BIGINT PRIMARY KEY, NAME VARCHAR(100))"); + stmt.execute("MERGE INTO TEST_TABLE VALUES (1, 'alice')"); + stmt.execute("MERGE INTO TEST_TABLE VALUES (2, 'bob')"); + } + } + + @Test + void supportsDbOnly() { + assertTrue(extractor.supports("DB")); + assertFalse(extractor.supports("CSV")); + } + + @Test + void extractReturnsQualifiedRows() { + TableDataset ds = + TableDataset.builder() + .id("t") + .datasourceId("ds") + .sourceTable("TEST_TABLE") + .alias("测试表") + .fields( + List.of( + Field.builder() + .name("ID") + .dataType(DataType.NUMBER) + .primaryKey(true) + .build(), + Field.builder() + .name("NAME") + .dataType(DataType.STRING) + .build())) + .build(); + RawTable raw = extractor.extract(dbSource(), ds); + + assertEquals(List.of("t.ID", "t.NAME"), raw.getColumns()); + assertEquals(2, raw.getRows().size()); + assertEquals(1.0, raw.getRows().get(0).get("t.ID")); + assertEquals("alice", raw.getRows().get(0).get("t.NAME")); + } + + @Test + void testConnectionSucceeds() { + TestResult result = extractor.test(dbSource()); + assertTrue(result.ok(), () -> "连接应成功: " + result.message()); + } + + @Test + void testConnectionFailsOnBadUrl() { + DataSource bad = + DataSource.builder() + .id("ds") + .type(new DbDataSourceType(null, null)) + .config( + Map.of( + "url", + "jdbc:h2:mem:nope;IFEXISTS=TRUE", + "driver", + "org.h2.Driver")) + .build(); + TestResult result = extractor.test(bad); + assertFalse(result.ok()); + } + + @Test + void listTablesIncludesTestTable() { + List tables = extractor.listTables(dbSource()); + assertTrue(tables.contains("TEST_TABLE")); + } + + @Test + void listColumnsMarksPrimaryKey() { + List cols = extractor.listColumns(dbSource(), "TEST_TABLE"); + ColumnMeta idCol = + cols.stream().filter(c -> c.name().equals("ID")).findFirst().orElseThrow(); + assertTrue(idCol.primaryKey()); + ColumnMeta nameCol = + cols.stream().filter(c -> c.name().equals("NAME")).findFirst().orElseThrow(); + assertFalse(nameCol.primaryKey()); + } +} diff --git a/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractorTest.java b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractorTest.java new file mode 100644 index 0000000..ed778d2 --- /dev/null +++ b/report-engine-framework/src/test/java/com/codingapi/report/data/datasource/extractor/ExcelDataExtractorTest.java @@ -0,0 +1,181 @@ +package com.codingapi.report.data.datasource.extractor; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.codingapi.report.data.dataset.DataType; +import com.codingapi.report.data.dataset.Field; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.type.ExcelDataSourceType; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.apache.poi.xssf.usermodel.XSSFRow; +import org.apache.poi.xssf.usermodel.XSSFSheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * 用 POI 在测试启动时动态生成一个小 .xlsx 文件做测试,不引入二进制 test resource。 + * + *

    测试覆盖:{@code supports()}、{@code extract()} 正确解析(含表头映射/类型归一/空值)、 {@code + * test()} 成功与失败路径。 + */ +class ExcelDataExtractorTest { + + private static final ExcelDataExtractor extractor = new ExcelDataExtractor(); + + @TempDir + static Path tempDir; + + private static String xlsxPath; + + @BeforeAll + static void writeXlsx() throws IOException { + xlsxPath = tempDir.resolve("sample.xlsx").toString(); + try (XSSFWorkbook wb = new XSSFWorkbook(); + FileOutputStream out = new FileOutputStream(xlsxPath)) { + XSSFSheet sheet = wb.createSheet("data"); + // 表头 + XSSFRow header = sheet.createRow(0); + header.createCell(0).setCellValue("id"); + header.createCell(1).setCellValue("name"); + header.createCell(2).setCellValue("price"); + header.createCell(3).setCellValue("active"); + // 数据行 1 + XSSFRow r1 = sheet.createRow(1); + r1.createCell(0).setCellValue(1); + r1.createCell(1).setCellValue("alice"); + r1.createCell(2).setCellValue(10.5); + r1.createCell(3).setCellValue(true); + // 数据行 2 + XSSFRow r2 = sheet.createRow(2); + r2.createCell(0).setCellValue(2); + r2.createCell(1).setCellValue("bob"); + r2.createCell(2).setCellValue(20.0); + r2.createCell(3).setCellValue(false); + wb.write(out); + } + } + + @AfterAll + static void cleanup() { + // @TempDir 自动清理 + } + + private DataSource excelSource() { + return DataSource.builder() + .id("excel") + .name("test-excel") + .type(new ExcelDataSourceType(null)) + .config(Map.of("path", xlsxPath, "sheetIndex", 0, "headerRow", 0)) + .build(); + } + + private TableDataset dataset() { + return TableDataset.builder() + .id("items") + .datasourceId("excel") + .sourceTable(null) + .alias("items") + .fields(List.of( + Field.builder().name("id").dataType(DataType.NUMBER).build(), + Field.builder().name("name").dataType(DataType.STRING).build(), + Field.builder().name("price").dataType(DataType.NUMBER).build(), + Field.builder().name("active").dataType(DataType.BOOLEAN).build())) + .build(); + } + + @Test + void supports_onlyExcel() { + assertTrue(extractor.supports("EXCEL")); + assertFalse(extractor.supports("DB")); + assertFalse(extractor.supports("CSV")); + } + + @Test + void extract_parsesRowsAndCoercesTypes() { + RawTable table = extractor.extract(excelSource(), dataset()); + assertEquals( + List.of("items.id", "items.name", "items.price", "items.active"), + table.getColumns()); + assertEquals(2, table.getRows().size()); + + Map first = table.getRows().get(0); + assertEquals(1.0, first.get("items.id")); + assertEquals("alice", first.get("items.name")); + assertEquals(10.5, first.get("items.price")); + assertEquals(Boolean.TRUE, first.get("items.active")); + + Map second = table.getRows().get(1); + assertEquals(2.0, second.get("items.id")); + assertEquals("bob", second.get("items.name")); + assertEquals(20.0, second.get("items.price")); + assertEquals(Boolean.FALSE, second.get("items.active")); + } + + @Test + void extract_missingColumnLeavesNull() { + // 表里没有 "active" 列时,对应字段应为 null(行初始化时已置 null) + DataSource source = DataSource.builder() + .id("excel") + .name("test-excel") + .type(new ExcelDataSourceType(null)) + .config(Map.of("path", xlsxPath, "sheetIndex", 0, "headerRow", 0)) + .build(); + TableDataset ds = TableDataset.builder() + .id("items") + .datasourceId("excel") + .sourceTable(null) + .alias("items") + .fields(List.of( + Field.builder().name("id").dataType(DataType.NUMBER).build(), + Field.builder().name("missing").dataType(DataType.STRING).build())) + .build(); + RawTable table = extractor.extract(source, ds); + assertEquals(List.of("items.id", "items.missing"), table.getColumns()); + assertEquals(2, table.getRows().size()); + assertEquals(1.0, table.getRows().get(0).get("items.id")); + assertNull(table.getRows().get(0).get("items.missing")); + } + + @Test + void test_ok() { + TestResult result = extractor.test(excelSource()); + assertTrue(result.ok(), result.message()); + } + + @Test + void test_fail_missingFile() { + DataSource bad = DataSource.builder() + .id("excel") + .name("bad") + .type(new ExcelDataSourceType(null)) + .config(Map.of("path", "/no/such/file.xlsx")) + .build(); + TestResult result = extractor.test(bad); + assertFalse(result.ok()); + } + + @Test + void test_fail_missingPath() { + DataSource bad = DataSource.builder() + .id("excel") + .name("bad") + .type(new ExcelDataSourceType(null)) + .config(Map.of()) + .build(); + TestResult result = extractor.test(bad); + assertFalse(result.ok()); + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/DataModelMgmtAutoConfiguration.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/DataModelMgmtAutoConfiguration.java new file mode 100644 index 0000000..37745fe --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/DataModelMgmtAutoConfiguration.java @@ -0,0 +1,48 @@ +package com.codingapi.report.starter; + +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.credential.CredentialService; +import com.codingapi.report.data.datasource.extractor.CsvDataExtractor; +import com.codingapi.report.data.datasource.extractor.DbDataExtractor; +import com.codingapi.report.data.datasource.extractor.ExcelDataExtractor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * 数据源管理自动配置:注册能力 Bean(凭证服务、DTO 转换器、各 {@link DataExtractor} 实现)。 + * + *

    本模块只提供能力,不提供 REST API——管理 Controller 归 starter("全部通用 REST API 在 starter"的架构约定), 由 starter 的 + * {@code ReportEngineAutoConfiguration} 装配并注入这里的能力 Bean。 + */ +@Configuration +public class DataModelMgmtAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public CredentialService credentialService( + @Value("${report.datasource.crypto.key:}") String key) { + return new CredentialService(key); + } + + @Bean + @ConditionalOnMissingBean + public DbDataExtractor dbDataExtractor() { + return new DbDataExtractor(); + } + + /** CSV 提取器(framework 内置,Bean 注册下放至此,example 不再单独声明)。 */ + @Bean + @ConditionalOnMissingBean + public CsvDataExtractor csvDataExtractor() { + return new CsvDataExtractor(); + } + + /** Excel 提取器:复用 report-engine-excel 的 ExcelImporter 解析 .xlsx。 */ + @Bean + @ConditionalOnMissingBean + public ExcelDataExtractor excelDataExtractor() { + return new ExcelDataExtractor(); + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/ReportEngineAutoConfiguration.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/ReportEngineAutoConfiguration.java index e68ded6..6a29179 100644 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/ReportEngineAutoConfiguration.java +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/ReportEngineAutoConfiguration.java @@ -1,10 +1,12 @@ package com.codingapi.report.starter; -import com.codingapi.report.data.datamodel.DataModel; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.credential.CredentialService; import com.codingapi.report.excel.FontRegistry; +import com.codingapi.report.repository.DataModelRepository; import com.codingapi.report.repository.ReportRepository; -import com.codingapi.report.starter.controller.DataModelController; +import com.codingapi.report.starter.controller.DataModelMgmtController; +import com.codingapi.report.starter.controller.DataSourceController; import com.codingapi.report.starter.controller.DatasetController; import com.codingapi.report.starter.controller.ExcelController; import com.codingapi.report.starter.controller.ExpressionController; @@ -12,6 +14,10 @@ import com.codingapi.report.starter.controller.ReportConfigController; import com.codingapi.report.starter.controller.ReportRenderController; import com.codingapi.report.starter.properties.ReportFontProperties; +import com.codingapi.report.starter.service.DataModelService; +import com.codingapi.report.starter.service.DataSourceService; +import com.codingapi.report.starter.service.ReportConfigService; +import com.codingapi.report.starter.service.ReportRenderService; import java.io.IOException; import java.nio.file.Path; import java.util.List; @@ -26,6 +32,9 @@ * Report Engine 自动配置。 * *

    自动装配 {@link FontRegistry} Bean: 提取内置字体 → 合并用户自定义字体 → 扫描 → 注册到 JVM + * + *

    Web 环境下注册全部通用 REST API Controller(数据源管理 / 报表配置 / 渲染 / 数据集 / 字体 / 公式 / Excel), 业务下沉 service + * 层({@code com.codingapi.report.starter.service}),Controller 只做 HTTP 编排。 */ @Configuration @EnableConfigurationProperties(ReportFontProperties.class) @@ -50,11 +59,41 @@ public FontRegistry fontRegistry(ReportFontProperties properties) throws IOExcep return registry; } - /** Web 环境下的自动配置:注册 REST API Controller。 */ + /** Web 环境下的自动配置:注册 service 与 REST API Controller。 */ @Configuration @ConditionalOnClass(RestController.class) static class WebConfiguration { + // ─── service 层 ─────────────────────────────────── + @Bean + @ConditionalOnMissingBean + public DataModelService dataModelService( + DataModelRepository dataModelRepository, CredentialService credentials) { + return new DataModelService(dataModelRepository, credentials); + } + + @Bean + @ConditionalOnMissingBean + public DataSourceService dataSourceService( + DataModelService dataModelService, List extractors) { + return new DataSourceService(dataModelService, extractors); + } + + @Bean + @ConditionalOnMissingBean + public ReportConfigService reportConfigService( + ReportRepository repository, DataModelService dataModelService) { + return new ReportConfigService(repository, dataModelService); + } + + @Bean + @ConditionalOnMissingBean + public ReportRenderService reportRenderService( + DataModelService dataModelService, List extractors) { + return new ReportRenderService(dataModelService, extractors); + } + + // ─── Controller 层 ──────────────────────────────── @Bean public FontController fontController(FontRegistry fontRegistry) { return new FontController(fontRegistry); @@ -67,16 +106,15 @@ public ExcelController excelController() { @Bean @ConditionalOnMissingBean - public ReportRenderController reportRenderController( - DataModel dataModel, CsvDataExtractor csvExtractor) { - return new ReportRenderController(dataModel, csvExtractor); + public ReportRenderController reportRenderController(ReportRenderService renderService) { + return new ReportRenderController(renderService); } @Bean @ConditionalOnMissingBean public DatasetController datasetController( - DataModel dataModel, CsvDataExtractor csvExtractor) { - return new DatasetController(dataModel, csvExtractor); + DataModelService dataModelService, DataSourceService dataSourceService) { + return new DatasetController(dataModelService, dataSourceService); } @Bean @@ -88,14 +126,20 @@ public ExpressionController expressionController() { @Bean @ConditionalOnMissingBean public ReportConfigController reportConfigController( - ReportRepository repository, DataModel dataModel) { - return new ReportConfigController(repository, dataModel); + ReportConfigService reportConfigService) { + return new ReportConfigController(reportConfigService); + } + + @Bean + @ConditionalOnMissingBean + public DataModelMgmtController dataModelMgmtController(DataModelService dataModelService) { + return new DataModelMgmtController(dataModelService); } @Bean @ConditionalOnMissingBean - public DataModelController dataModelController(List dataModels) { - return new DataModelController(dataModels); + public DataSourceController dataSourceController(DataSourceService dataSourceService) { + return new DataSourceController(dataSourceService); } } } diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelController.java deleted file mode 100644 index 4b3f9bc..0000000 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelController.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.codingapi.report.starter.controller; - -import com.codingapi.report.data.datamodel.DataModel; -import com.codingapi.springboot.framework.dto.response.MultiResponse; -import java.util.List; -import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -/** - * 数据模型列表:供前端创建报表时选择数据模型。 - * - *

    注入容器中所有 {@link DataModel} Bean(使用方可注册多个数据模型),返回 id + name 简要信息。 - */ -@RestController -@RequestMapping("/api/datamodels") -@ConditionalOnClass(RestController.class) -public class DataModelController { - - private final List dataModels; - - public DataModelController(List dataModels) { - this.dataModels = dataModels; - } - - /** 数据模型列表(id + name)。 */ - @GetMapping - public MultiResponse list() { - List briefs = - dataModels.stream() - .map(dm -> new DataModelBrief(dm.getId(), dm.getName())) - .toList(); - return MultiResponse.of(briefs); - } - - public record DataModelBrief(String id, String name) {} -} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelMgmtController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelMgmtController.java new file mode 100644 index 0000000..969a138 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelMgmtController.java @@ -0,0 +1,82 @@ +package com.codingapi.report.starter.controller; + +import com.codingapi.report.dto.datamodel.DataModelDTO; +import com.codingapi.report.dto.datamodel.RelationshipDTO; +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.repository.PageResult; +import com.codingapi.report.starter.service.DataModelService; +import com.codingapi.springboot.framework.dto.response.MultiResponse; +import com.codingapi.springboot.framework.dto.response.SingleResponse; +import java.util.List; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 数据模型管理 API:CRUD 可复用的数据模型配置(含内嵌的连接/数据集/关系)。 + * + *

    仅做 HTTP 编排,业务(凭证脱敏/merge/加密、CRUD)下沉 {@link DataModelService}。 + */ +@RestController +@RequestMapping("/api/datamodels") +@ConditionalOnClass(RestController.class) +public class DataModelMgmtController { + + private final DataModelService dataModelService; + + public DataModelMgmtController(DataModelService dataModelService) { + this.dataModelService = dataModelService; + } + + @GetMapping + public MultiResponse list( + @RequestParam(defaultValue = "1") int current, + @RequestParam(defaultValue = "10") int pageSize) { + PageResult result = dataModelService.page(current, pageSize); + List briefs = + result.content().stream() + .map( + c -> + new DataModelBrief( + c.getId(), + c.getName() != null ? c.getName() : "未命名模型", + c.getStatus() != null ? c.getStatus().name() : null, + c.getCreateTime(), + c.getUpdateTime())) + .toList(); + return MultiResponse.of(briefs, result.total()); + } + + @GetMapping("/{id}") + public SingleResponse get(@PathVariable String id) { + return SingleResponse.of(dataModelService.getMasked(id)); + } + + @PostMapping + public SingleResponse save(@RequestBody DataModelDTO dto) { + return SingleResponse.of(dataModelService.save(dto)); + } + + @DeleteMapping("/{id}") + public SingleResponse delete(@PathVariable String id) { + dataModelService.delete(id); + return SingleResponse.of(null); + } + + @PutMapping("/relationships") + public SingleResponse saveRelationships( + @RequestParam String dataModelId, @RequestBody List relationships) { + dataModelService.saveRelationships(dataModelId, relationships); + return SingleResponse.of(null); + } + + public record DataModelBrief( + String id, String name, String status, long createTime, long updateTime) {} +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataSourceController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataSourceController.java new file mode 100644 index 0000000..559373d --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataSourceController.java @@ -0,0 +1,52 @@ +package com.codingapi.report.starter.controller; + +import com.codingapi.report.dto.datamodel.DataSourceDTO; +import com.codingapi.report.data.datasource.ColumnMeta; +import com.codingapi.report.data.datasource.TestResult; +import com.codingapi.report.starter.service.DataSourceService; +import com.codingapi.springboot.framework.dto.response.MultiResponse; +import com.codingapi.springboot.framework.dto.response.SingleResponse; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 数据源(连接)操作 API:连接测试 + 表/列探查。 + * + *

    仅做 HTTP 编排,业务(提取器派发、连接解密加载、探查)下沉 {@link DataSourceService}。 + */ +@RestController +@RequestMapping("/api/datasources") +@ConditionalOnClass(RestController.class) +public class DataSourceController { + + private final DataSourceService dataSourceService; + + public DataSourceController(DataSourceService dataSourceService) { + this.dataSourceService = dataSourceService; + } + + @PostMapping("/test") + public SingleResponse test(@RequestBody DataSourceDTO dto) { + return SingleResponse.of(dataSourceService.testConnection(dto)); + } + + @GetMapping("/{dataModelId}/{datasourceId}/tables") + public MultiResponse tables( + @PathVariable String dataModelId, @PathVariable String datasourceId) { + return MultiResponse.of(dataSourceService.listTables(dataModelId, datasourceId)); + } + + @GetMapping("/{dataModelId}/{datasourceId}/columns") + public MultiResponse columns( + @PathVariable String dataModelId, + @PathVariable String datasourceId, + @RequestParam String table) { + return MultiResponse.of(dataSourceService.listColumns(dataModelId, datasourceId, table)); + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DatasetController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DatasetController.java index 8fa23c8..5a43aa0 100644 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DatasetController.java +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DatasetController.java @@ -1,17 +1,11 @@ package com.codingapi.report.starter.controller; -import com.codingapi.report.data.datamodel.DataModel; -import com.codingapi.report.data.dataset.Dataset; -import com.codingapi.report.data.dataset.TableDataset; -import com.codingapi.report.data.datasource.DataSource; -import com.codingapi.report.data.datasource.DataSourceType; -import com.codingapi.report.data.datasource.RawTable; -import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.starter.dto.DatasetDtos.DatasetDTO; +import com.codingapi.report.starter.dto.DatasetDtos.PreviewDTO; +import com.codingapi.report.starter.service.DataModelService; +import com.codingapi.report.starter.service.DataSourceService; import com.codingapi.springboot.framework.dto.response.MultiResponse; import com.codingapi.springboot.framework.dto.response.SingleResponse; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,115 +13,35 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -/** 数据集元数据 API:列出数据集(含字段定义)、预览前 N 行,供前端左面板树形展示。 */ +/** + * 数据集元数据 API:列出数据集(含字段定义)、预览前 N 行,供前端左面板树形展示。 + * + *

    列表走 {@link DataModelService}(模型视图),预览走 {@link DataSourceService}(提取器派发)。 + */ @RestController @RequestMapping("/api/datasets") @ConditionalOnClass(RestController.class) public class DatasetController { - private final DataModel dataModel; - private final CsvDataExtractor csvExtractor; + private final DataModelService dataModelService; + private final DataSourceService dataSourceService; - public DatasetController(DataModel dataModel, CsvDataExtractor csvExtractor) { - this.dataModel = dataModel; - this.csvExtractor = csvExtractor; + public DatasetController( + DataModelService dataModelService, DataSourceService dataSourceService) { + this.dataModelService = dataModelService; + this.dataSourceService = dataSourceService; } - /** 列出所有数据集(含字段定义) */ @GetMapping - public MultiResponse list() { - List list = - dataModel.getDatasets().stream() - .filter(ds -> ds instanceof TableDataset) - .map( - ds -> { - TableDataset tds = (TableDataset) ds; - List fields = - tds.getFields().stream() - .map( - f -> - new FieldDTO( - f.getName(), - f.getAlias(), - f.getDataType().name(), - f.isPrimaryKey())) - .toList(); - return new DatasetDTO( - tds.getId(), - tds.getAlias(), - tds.getDatasourceId(), - "CSV", - fields); - }) - .toList(); - return MultiResponse.of(list); + public MultiResponse list(@RequestParam String dataModelId) { + return MultiResponse.of(dataModelService.listDatasets(dataModelId)); } - /** 预览数据集前 N 行 */ @GetMapping("/{id}/preview") public SingleResponse preview( - @PathVariable String id, @RequestParam(defaultValue = "20") int limit) { - - Dataset ds = - dataModel.getDatasets().stream() - .filter(d -> d.getId().equals(id)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("数据集不存在: " + id)); - - if (!(ds instanceof TableDataset tds)) { - throw new IllegalArgumentException("非表格数据集: " + id); - } - - DataSource source = - DataSource.builder() - .id("csv") - .name("CSV") - .type(DataSourceType.CSV) - .config(Map.of("path", "/data/" + tds.getSourceTable())) - .build(); - - RawTable raw = csvExtractor.extract(source, tds); - - // 取前 limit 行,去掉列名的 datasetId 前缀 - String prefix = tds.getId() + "."; - List columns = - raw.getColumns().stream() - .map(c -> c.startsWith(prefix) ? c.substring(prefix.length()) : c) - .toList(); - - List> rows = - raw.getRows().stream() - .limit(limit) - .map( - row -> { - Map simplified = new LinkedHashMap<>(); - for (Map.Entry e : row.entrySet()) { - String key = e.getKey(); - simplified.put( - key.startsWith(prefix) - ? key.substring(prefix.length()) - : key, - e.getValue()); - } - return simplified; - }) - .toList(); - - return SingleResponse.of(new PreviewDTO(columns, rows)); + @PathVariable String id, + @RequestParam String dataModelId, + @RequestParam(defaultValue = "20") int limit) { + return SingleResponse.of(dataSourceService.previewDataset(dataModelId, id, limit)); } - - // ============================================================ - // DTO - // ============================================================ - - public record DatasetDTO( - String id, - String alias, - String dataSourceId, - String dataSourceType, - List fields) {} - - public record FieldDTO(String name, String alias, String dataType, boolean primaryKey) {} - - public record PreviewDTO(List columns, List> rows) {} } diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportConfigController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportConfigController.java index d714ce8..eaa1218 100644 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportConfigController.java +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportConfigController.java @@ -1,11 +1,9 @@ package com.codingapi.report.starter.controller; -import com.codingapi.report.config.ReportConfig; -import com.codingapi.report.data.datamodel.DataModel; -import com.codingapi.report.repository.PageQuery; +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.core.Report; import com.codingapi.report.repository.PageResult; -import com.codingapi.report.repository.ReportRepository; -import com.codingapi.report.starter.converter.DataModelDtoAssembler; +import com.codingapi.report.starter.service.ReportConfigService; import com.codingapi.springboot.framework.dto.request.SearchRequest; import com.codingapi.springboot.framework.dto.response.MultiResponse; import com.codingapi.springboot.framework.dto.response.SingleResponse; @@ -22,54 +20,40 @@ /** * 报表配置的保存 / 加载 / 列表 / 删除。 * - *

    配置以强类型 {@link ReportConfig} 实体存取(name/cellBindings/loopBlocks/summaries/params/template + - * 时间戳), 打开报表时整体恢复前端状态。加载时附带数据模型信息(datasets + relationships)。 - * - *

    存储交给 {@link ReportRepository}(使用方提供实现)。 + *

    仅做 HTTP 编排,业务(CRUD + 富化)下沉 {@link ReportConfigService}。 存储交给 {@link + * com.codingapi.report.repository.ReportRepository}(使用方提供实现)。 */ @RestController @RequestMapping("/api/report") @ConditionalOnClass(RestController.class) public class ReportConfigController { - private final ReportRepository repository; - private final DataModel dataModel; + private final ReportConfigService reportConfigService; - public ReportConfigController(ReportRepository repository, DataModel dataModel) { - this.repository = repository; - this.dataModel = dataModel; + public ReportConfigController(ReportConfigService reportConfigService) { + this.reportConfigService = reportConfigService; } - /** 保存报表配置,返回报表 id。 */ @PostMapping("/configs") - public SingleResponse save(@RequestBody ReportConfig config) { - return SingleResponse.of(repository.save(config)); + public SingleResponse save(@RequestBody ReportDTO config) { + return SingleResponse.of(reportConfigService.save(config)); } - /** 加载指定报表的完整配置(附带数据模型信息)。 */ @GetMapping("/configs/{id}") - public SingleResponse get(@PathVariable String id) { - ReportConfig config = repository.find(id); - if (config == null) { - return SingleResponse.of(null); - } - config.setDataModel(DataModelDtoAssembler.assemble(dataModel)); - return SingleResponse.of(config); + public SingleResponse get(@PathVariable String id) { + return SingleResponse.of(reportConfigService.get(id)); } - /** 删除指定报表配置。 */ @DeleteMapping("/configs/{id}") public SingleResponse delete(@PathVariable String id) { - repository.delete(id); + reportConfigService.delete(id); return SingleResponse.of(null); } - /** 报表列表(id + name + dataModelId + 时间戳),按 SearchRequest 分页。 */ @GetMapping("/configs") public MultiResponse list(SearchRequest searchRequest) { - // Spring 入参 → framework 分页类型(接口本身不依赖 Spring) - PageQuery query = new PageQuery(searchRequest.getCurrent(), searchRequest.getPageSize()); - PageResult result = repository.page(query); + PageResult result = + reportConfigService.page(searchRequest.getCurrent(), searchRequest.getPageSize()); List briefs = result.content().stream() .map( diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportRenderController.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportRenderController.java index 00fa66d..f4dd2c8 100644 Binary files a/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportRenderController.java and b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/ReportRenderController.java differ diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/DataModelDtoAssembler.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/DataModelDtoAssembler.java index b32333e..9df0ba3 100644 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/DataModelDtoAssembler.java +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/DataModelDtoAssembler.java @@ -1,8 +1,8 @@ package com.codingapi.report.starter.converter; import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.dataset.Dataset; import com.codingapi.report.data.dataset.TableDataset; -import com.codingapi.report.data.datasource.DataSource; import com.codingapi.report.data.relation.Relationship; import java.util.LinkedHashMap; import java.util.List; @@ -18,11 +18,11 @@ public final class DataModelDtoAssembler { private DataModelDtoAssembler() {} public static Map assemble(DataModel dataModel) { - // 构建 datasourceId → type 映射 + // 构建 datasourceId → type 映射(连接由各 TableDataset 自带,DataModel 不再持有 datasources) Map sourceTypeMap = new LinkedHashMap<>(); - if (dataModel.getDatasources() != null) { - for (DataSource ds : dataModel.getDatasources()) { - sourceTypeMap.put(ds.getId(), ds.getType().name()); + for (Dataset ds : dataModel.getDatasets()) { + if (ds instanceof TableDataset t && t.getDatasource() != null) { + sourceTypeMap.put(t.getDatasource().getId(), t.getDatasource().getType().type()); } } diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/RenderDtoConverter.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/RenderDtoConverter.java deleted file mode 100644 index 034cc9a..0000000 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/converter/RenderDtoConverter.java +++ /dev/null @@ -1,195 +0,0 @@ -package com.codingapi.report.starter.converter; - -import com.codingapi.report.config.dto.ConfigDtos.BindingDTO; -import com.codingapi.report.config.dto.ConfigDtos.ConditionDTO; -import com.codingapi.report.config.dto.ConfigDtos.LoopBlockDTO; -import com.codingapi.report.config.dto.ConfigDtos.PartDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryCellDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryRowDTO; -import com.codingapi.report.config.dto.ConfigDtos.ValueDTO; -import com.codingapi.report.data.dataset.FieldRef; -import com.codingapi.report.data.dataset.Query; -import com.codingapi.report.expression.Value; -import com.codingapi.report.operator.condition.CompareOperator; -import com.codingapi.report.operator.condition.Condition; -import com.codingapi.report.render.grid.Axis; -import com.codingapi.report.render.grid.CellBinding; -import com.codingapi.report.render.grid.CellRef; -import com.codingapi.report.render.grid.ExpandMode; -import com.codingapi.report.render.grid.Expansion; -import com.codingapi.report.render.grid.LoopBlock; -import com.codingapi.report.render.grid.SummaryCell; -import com.codingapi.report.render.grid.SummaryRow; -import java.util.ArrayList; -import java.util.List; - -/** - * 渲染请求 DTO → framework 领域对象的转换器。 - * - *

    因 {@code Value} 等 sealed interface 无 Jackson 多态注解,前端 JSON 经 {@link - * com.codingapi.report.starter.dto.RenderDtos} 承接后由此处统一转换。所有方法无状态(static)。 - */ -public final class RenderDtoConverter { - - private RenderDtoConverter() {} - - public static List convertBindings(List dtos) { - if (dtos == null) return List.of(); - List result = new ArrayList<>(); - for (BindingDTO dto : dtos) { - result.add( - CellBinding.builder() - .cell(CellRef.parse(dto.cellKey())) - .value(convertValue(dto.value())) - .expansion( - dto.expansion() != null - ? Expansion.valueOf(dto.expansion()) - : null) - .expandMode( - dto.expandMode() != null - ? ExpandMode.valueOf(dto.expandMode()) - : null) - .mergeRepeated(dto.mergeRepeated()) - .parentCell( - dto.parentCell() != null - ? CellRef.parse(dto.parentCell()) - : null) - .conditions(convertConditions(dto.conditions())) - .independent(dto.independent()) - .drillEnabled(dto.drillEnabled()) - .drillView(dto.drillView()) - .build()); - } - return result; - } - - public static Value convertValue(ValueDTO dto) { - if (dto == null) return new Value.Literal(null); - return switch (dto.type()) { - case "FieldValue" -> { - String[] parts = dto.payload().split("\\.", 2); - yield new Value.FieldValue(new FieldRef(parts[0], parts[1])); - } - case "Literal" -> new Value.Literal(dto.payload()); - case "NameRef" -> new Value.NameRef(dto.payload()); - case "ParamValue" -> new Value.ParamValue(dto.payload()); - case "LoopFieldValue" -> { - String[] parts = dto.payload().split("\\.", 2); - yield new Value.LoopFieldValue(parts[0], parts[1]); - } - case "Aggregate" -> new Value.Aggregate(dto.aggregation(), convertValue(dto.operand())); - case "FunctionCall" -> - new Value.FunctionCall( - dto.funcName(), - dto.args() != null - ? dto.args().stream() - .map(RenderDtoConverter::convertValue) - .toList() - : List.of()); - case "Template" -> { - List parts = new ArrayList<>(); - if (dto.parts() != null) { - for (PartDTO p : dto.parts()) { - if ("text".equals(p.kind())) { - parts.add(new Value.Template.Text(p.text())); - } else { - parts.add(new Value.Template.Hole(convertValue(p.value()))); - } - } - } - yield new Value.Template(parts); - } - default -> new Value.Literal(dto.payload()); - }; - } - - public static List convertConditions(List dtos) { - if (dtos == null) return List.of(); - List result = new ArrayList<>(); - for (ConditionDTO dto : dtos) { - result.add( - Condition.builder() - .left(convertValue(dto.left())) - .operator(CompareOperator.valueOf(dto.operator())) - .right(dto.right() != null ? convertValue(dto.right()) : null) - .build()); - } - return result; - } - - public static List convertLoops(List dtos) { - if (dtos == null) return List.of(); - List result = new ArrayList<>(); - for (LoopBlockDTO dto : dtos) { - Query query = - Query.builder() - .datasetId(dto.source().datasetId()) - .filters(convertConditions(dto.source().filters())) - .groupBy( - dto.source().groupBy() != null - ? dto.source().groupBy() - : List.of()) - .orderBy( - dto.source().orderBy() != null - ? dto.source().orderBy() - : List.of()) - .build(); - result.add( - LoopBlock.builder() - .id(dto.id()) - .label(dto.label()) - .start(new CellRef(dto.sheetId(), dto.startRow(), dto.startColumn())) - .end(new CellRef(dto.sheetId(), dto.endRow(), dto.endColumn())) - .source(query) - .build()); - } - return result; - } - - public static List convertSummaries(List dtos) { - if (dtos == null) return List.of(); - List result = new ArrayList<>(); - for (SummaryRowDTO dto : dtos) { - FieldRef groupBy = - dto.groupBy() != null - ? new FieldRef(dto.groupBy().datasetId(), dto.groupBy().field()) - : null; - List cells = new ArrayList<>(); - if (dto.cells() != null) { - for (SummaryCellDTO c : dto.cells()) { - if (c.value() != null) { - // 新格式:直接使用 ValueDTO + 反查配置 - cells.add( - new SummaryCell( - c.crossPos(), - convertValue(c.value()), - c.drillEnabled(), - c.drillView())); - } else if ("label".equals(c.kind())) { - // 旧格式兼容 - cells.add(SummaryCell.label(c.crossPos(), c.payload())); - } else { - // 旧格式 agg 兼容 - String[] parts = c.payload().split("\\.", 2); - cells.add( - SummaryCell.agg( - c.crossPos(), - new FieldRef(parts[0], parts[1]), - c.aggregation())); - } - } - } - Axis axis = "HORIZONTAL".equals(dto.axis()) ? Axis.HORIZONTAL : Axis.VERTICAL; - result.add( - SummaryRow.builder() - .axis(axis) - .groupBy(groupBy) - .crossFrom(dto.crossFrom()) - .crossTo(dto.crossTo()) - .cells(cells) - .mainPos(dto.mainPos()) - .build()); - } - return result; - } -} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/DatasetDtos.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/DatasetDtos.java new file mode 100644 index 0000000..0233fe1 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/DatasetDtos.java @@ -0,0 +1,21 @@ +package com.codingapi.report.starter.dto; + +import java.util.List; +import java.util.Map; + +/** 数据集元数据/预览的响应 DTO 契约(供 {@code DatasetController} 列表与预览端点)。 */ +public final class DatasetDtos { + + private DatasetDtos() {} + + public record DatasetDTO( + String id, + String alias, + String dataSourceId, + String dataSourceType, + List fields) {} + + public record FieldDTO(String name, String alias, String dataType, boolean primaryKey) {} + + public record PreviewDTO(List columns, List> rows) {} +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/RenderDtos.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/RenderDtos.java index 182a6ec..8e4a512 100644 --- a/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/RenderDtos.java +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/dto/RenderDtos.java @@ -1,27 +1,44 @@ package com.codingapi.report.starter.dto; -import com.codingapi.report.config.dto.ConfigDtos.BindingDTO; -import com.codingapi.report.config.dto.ConfigDtos.LoopBlockDTO; -import com.codingapi.report.config.dto.ConfigDtos.SummaryRowDTO; +import com.codingapi.report.dto.report.BindingDTO; +import com.codingapi.report.dto.report.LoopBlockDTO; +import com.codingapi.report.dto.report.SummaryRowDTO; import com.codingapi.report.excel.pojo.Workbook; import java.util.List; import java.util.Map; /** - * 渲染请求的 DTO 契约(前端 JSON → 这些 DTO → framework 领域对象)。 + * 渲染请求/响应的 DTO 契约(前端 JSON ↔ 这些 DTO ↔ framework 领域对象)。 * - *

    单元格绑定 / 循环块 / 汇总行的 DTO record 已上提到 framework {@code - * com.codingapi.report.config.dto.ConfigDtos}(同时作为 {@code ReportConfig} 实体的持久化契约), 本类仅保留渲染请求 {@link - * RenderRequest}(params 为运行时参数值 Map,与实体的 params 定义不同)。 + *

    单元格绑定 / 循环块 / 汇总行的 DTO record 在 framework {@code com.codingapi.report.dto.report} 包 + * (与领域 {@code core.Report} 经 {@code RenderDtoConverter} 互转),本类保留渲染请求 {@link RenderRequest} 与渲染响应(预览结果 / + * 反查请求与结果)。 */ public final class RenderDtos { private RenderDtos() {} public record RenderRequest( + String dataModelId, List cellBindings, List loopBlocks, List summaries, Map params, Workbook template) {} + + /** 预览响应:工作簿 + 反查格坐标列表("row:col" 格式) */ + public record PreviewResult(Workbook workbook, List drillable) {} + + /** 反查请求:渲染配置 + 目标格坐标 */ + public record DrillRequest(RenderRequest request, int row, int col) {} + + /** 反查结果:数据集 id/别名 + 字段列表 + 明细行(本期全量返回,不分页) */ + public record DrillResult( + String datasetId, + String alias, + List fields, + List> rows) {} + + /** 字段信息:name + alias */ + public record FieldInfo(String name, String alias) {} } diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataModelService.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataModelService.java new file mode 100644 index 0000000..9c8d6ee --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataModelService.java @@ -0,0 +1,164 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.dto.datamodel.DataModelDTO; +import com.codingapi.report.dto.datamodel.DataSourceDTO; +import com.codingapi.report.dto.datamodel.RelationshipDTO; +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.credential.CredentialService; +import com.codingapi.report.repository.DataModelRepository; +import com.codingapi.report.repository.PageQuery; +import com.codingapi.report.repository.PageResult; +import com.codingapi.report.starter.dto.DatasetDtos.DatasetDTO; +import com.codingapi.report.starter.dto.DatasetDtos.FieldDTO; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 数据模型业务:CRUD + 凭证(出口脱敏 / *** 回填)+ 数据集列表视图。 + * + *

    仓库以领域 {@link DataModel} 存取({@code config} 明文,落盘加密交仓库实现)。出入站用 {@link DataModelDTO}: + * {@link DataModel#toDTO()} / {@link DataModel#fromDTO} 互转,敏感字段仅在出口 {@link #getMasked} 脱敏。 + */ +public class DataModelService { + + private final DataModelRepository repository; + private final CredentialService credentials; + + public DataModelService(DataModelRepository repository, CredentialService credentials) { + this.repository = repository; + this.credentials = credentials; + } + + public PageResult page(int current, int pageSize) { + return repository.page(new PageQuery(current, pageSize)); + } + + /** 详情({@code datasources.config} 脱敏)。 */ + public DataModelDTO getMasked(String id) { + DataModel dm = repository.find(id); + if (dm == null) return null; + return maskDto(dm.toDTO()); + } + + /** 新建/更新:{@code ***} 凭证回填旧值(明文存储,落盘加密交仓库实现)。 */ + public String save(DataModelDTO dto) { + DataModel incoming = DataModel.fromDTO(dto); + DataModel old = + dto.id() != null && !dto.id().isBlank() ? repository.find(dto.id()) : null; + mergeMaskedCredentials(incoming, old); + return repository.save(incoming); + } + + public void delete(String id) { + repository.delete(id); + } + + /** 批量替换关系(列表式整体替换)。 */ + public void saveRelationships(String dataModelId, List relationships) { + DataModel dm = repository.find(dataModelId); + if (dm == null) { + throw new IllegalArgumentException("数据模型不存在: " + dataModelId); + } + dm.setRelationships(DataModel.buildRelationships(relationships)); + repository.save(dm); + } + + /** 加载领域模型;不存在返回 null(富化容错用)。 */ + public DataModel findDataModel(String dataModelId) { + if (dataModelId == null || dataModelId.isBlank()) return null; + return repository.find(dataModelId); + } + + /** 加载领域模型;不存在或 id 缺失抛异常(渲染必须)。 */ + public DataModel loadDataModel(String dataModelId) { + if (dataModelId == null || dataModelId.isBlank()) { + throw new IllegalArgumentException("渲染请求缺少 dataModelId"); + } + DataModel dm = findDataModel(dataModelId); + if (dm == null) { + throw new IllegalArgumentException("数据模型不存在: " + dataModelId); + } + return dm; + } + + /** 列出数据集(含字段 + 来源类型,供左面板树)。 */ + public List listDatasets(String dataModelId) { + DataModel dm = loadDataModel(dataModelId); + List out = new ArrayList<>(); + if (dm.getDatasets() == null) return out; + for (Dataset ds : dm.getDatasets()) { + if (!(ds instanceof TableDataset tds)) continue; + List fields = + tds.getFields().stream() + .map( + f -> + new FieldDTO( + f.getName(), + f.getAlias(), + f.getDataType().name(), + f.isPrimaryKey())) + .toList(); + String type = + tds.getDatasource() != null ? tds.getDatasource().getType().type() : "CSV"; + out.add(new DatasetDTO(tds.getId(), tds.getAlias(), tds.getDatasourceId(), type, fields)); + } + return out; + } + + // ============================================================ + // 凭证 mask / merge + // ============================================================ + + private DataModelDTO maskDto(DataModelDTO dto) { + if (dto.datasources() == null) return dto; + List masked = + dto.datasources().stream() + .map( + s -> + new DataSourceDTO( + s.id(), + s.name(), + s.type(), + credentials.maskConfig(s.config()))) + .toList(); + return new DataModelDTO( + dto.id(), + dto.name(), + dto.status(), + dto.createTime(), + dto.updateTime(), + masked, + dto.datasets(), + dto.relationships()); + } + + /** 前端回传的 {@code ***} 占位用旧连接的真实值回填(按连接 id 匹配),避免覆盖真实凭证。 */ + private void mergeMaskedCredentials(DataModel incoming, DataModel old) { + if (old == null) return; + Map oldById = new LinkedHashMap<>(); + for (DataSource s : old.datasources()) { + oldById.put(s.getId(), s); + } + for (DataSource neu : incoming.datasources()) { + DataSource o = oldById.get(neu.getId()); + if (o == null || neu.getConfig() == null || o.getConfig() == null) continue; + Map merged = new LinkedHashMap<>(neu.getConfig()); + boolean changed = false; + for (Map.Entry e : merged.entrySet()) { + if (credentials.isMasked(e.getValue())) { + Object oldVal = o.getConfig().get(e.getKey()); + if (oldVal != null) { + e.setValue(oldVal); + changed = true; + } + } + } + if (changed) neu.setConfig(merged); + } + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataSourceService.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataSourceService.java new file mode 100644 index 0000000..2ca4835 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataSourceService.java @@ -0,0 +1,118 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.dto.datamodel.DataSourceDTO; +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.data.datasource.ColumnMeta; +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.DataSource; +import com.codingapi.report.data.datasource.type.DataSourceType; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import com.codingapi.report.starter.dto.DatasetDtos.PreviewDTO; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 数据源(连接)操作业务:连接测试 + 表/列探查 + 数据集预览。 + * + *

    提取器按 {@code supports(type)} 在 {@code List} 中派发,与渲染链路同一注册表范式。 预览/探查 的连接从 {@link + * DataModelService#loadDataModel} 取(已解密)。 + */ +public class DataSourceService { + + private final DataModelService dataModelService; + private final List extractors; + + public DataSourceService(DataModelService dataModelService, List extractors) { + this.dataModelService = dataModelService; + this.extractors = extractors; + } + + /** 测试连接(不落库,凭证明文)。 */ + public TestResult testConnection(DataSourceDTO dto) { + DataSourceType type = DataSourceType.of(dto.type(), dto.config()); + DataSource ds = + DataSource.builder() + .id(dto.id()) + .name(dto.name()) + .type(type) + .config(dto.config()) + .build(); + return findExtractor(type).test(ds); + } + + public List listTables(String dataModelId, String datasourceId) { + DataSource ds = loadDataSource(dataModelId, datasourceId); + return findExtractor(ds.getType()).listTables(ds); + } + + public List listColumns(String dataModelId, String datasourceId, String table) { + DataSource ds = loadDataSource(dataModelId, datasourceId); + return findExtractor(ds.getType()).listColumns(ds, table); + } + + /** 数据集预览:提取前 N 行,去掉列名 datasetId 前缀。 */ + public PreviewDTO previewDataset(String dataModelId, String datasetId, int limit) { + DataModel dm = dataModelService.loadDataModel(dataModelId); + Dataset ds = + dm.getDatasets().stream() + .filter(d -> d.getId().equals(datasetId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("数据集不存在: " + datasetId)); + if (!(ds instanceof TableDataset tds)) { + throw new IllegalArgumentException("非表格数据集: " + datasetId); + } + DataSource source = tds.getDatasource(); + if (source == null) { + throw new IllegalArgumentException("数据集未绑定数据源: " + tds.getDatasourceId()); + } + RawTable raw = findExtractor(source.getType()).extract(source, tds); + + String prefix = tds.getId() + "."; + List columns = + raw.getColumns().stream() + .map(c -> c.startsWith(prefix) ? c.substring(prefix.length()) : c) + .toList(); + List> rows = + raw.getRows().stream() + .limit(limit) + .map( + row -> { + Map simplified = new LinkedHashMap<>(); + for (Map.Entry e : row.entrySet()) { + String key = e.getKey(); + simplified.put( + key.startsWith(prefix) + ? key.substring(prefix.length()) + : key, + e.getValue()); + } + return simplified; + }) + .toList(); + return new PreviewDTO(columns, rows); + } + + private DataExtractor findExtractor(DataSourceType type) { + return extractors.stream() + .filter(e -> e.supports(type.type())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("无提取器支持类型: " + type.type())); + } + + private DataSource loadDataSource(String dataModelId, String datasourceId) { + DataModel dm = dataModelService.loadDataModel(dataModelId); + return dm.getDatasets().stream() + .filter( + d -> + d instanceof TableDataset t + && t.getDatasource() != null + && datasourceId.equals(t.getDatasource().getId())) + .map(d -> ((TableDataset) d).getDatasource()) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("连接不存在: " + datasourceId)); + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportConfigService.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportConfigService.java new file mode 100644 index 0000000..90c0973 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportConfigService.java @@ -0,0 +1,53 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.dto.report.ReportDTO; +import com.codingapi.report.core.Report; +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.repository.PageQuery; +import com.codingapi.report.repository.PageResult; +import com.codingapi.report.repository.ReportRepository; +import com.codingapi.report.starter.converter.DataModelDtoAssembler; + +/** + * 报表业务:CRUD + 加载时富化数据模型视图。 + * + *

    仓库以领域 {@link Report} 存取;出入站用 {@link ReportDTO}({@link Report#toDTO()} / {@link + * Report#fromDTO})。富化通过 {@link DataModelService#findDataModel} 取运行时模型(容错:模型缺失不阻断加载), 由 {@link + * DataModelDtoAssembler} 组装为前端视图。 + */ +public class ReportConfigService { + + private final ReportRepository repository; + private final DataModelService dataModelService; + + public ReportConfigService(ReportRepository repository, DataModelService dataModelService) { + this.repository = repository; + this.dataModelService = dataModelService; + } + + public String save(ReportDTO dto) { + return repository.save(Report.fromDTO(dto)); + } + + /** 加载并富化数据模型视图。 */ + public ReportDTO get(String id) { + Report report = repository.find(id); + if (report == null) return null; + ReportDTO dto = report.toDTO(); + if (report.getDataModelId() != null) { + DataModel dm = dataModelService.findDataModel(report.getDataModelId()); + if (dm != null) { + dto.setDataModel(DataModelDtoAssembler.assemble(dm)); + } + } + return dto; + } + + public void delete(String id) { + repository.delete(id); + } + + public PageResult page(int current, int pageSize) { + return repository.page(new PageQuery(current, pageSize)); + } +} diff --git a/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportRenderService.java b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportRenderService.java new file mode 100644 index 0000000..3891388 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportRenderService.java @@ -0,0 +1,135 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.dataset.Dataset; +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.excel.pojo.Workbook; +import com.codingapi.report.param.ParamContext; +import com.codingapi.report.core.Report; +import com.codingapi.report.core.engine.DrillCollector; +import com.codingapi.report.core.engine.ReportRenderer; +import com.codingapi.report.core.grid.CellBinding; +import com.codingapi.report.core.grid.LoopBlock; +import com.codingapi.report.core.grid.SummaryRow; +import com.codingapi.report.core.RenderDtoConverter; +import com.codingapi.report.starter.dto.RenderDtos.DrillRequest; +import com.codingapi.report.starter.dto.RenderDtos.DrillResult; +import com.codingapi.report.starter.dto.RenderDtos.FieldInfo; +import com.codingapi.report.starter.dto.RenderDtos.PreviewResult; +import com.codingapi.report.starter.dto.RenderDtos.RenderRequest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 报表渲染业务:render/preview/drill 编排 + drill 反查投影去重。 + * + *

    数据模型从 {@link DataModelService#loadDataModel} 加载,提取器走 {@code List} 注册表派发。 + * Controller 只做 HTTP 编排(xlsx 导出 / 响应包装),渲染逻辑全部在此。 + */ +public class ReportRenderService { + + private final DataModelService dataModelService; + private final List extractors; + + public ReportRenderService(DataModelService dataModelService, List extractors) { + this.dataModelService = dataModelService; + this.extractors = extractors; + } + + /** 渲染为工作簿({@code /render} 端点再导出 xlsx)。 */ + public Workbook render(RenderRequest request) { + DataModel dm = dataModelService.loadDataModel(request.dataModelId()); + return renderWorkbook(dm, request, null); + } + + /** 预览:渲染并返回工作簿 + 反查格坐标列表。 */ + public PreviewResult preview(RenderRequest request) { + DataModel dm = dataModelService.loadDataModel(request.dataModelId()); + DrillCollector collector = new DrillCollector(); + Workbook workbook = renderWorkbook(dm, request, collector); + List drillable = new ArrayList<>(collector.getAll().keySet()); + return new PreviewResult(workbook, drillable); + } + + /** 反查明细:渲染 + 投影目标格贡献的原始行(按主键去重)。 */ + public DrillResult drill(DrillRequest request) { + DataModel dm = dataModelService.loadDataModel(request.request().dataModelId()); + DrillCollector collector = new DrillCollector(); + renderWorkbook(dm, request.request(), collector); + + DrillCollector.DrillInfo info = collector.get(request.row(), request.col()); + if (info == null) { + return new DrillResult(null, null, List.of(), List.of()); + } + + String datasetId = info.getDrillView(); + Dataset dataset = + dm.getDatasets().stream() + .filter(d -> d.getId().equals(datasetId)) + .findFirst() + .orElse(null); + if (dataset == null) { + return new DrillResult(datasetId, null, List.of(), List.of()); + } + + // 投影:从组合行中提取该数据集的字段,按主键去重 + List> projected = new ArrayList<>(); + List seenKeys = new ArrayList<>(); + for (Map row : info.getRows()) { + Map projRow = new LinkedHashMap<>(); + StringBuilder keyBuilder = new StringBuilder(); + for (com.codingapi.report.data.dataset.Field field : dataset.getFields()) { + String qualifiedName = datasetId + "." + field.getName(); + Object value = row.get(qualifiedName); + if (value != null) { + projRow.put(field.getName(), value); + if (field.isPrimaryKey()) { + keyBuilder.append(value).append(" "); + } + } + } + String key = + keyBuilder.length() > 0 + ? keyBuilder.toString() + : String.valueOf(projected.size()); + if (!seenKeys.contains(key)) { + seenKeys.add(key); + projected.add(projRow); + } + } + + return new DrillResult( + datasetId, + dataset.getAlias(), + dataset.getFields().stream() + .map(f -> new FieldInfo(f.getName(), f.getAlias())) + .toList(), + projected); + } + + /** 配置 + 模板快照 → 领域对象 → 渲染(render/preview/drill 共用)。 */ + private Workbook renderWorkbook( + DataModel dm, RenderRequest request, DrillCollector drillCollector) { + ReportRenderer renderer = new ReportRenderer(extractors); + + List bindings = RenderDtoConverter.convertBindings(request.cellBindings()); + List loops = RenderDtoConverter.convertLoops(request.loopBlocks()); + List summaries = RenderDtoConverter.convertSummaries(request.summaries()); + + Report report = + Report.builder() + .id("render-" + System.currentTimeMillis()) + .name("报表导出") + .dataModelId(dm.getId()) + .cellBindings(bindings) + .loopBlocks(loops) + .summaries(summaries) + .build(); + + Workbook template = request.template(); + Map paramValues = request.params() != null ? request.params() : Map.of(); + return renderer.render(dm, report, new ParamContext(paramValues), template, drillCollector); + } +} diff --git a/report-engine-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/report-engine-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 9d87101..eb651b8 100644 --- a/report-engine-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/report-engine-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ com.codingapi.report.starter.ReportEngineAutoConfiguration +com.codingapi.report.starter.DataModelMgmtAutoConfiguration diff --git a/report-frontend/README.md b/report-frontend/README.md index a4c01b8..569b43f 100644 --- a/report-frontend/README.md +++ b/report-frontend/README.md @@ -9,7 +9,7 @@ report-frontend/ ├── packages/ │ ├── report-univer/ # @coding-report/report-univer — Univer 封装层 │ ├── report-api/ # @coding-report/report-api — 后端 API 客户端 -│ └── report-engine/ # @coding-report/report-engine — 报表设计器组件库 +│ └── report-engine/ # @coding-report/report-engine — 报表设计器 + 数据源管理组件库 └── apps/ └── app-pc/ # @report-example/app-pc — 演示应用 ``` @@ -61,6 +61,7 @@ Univer 电子表格的 React 封装层,提供: - **`ReportEngine`**:三栏式布局(左数据模型 / 中电子表格 / 右属性面板)。顶部按钮可配置:默认组(导入模板/循环块/报表预览/导出报表/保存报表)受 `enableImport`/`enableLoopBlock`/`enablePreview`/`enableExport`/`enableSave` 控制;`customActions`(左,加竖线分隔)+ `extraActions`(右)注入自定义按钮。预览/导出通过 `renderService` prop 注入 report-api 函数启用。 - **`ReportPreview`**:预览能力组件(参数弹窗 → 渲染 → 预览抽屉 → 反查 → 抽屉内导出),设计器与独立预览页共用。声明式 `config`(引用变化触发预览)+ `onClose` 回调 + ref `exportXlsx` 命令式导出。 - **`useReportPreview`** hook:预览流程逻辑/状态(纯逻辑,JSX 在组件层渲染)。 +- **数据源管理组件**(原 `report-datasource` 已并入本包):`ConnectionForm`(连接配置)/ `DatasetManager`(数据集增删)/ `RelationEditor`(关系编辑)/ `ExploreTree`(表/列探查)+ `useDatasource`/`useExplore` hook,经 `DatasourceService` prop 注入 report-api 实现。数据源类型对齐后端:`DB`/`EXCEL`/`CSV`。 ### app-pc diff --git a/report-frontend/apps/app-pc/src/config/menus.tsx b/report-frontend/apps/app-pc/src/config/menus.tsx index 9f526a5..64a50fb 100644 --- a/report-frontend/apps/app-pc/src/config/menus.tsx +++ b/report-frontend/apps/app-pc/src/config/menus.tsx @@ -1,6 +1,7 @@ -import { HomeOutlined, TableOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, HomeOutlined, TableOutlined } from '@ant-design/icons'; export const menuItems = [ { key: '/', icon: , label: '首页' }, { key: '/reports', icon: , label: '报表管理' }, + { key: '/datamodels', icon: , label: '数据模型' }, ]; diff --git a/report-frontend/apps/app-pc/src/config/routes.tsx b/report-frontend/apps/app-pc/src/config/routes.tsx index 847cc75..b5a67a7 100644 --- a/report-frontend/apps/app-pc/src/config/routes.tsx +++ b/report-frontend/apps/app-pc/src/config/routes.tsx @@ -1,8 +1,9 @@ -import { HomeOutlined, TableOutlined } from '@ant-design/icons'; +import { DatabaseOutlined, HomeOutlined, TableOutlined } from '@ant-design/icons'; import HomePage from '@/pages/home'; import ReportsPage from '@/pages/reports'; import AppReport from '@/pages/engine'; import AppPreview from '@/pages/preview'; +import DataModelsPage from '@/pages/datamodels'; import React from 'react'; export interface RouteConfig { @@ -25,6 +26,12 @@ const routes: RouteConfig[] = [ icon: , element: , }, + { + path: '/datamodels', + name: '数据模型', + icon: , + element: , + }, { path: '/engine', name: '报表设计器', diff --git a/report-frontend/apps/app-pc/src/pages/datamodels.tsx b/report-frontend/apps/app-pc/src/pages/datamodels.tsx new file mode 100644 index 0000000..5723576 --- /dev/null +++ b/report-frontend/apps/app-pc/src/pages/datamodels.tsx @@ -0,0 +1,216 @@ +import { useEffect, useState } from 'react'; +import { Layout, List, Tabs, Typography, Empty, Descriptions, Spin, message } from 'antd'; +import { DatabaseOutlined } from '@ant-design/icons'; +import { + listDataModelBriefs, + getDataModel, +} from '@coding-report/report-api'; +import type { + DataModelBrief, + DataModelInfo, + DataModelDataset, + DataModelSource, +} from '@coding-report/report-api'; +import { + DatasetManager, + RelationEditor, +} from '@coding-report/report-engine'; +import type { + DatasetDef, + DataSourceConfig, + Relationship, +} from '@coding-report/report-engine'; + +const { Sider, Content } = Layout; +const { Title, Text } = Typography; + +/** + * 后端 DatasetDTO(kind=TABLE/UNION)→ report-engine 的 DatasetDef(kind=PHYSICAL/UNION)。 + * 两个端点的 dataset 字段集不同(configs/{id} 精简视图无 kind,datamodels/{id} 完整 DTO 有), + * 此处统一适配:kind 缺失或 TABLE 都按物理表处理。 + */ +const toDatasetDef = (d: DataModelDataset): DatasetDef => { + const fields = (d.fields ?? []).map((f) => ({ + name: f.name, + alias: f.alias, + dataType: f.dataType, + primaryKey: f.primaryKey, + })); + if (d.kind === 'UNION') { + return { + kind: 'UNION', + id: d.id, + alias: d.alias, + baseDatasetIds: (d.members ?? []).map((m) => m.datasetId), + fields, + }; + } + return { + kind: 'PHYSICAL', + id: d.id, + alias: d.alias, + sourceId: d.datasourceId ?? '', + table: d.sourceTable ?? '', + fields, + }; +}; + +/** 后端 DataSourceDTO → report-engine 的 DataSourceConfig(来源列只需 id/name/type) */ +const toDataSourceConfig = (s: DataModelSource): DataSourceConfig => ({ + id: s.id, + name: s.name, + type: s.type, + options: s.config, +}); + +const DataModelsPage = () => { + const [briefs, setBriefs] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedId, setSelectedId] = useState(null); + const [model, setModel] = useState(null); + const [modelLoading, setModelLoading] = useState(false); + + useEffect(() => { + let active = true; + setLoading(true); + listDataModelBriefs() + .then((list) => { + if (!active) return; + setBriefs(list); + if (list.length > 0) setSelectedId(list[0].id); + }) + .catch((e) => { + message.error(`加载数据模型列表失败: ${(e as Error).message}`); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + }; + }, []); + + useEffect(() => { + if (!selectedId) { + setModel(null); + return; + } + let active = true; + setModelLoading(true); + getDataModel(selectedId) + .then((data) => { + if (!active) return; + setModel(data); + }) + .catch((e) => { + message.error(`加载数据模型详情失败: ${(e as Error).message}`); + }) + .finally(() => { + if (active) setModelLoading(false); + }); + return () => { + active = false; + }; + }, [selectedId]); + + const datasets: DatasetDef[] = (model?.datasets ?? []).map(toDatasetDef); + const relationships = (model?.relationships ?? []) as unknown as Relationship[]; + const dataSources: DataSourceConfig[] = (model?.datasources ?? []).map( + toDataSourceConfig, + ); + + return ( + + +

    数据模型
    + + {briefs.length === 0 && !loading ? ( + + ) : ( + ( + setSelectedId(item.id)} + > + } + title={item.name} + description={{item.id}} + /> + + )} + /> + )} + + + + {!selectedId ? ( + + ) : modelLoading ? ( +
    + +
    + ) : ( + + ), + }, + { + key: 'relations', + label: '关系', + children: ( + + ), + }, + { + key: 'params', + label: '参数', + children: , + }, + ]} + /> + )} +
    + +
    属性
    + {selectedId && model ? ( + + {selectedId} + + {briefs.find((b) => b.id === selectedId)?.name ?? '-'} + + + {model.datasets.length} + + + {model.relationships.length} + + + ) : ( + + 未选中数据模型 + + )} +
    + + ); +}; + +export default DataModelsPage; diff --git a/report-frontend/apps/app-pc/src/pages/engine.tsx b/report-frontend/apps/app-pc/src/pages/engine.tsx index 6061fe5..578b852 100644 --- a/report-frontend/apps/app-pc/src/pages/engine.tsx +++ b/report-frontend/apps/app-pc/src/pages/engine.tsx @@ -6,7 +6,7 @@ import type { Dataset, ExpressionCatalog, Relationship, - ReportConfig, + ReportDTO, ReportEngineHandle, } from '@coding-report/report-engine'; import { ReportEngine } from '@coding-report/report-engine'; @@ -25,7 +25,7 @@ import { // ─── 页面组件 ────────────────────────────────── /** 加载的报表配置(附带后端注入的数据模型信息) */ -interface LoadedReportConfig extends ReportConfig { +interface LoadedReportConfig extends ReportDTO { dataModel?: DataModelInfo; } @@ -101,11 +101,11 @@ const AppReport = () => { message.warning('表格为空,无配置可打印'); return; } - console.log('[ReportConfig object]', config); - console.log('[ReportConfig JSON]\n', JSON.stringify(config, null, 2)); + console.log('[ReportDTO object]', config); + console.log('[ReportDTO JSON]\n', JSON.stringify(config, null, 2)); }; - const handleSaveReport = async (config: ReportConfig): Promise => { + const handleSaveReport = async (config: ReportDTO): Promise => { return saveReportConfig({ ...config, dataModelId }); }; diff --git a/report-frontend/apps/app-pc/src/pages/preview.tsx b/report-frontend/apps/app-pc/src/pages/preview.tsx index 50bda1c..b3b13d0 100644 --- a/report-frontend/apps/app-pc/src/pages/preview.tsx +++ b/report-frontend/apps/app-pc/src/pages/preview.tsx @@ -6,8 +6,8 @@ import type { CellBinding, LoopBlock, RenderConfig, - ReportConfig, - ReportParam, + ReportDTO, + ParamDTO, SummaryRow, } from '@coding-report/report-engine'; import { ReportPreview } from '@coding-report/report-engine'; @@ -21,7 +21,7 @@ import { } from '@coding-report/report-api'; /** 加载的报表配置(附带后端注入的数据模型信息) */ -interface LoadedReportConfig extends ReportConfig { +interface LoadedReportConfig extends ReportDTO { dataModel?: DataModelInfo; } @@ -47,11 +47,12 @@ const AppPreview = () => { if (!active) return; if (config.name) setReportName(config.name); setPreviewConfig({ + dataModelId: config.dataModelId, bindings: config.cellBindings as CellBinding[], loops: config.loopBlocks as LoopBlock[], summaries: config.summaries as SummaryRow[], workbook: config.template as ExcelWorkbook, - params: (config.params as ReportParam[]) ?? [], + params: (config.params as ParamDTO[]) ?? [], }); } catch (e) { message.error(`加载报表失败: ${e}`); diff --git a/report-frontend/packages/report-api/src/datamodel.ts b/report-frontend/packages/report-api/src/datamodel.ts index d52380a..78acbf1 100644 --- a/report-frontend/packages/report-api/src/datamodel.ts +++ b/report-frontend/packages/report-api/src/datamodel.ts @@ -17,10 +17,32 @@ export interface DataModelField { primaryKey?: boolean; } +/** UNION 成员(对齐后端 UnionMemberDTO:统一列名 → 成员实际字段名) */ +export interface UnionMember { + datasetId: string; + mapping?: Record; +} + +/** + * 数据集(兼容两种端点返回,字段按需可选): + * - {@code GET /api/report/configs/{id}} 附带的 {@code dataModel.datasets}:精简视图 + * {@code { id, alias, dataSourceType, fields }}(无 kind/datasourceId/sourceTable/members) + * - {@code GET /api/datamodels/{id}} 返回的 {@code datasets}:完整 DatasetDTO + * {@code { id, alias, kind, datasourceId, sourceTable, fields, members }}(无 dataSourceType) + */ export interface DataModelDataset { id: string; alias?: string; + /** 数据源类型(仅 configs/{id} 精简视图返回) */ dataSourceType?: DataSourceType; + /** 数据集形态(仅 datamodels/{id} 完整 DTO 返回):TABLE | UNION */ + kind?: 'TABLE' | 'UNION'; + /** 物理表所属数据源 id(kind=TABLE) */ + datasourceId?: string; + /** 物理表名/查询(kind=TABLE) */ + sourceTable?: string; + /** UNION 成员(kind=UNION) */ + members?: UnionMember[]; fields: DataModelField[]; } @@ -35,8 +57,19 @@ export interface RelationshipInfo { joinType: JoinType; } +/** 数据源连接(对齐后端 DataSourceDTO:{@code { id, name, type, config }},敏感字段后端已脱敏) */ +export interface DataModelSource { + id: string; + name: string; + type: DataSourceType; + /** 连接配置(含 path/url/凭证等,后端出口已脱敏) */ + config?: Record; +} + /** 报表所用的数据模型:数据集 + 数据关系(报表参数属报表级,前端管理,不在此) */ export interface DataModelInfo { datasets: DataModelDataset[]; relationships: RelationshipInfo[]; + /** 数据源连接列表(仅 GET /api/datamodels/{id} 完整返回;精简视图不含) */ + datasources?: DataModelSource[]; } diff --git a/report-frontend/packages/report-api/src/dataset.ts b/report-frontend/packages/report-api/src/dataset.ts index b6d64d9..9fffda6 100644 --- a/report-frontend/packages/report-api/src/dataset.ts +++ b/report-frontend/packages/report-api/src/dataset.ts @@ -30,9 +30,9 @@ export interface DatasetPreview { // API // ============================================================ -/** 获取所有数据集列表(含字段定义) */ -export async function fetchDatasets(): Promise { - const res = await http.get('/datasets'); +/** 获取指定数据模型下的数据集列表(含字段定义) */ +export async function fetchDatasets(dataModelId: string): Promise { + const res = await http.get('/datasets', { params: { dataModelId } }); return res.data.list; } diff --git a/report-frontend/packages/report-api/src/datasource.ts b/report-frontend/packages/report-api/src/datasource.ts new file mode 100644 index 0000000..fd8ad29 --- /dev/null +++ b/report-frontend/packages/report-api/src/datasource.ts @@ -0,0 +1,82 @@ +import http from './http'; +import type { DataModelInfo } from './datamodel'; +import type { DataModelBrief } from './report'; + +// ============================================================ +// Types +// ============================================================ + +/** 数据源连通性测试结果 */ +export interface TestResult { + ok: boolean; + message: string; + latencyMs: number; +} + +/** 表字段元信息(探测数据源表结构时返回) */ +export interface ColumnMeta { + name: string; + type: string; + primaryKey: boolean; +} + +/** 数据源测试请求体:既有数据源 id 或完整配置二选一 */ +export interface DataSourceTestRequest { + sourceId?: string; + config?: Record; +} + +// ============================================================ +// API +// ============================================================ + +/** 数据模型列表(id + name) */ +export async function listDataModelBriefs(): Promise { + const res = await http.get('/datamodels'); + return res.data.list; +} + +/** 获取数据模型详情(数据集 + 数据关系) */ +export async function getDataModel(id: string): Promise { + const res = await http.get(`/datamodels/${id}`); + return res.data as DataModelInfo; +} + +/** 创建数据模型,返回新数据模型 id */ +export async function createDataModel(data: DataModelInfo): Promise { + const res = await http.post('/datamodels', data); + return res.data as string; +} + +/** 更新数据模型,返回数据模型 id */ +export async function updateDataModel(id: string, data: DataModelInfo): Promise { + const res = await http.put(`/datamodels/${id}`, data); + return res.data as string; +} + +/** 删除指定数据模型 */ +export async function deleteDataModel(id: string): Promise { + await http.delete(`/datamodels/${id}`); +} + +/** 测试数据源连通性(传入 sourceId 或完整 config) */ +export async function testDataSource(req: DataSourceTestRequest): Promise { + const res = await http.post('/datasources/test', req); + return res.data as TestResult; +} + +/** 探测数据源下所有表名 */ +export async function exploreTables(sourceId: string): Promise { + const res = await http.get('/datasources/tables', { + params: { sourceId }, + }); + return res.data.list; +} + +/** 探测指定表的字段元信息 */ +export async function exploreColumns(sourceId: string, table: string): Promise { + const res = await http.get('/datasources/columns', { + params: { sourceId, table }, + }); + return res.data.list; +} diff --git a/report-frontend/packages/report-api/src/index.ts b/report-frontend/packages/report-api/src/index.ts index 4a64ab8..91cdc78 100644 --- a/report-frontend/packages/report-api/src/index.ts +++ b/report-frontend/packages/report-api/src/index.ts @@ -32,6 +32,19 @@ export type { DataModelInfo, DataModelDataset, DataModelField, + DataModelSource, + UnionMember, RelationshipInfo, FieldRefInfo, } from './datamodel'; +export { + listDataModelBriefs, + getDataModel, + createDataModel, + updateDataModel, + deleteDataModel, + testDataSource, + exploreTables, + exploreColumns, +} from './datasource'; +export type { TestResult, ColumnMeta, DataSourceTestRequest } from './datasource'; diff --git a/report-frontend/packages/report-api/src/report.ts b/report-frontend/packages/report-api/src/report.ts index 1b56546..f44e370 100644 --- a/report-frontend/packages/report-api/src/report.ts +++ b/report-frontend/packages/report-api/src/report.ts @@ -41,6 +41,8 @@ export interface RenderBindingDTO { } export interface RenderRequest { + /** 数据模型 ID(后端按此加载数据模型,必传) */ + dataModelId: string; cellBindings: RenderBindingDTO[]; loopBlocks: unknown[]; summaries: unknown[]; diff --git a/report-frontend/packages/report-api/test/datasource.test.ts b/report-frontend/packages/report-api/test/datasource.test.ts new file mode 100644 index 0000000..919ca11 --- /dev/null +++ b/report-frontend/packages/report-api/test/datasource.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from '@rstest/core'; +import http from '../src/http'; +import { + listDataModelBriefs, + getDataModel, + createDataModel, + updateDataModel, + deleteDataModel, + testDataSource, + exploreTables, + exploreColumns, +} from '../src/datasource'; + +vi.mock('../src/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})); + +const mockedHttp = vi.mocked(http); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('datasource api', () => { + it('listDataModelBriefs 请求 /datamodels 并返回 list', async () => { + mockedHttp.get.mockResolvedValueOnce({ + data: { total: 1, list: [{ id: 'dm1', name: '默认模型' }] }, + } as never); + const result = await listDataModelBriefs(); + expect(mockedHttp.get).toHaveBeenCalledWith('/datamodels'); + expect(result).toEqual([{ id: 'dm1', name: '默认模型' }]); + }); + + it('getDataModel 请求 /datamodels/{id} 并返回 data', async () => { + const info = { datasets: [], relationships: [] }; + mockedHttp.get.mockResolvedValueOnce({ data: info } as never); + const result = await getDataModel('dm1'); + expect(mockedHttp.get).toHaveBeenCalledWith('/datamodels/dm1'); + expect(result).toEqual(info); + }); + + it('createDataModel POST /datamodels 并返回 id', async () => { + const payload = { datasets: [], relationships: [] }; + mockedHttp.post.mockResolvedValueOnce({ data: 'dm-new' } as never); + const id = await createDataModel(payload); + expect(mockedHttp.post).toHaveBeenCalledWith('/datamodels', payload); + expect(id).toBe('dm-new'); + }); + + it('updateDataModel PUT /datamodels/{id} 并返回 id', async () => { + const payload = { datasets: [], relationships: [] }; + mockedHttp.put.mockResolvedValueOnce({ data: 'dm1' } as never); + const id = await updateDataModel('dm1', payload); + expect(mockedHttp.put).toHaveBeenCalledWith('/datamodels/dm1', payload); + expect(id).toBe('dm1'); + }); + + it('deleteDataModel DELETE /datamodels/{id}', async () => { + mockedHttp.delete.mockResolvedValueOnce({ data: { success: true } } as never); + await deleteDataModel('dm1'); + expect(mockedHttp.delete).toHaveBeenCalledWith('/datamodels/dm1'); + }); + + it('testDataSource POST /datasources/test', async () => { + const req = { sourceId: 'ds1' }; + const result = { ok: true, message: 'ok', latencyMs: 12 }; + mockedHttp.post.mockResolvedValueOnce({ data: result } as never); + const r = await testDataSource(req); + expect(mockedHttp.post).toHaveBeenCalledWith('/datasources/test', req); + expect(r).toEqual(result); + }); + + it('exploreTables 传 sourceId 参数', async () => { + mockedHttp.get.mockResolvedValueOnce({ + data: { total: 2, list: ['t1', 't2'] }, + } as never); + const r = await exploreTables('ds1'); + expect(mockedHttp.get).toHaveBeenCalledWith('/datasources/tables', { + params: { sourceId: 'ds1' }, + }); + expect(r).toEqual(['t1', 't2']); + }); + + it('exploreColumns 传 sourceId 与 table 参数', async () => { + const cols = [ + { name: 'id', type: 'BIGINT', primaryKey: true }, + { name: 'name', type: 'VARCHAR', primaryKey: false }, + ]; + mockedHttp.get.mockResolvedValueOnce({ data: { total: 2, list: cols } } as never); + const r = await exploreColumns('ds1', 'users'); + expect(mockedHttp.get).toHaveBeenCalledWith('/datasources/columns', { + params: { sourceId: 'ds1', table: 'users' }, + }); + expect(r).toEqual(cols); + }); +}); diff --git a/report-frontend/packages/report-engine/README.md b/report-frontend/packages/report-engine/README.md index 48ede8e..9040d3b 100644 --- a/report-frontend/packages/report-engine/README.md +++ b/report-frontend/packages/report-engine/README.md @@ -7,7 +7,7 @@ - **`ReportEngine`** — 三栏式报表设计器(左数据模型 / 中电子表格 / 右属性面板)。 - **`ReportPreview`** — 预览能力组件(参数弹窗 → 渲染 → 预览抽屉 → 反查 → 抽屉内导出)。 - **`useReportPreview`** — 预览流程 hook(逻辑/状态)。 -- 领域类型:`CellBinding` / `LoopBlock` / `SummaryRow` / `ReportParam` / `ReportValue` / `ReportConfig` / `RenderConfig` / `RenderService` 等。 +- 领域类型:`CellBinding` / `LoopBlock` / `SummaryRow` / `ParamDTO` / `ReportValue` / `ReportDTO` / `RenderConfig` / `RenderService` 等(`ReportDTO`/`ParamDTO` 与后端同名对齐)。 ## ReportEngine diff --git a/report-frontend/packages/report-engine/src/components/data-model/index.tsx b/report-frontend/packages/report-engine/src/components/data-model/index.tsx index d646b63..9091af4 100644 --- a/report-frontend/packages/report-engine/src/components/data-model/index.tsx +++ b/report-frontend/packages/report-engine/src/components/data-model/index.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Tabs } from 'antd'; -import type { Dataset, Relationship, ReportParam } from '@/types'; +import type { Dataset, Relationship, ParamDTO } from '@/types'; import DatasetTree from '@/components/dataset-tree'; import RelationshipList from './relationship-list'; import ParamManager from './param-manager'; @@ -8,8 +8,8 @@ import ParamManager from './param-manager'; interface DataModelPanelProps { datasets: Dataset[]; relationships: Relationship[]; - params: ReportParam[]; - onParamsChange: (params: ReportParam[]) => void; + params: ParamDTO[]; + onParamsChange: (params: ParamDTO[]) => void; } /** diff --git a/report-frontend/packages/report-engine/src/components/data-model/param-manager.tsx b/report-frontend/packages/report-engine/src/components/data-model/param-manager.tsx index 9656e7b..2789d30 100644 --- a/report-frontend/packages/report-engine/src/components/data-model/param-manager.tsx +++ b/report-frontend/packages/report-engine/src/components/data-model/param-manager.tsx @@ -1,21 +1,21 @@ import { useState, useCallback } from 'react'; import { Button, List, Tag, Empty, Popconfirm, Typography } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined, HolderOutlined } from '@ant-design/icons'; -import type { ReportParam } from '@/types'; +import type { ParamDTO } from '@/types'; import { genId, dataTypeLabel } from '@/types'; import ParamModal from './param-modal'; const { Text } = Typography; interface ParamManagerProps { - params: ReportParam[]; - onChange: (params: ReportParam[]) => void; + params: ParamDTO[]; + onChange: (params: ParamDTO[]) => void; } /** 报表参数管理:列表展示 + 弹窗编辑 + 拖拽到报表画布。 */ const ParamManager: React.FC = ({ params, onChange }) => { const [modalOpen, setModalOpen] = useState(false); - const [editingParam, setEditingParam] = useState(null); + const [editingParam, setEditingParam] = useState(null); const existingNames = params.map((p) => p.name); @@ -26,12 +26,12 @@ const ParamManager: React.FC = ({ params, onChange }) => { setModalOpen(true); }; - const openEdit = (param: ReportParam) => { + const openEdit = (param: ParamDTO) => { setEditingParam(param); setModalOpen(true); }; - const handleConfirm = (param: ReportParam) => { + const handleConfirm = (param: ParamDTO) => { if (editingParam) { onChange(params.map((p) => (p.id === editingParam.id ? { ...p, ...param } : p))); } else { @@ -50,7 +50,7 @@ const ParamManager: React.FC = ({ params, onChange }) => { // ─── 拖拽 ────────────────────────────────── - const handleDragStart = (e: React.DragEvent, param: ReportParam) => { + const handleDragStart = (e: React.DragEvent, param: ParamDTO) => { const dragData = { type: 'report-param', paramName: param.name, diff --git a/report-frontend/packages/report-engine/src/components/data-model/param-modal.tsx b/report-frontend/packages/report-engine/src/components/data-model/param-modal.tsx index ee78ed3..20509ad 100644 --- a/report-frontend/packages/report-engine/src/components/data-model/param-modal.tsx +++ b/report-frontend/packages/report-engine/src/components/data-model/param-modal.tsx @@ -1,16 +1,16 @@ import { useEffect, useState } from 'react'; import { Modal, Form, Input, Select, message } from 'antd'; -import type { ReportParam, DataType } from '@/types'; +import type { ParamDTO, DataType } from '@/types'; import { DATA_TYPE_OPTIONS } from '@/types'; interface ParamModalProps { open: boolean; /** 编辑模式:传入已有参数(预填表单,name 不可改);新增模式:null */ - editingParam: ReportParam | null; + editingParam: ParamDTO | null; /** 已有参数名列表,用于唯一性校验 */ existingNames: string[]; onClose: () => void; - onConfirm: (param: ReportParam) => void; + onConfirm: (param: ParamDTO) => void; } const NAME_RE = /^[a-zA-Z_]\w*$/; @@ -61,7 +61,7 @@ const ParamModal: React.FC = ({ return; } - const param: ReportParam = { + const param: ParamDTO = { id: editingParam?.id ?? '', name, alias: alias || undefined, diff --git a/report-frontend/packages/report-engine/src/components/data-model/relationship-list.tsx b/report-frontend/packages/report-engine/src/components/data-model/relationship-list.tsx index 5373a49..8f89e39 100644 --- a/report-frontend/packages/report-engine/src/components/data-model/relationship-list.tsx +++ b/report-frontend/packages/report-engine/src/components/data-model/relationship-list.tsx @@ -12,11 +12,9 @@ interface RelationshipListProps { // ─── 数据源类型标签 ────────────────────────────────────── const SOURCE_COLORS: Record = { - CSV: 'green', - JSON: 'orange', DB: 'blue', - API: 'purple', EXCEL: 'cyan', + CSV: 'green', }; function getSourceTag(sourceType?: DataSourceType): React.ReactNode { diff --git a/report-frontend/packages/report-engine/src/components/dataset-tree.tsx b/report-frontend/packages/report-engine/src/components/dataset-tree.tsx index fa82287..6afee43 100644 --- a/report-frontend/packages/report-engine/src/components/dataset-tree.tsx +++ b/report-frontend/packages/report-engine/src/components/dataset-tree.tsx @@ -18,11 +18,9 @@ interface FieldDragData { // ─── 数据源类型标签 ────────────────────────────────────── const SOURCE_COLORS: Record = { - CSV: 'green', - JSON: 'orange', DB: 'blue', - API: 'purple', EXCEL: 'cyan', + CSV: 'green', }; function getSourceTag(sourceType?: DataSourceType): React.ReactNode { diff --git a/report-frontend/packages/report-engine/src/components/datasource/connection-form.tsx b/report-frontend/packages/report-engine/src/components/datasource/connection-form.tsx new file mode 100644 index 0000000..74b324c --- /dev/null +++ b/report-frontend/packages/report-engine/src/components/datasource/connection-form.tsx @@ -0,0 +1,100 @@ +import { Button, Form, Input, Select, App as AntdApp } from 'antd'; +import { useState } from 'react'; +import type { ConnectionFormProps, DataSourceConfig, DataSourceType } from '@/types'; + +const DATASOURCE_TYPE_OPTIONS: Array<{ label: string; value: DataSourceType }> = [ + { label: 'DB', value: 'DB' }, + { label: 'EXCEL', value: 'EXCEL' }, + { label: 'CSV', value: 'CSV' }, +]; + +/** + * 数据源连接配置表单(受控 + antd Form)。 + * 字段:name / type / url / username / password / options(JSON 文本) + * 「测试连接」依赖注入的 service;未注入时按钮禁用。 + */ +export default function ConnectionForm({ + value, + onChange, + service, + testResult, + onTestResultChange, + disabled, +}: ConnectionFormProps) { + const { message } = AntdApp.useApp(); + const [testing, setTesting] = useState(false); + + const handleValuesChange = (_: unknown, all: Partial) => { + onChange?.(all); + }; + + const handleTest = async () => { + if (!service?.testConnection || !value) { + onTestResultChange?.(null); + return; + } + setTesting(true); + try { + const result = await service.testConnection({ + type: value.type, + url: value.url, + username: value.username, + password: value.password, + options: value.options, + }); + onTestResultChange?.(result); + if (result.ok) { + message.success('连接成功'); + } else { + message.error(result.message ?? '连接失败'); + } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + onTestResultChange?.({ ok: false, message: msg }); + message.error(msg); + } finally { + setTesting(false); + } + }; + + const merged: Partial = value ?? {}; + const canTest = !!service?.testConnection && !!value?.type && !disabled && !testing; + + return ( +
    + + + + + + + + + + + + + + + + + + {testResult ? ( + + {testResult.ok ? '连接成功' : `失败:${testResult.message ?? ''}`} + + ) : null} + + + ); +} diff --git a/report-frontend/packages/report-engine/src/components/datasource/dataset-manager.tsx b/report-frontend/packages/report-engine/src/components/datasource/dataset-manager.tsx new file mode 100644 index 0000000..0a50b7c --- /dev/null +++ b/report-frontend/packages/report-engine/src/components/datasource/dataset-manager.tsx @@ -0,0 +1,178 @@ +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table } from 'antd'; +import { useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import type { DatasetDef, DatasetManagerProps, PhysicalDataset } from '@/types'; + +/** + * 数据集管理:物理表数据集 + UNION 派生数据集。 + * 列表展示 + 新建(物理/UNION)/编辑/删除。 + * 字段编辑(fields)此版暂以只读展示,后续接入 ExploreTree 联动选择。 + */ +export default function DatasetManager({ + datasets, + dataSources, + onChange, +}: DatasetManagerProps) { + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + + const dataSourceMap = new Map(dataSources.map((d) => [d.id, d])); + + const columns: ColumnsType = [ + { + title: '别名', + dataIndex: 'alias', + key: 'alias', + render: (_, r) => r.alias ?? r.id, + }, + { + title: '类型', + key: 'kind', + render: (_, r) => (r.kind === 'PHYSICAL' ? '物理表' : 'UNION'), + }, + { + title: '来源', + key: 'source', + render: (_, r) => { + if (r.kind === 'PHYSICAL') { + const ds = dataSourceMap.get(r.sourceId); + return `${ds?.name ?? r.sourceId}.${r.table}`; + } + return `${r.baseDatasetIds.length} 个数据集`; + }, + }, + { + title: '字段数', + key: 'fields', + render: (_, r) => r.fields.length, + }, + { + title: '操作', + key: 'actions', + render: (_, r) => ( + + { + setEditing(r); + form.setFieldsValue(r); + setModalOpen(true); + }} + > + 编辑 + + { + const next = datasets.filter((d) => d.id !== r.id); + onChange?.(next); + }} + > + 删除 + + + ), + }, + ]; + + const handleAdd = (kind: 'PHYSICAL' | 'UNION') => { + const newId = `ds-${Date.now()}`; + setEditing(null); + if (kind === 'PHYSICAL') { + const base: PhysicalDataset = { + id: newId, + alias: '', + sourceId: dataSources[0]?.id ?? '', + table: '', + fields: [], + }; + form.setFieldsValue({ ...base, kind }); + } else { + form.setFieldsValue({ + id: newId, + alias: '', + baseDatasetIds: [], + fields: [], + kind, + }); + } + setModalOpen(true); + }; + + const handleOk = async () => { + const values = (await form.validateFields()) as DatasetDef; + const next = editing + ? datasets.map((d) => (d.id === values.id ? values : d)) + : [...datasets, values]; + onChange?.(next); + setModalOpen(false); + }; + + return ( + <> + + + + + + rowKey="id" + columns={columns} + dataSource={datasets} + pagination={false} + size="small" + /> + setModalOpen(false)} + destroyOnClose + > +
    + + + + + + p?.kind !== n?.kind}> + {({ getFieldValue }) => { + const kind = getFieldValue('kind'); + if (kind === 'PHYSICAL') { + return ( + <> + + + + + ); + } + return ( + + + + + + + + + + +