From c3c1c51e1d803f173cd41d52429eb685ba8ace1e Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 25 Jun 2026 11:52:23 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=E6=95=B0=E6=8D=AE=E6=BA=90?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=90=8E=E7=AB=AF=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD=20+=20starter=20service=20=E5=88=86=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 report-engine-datasource 模块(依赖 framework,不反向): - CredentialService: AES/GCM 凭证加解密 + 脱敏(JDK javax.crypto) - DataModelConfigConverter: domain↔DTO 双向转换,加解密在转换处 - DbDataExtractor: JDBC 提取 + 连接测试 + 表/列探查 - DataModelMgmtAutoConfiguration: 注册能力 Bean(提取器/凭证/converter) framework 抽象层(零 Spring): - DataModelRepository 接口 + DataModelConfig 实体 + DataModelDtos DTO record - DataExtractor 加 default 方法(test/listTables/listColumns)+ TestResult/ColumnMeta starter 装配改造 + service 分层: - 新建 service 包: DataModelService/DataSourceService/ReportConfigService/ReportRenderService - 5 个 Controller 改薄(业务下沉 service,含从 datasource 迁回的 DataModelMgmt/DataSourceController) - 渲染链路从单例 DataModel 改为按 dataModelId 从仓库加载 + List 派发 - RenderRequest 加 dataModelId;删除 DataModelController example: - InMemoryDataModelRepository + RepositoryConfig 注册 - DatasetConfig 重构为 DataModelSeeder(写仓库,删 @Bean DataModel/CsvDataExtractor) 验证: 全量 install + framework/datasource 单测 + example 启动 + curl 数据源/渲染 API Co-Authored-By: Claude --- pom.xml | 9 + report-engine-datasource/pom.xml | 36 +++ .../DataModelMgmtAutoConfiguration.java | 47 ++++ .../converter/DataModelConfigConverter.java | 258 ++++++++++++++++++ .../credential/CredentialService.java | 151 ++++++++++ .../report/datasource/db/DbDataExtractor.java | 206 ++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + .../DataModelConfigConverterTest.java | 110 ++++++++ .../credential/CredentialServiceTest.java | 62 +++++ .../datasource/db/DbDataExtractorTest.java | 124 +++++++++ ...atasetConfig.java => DataModelSeeder.java} | 120 ++++---- .../report/config/RepositoryConfig.java | 7 + .../InMemoryDataModelRepository.java | 66 +++++ .../report/config/DataModelConfig.java | 50 ++++ .../report/config/dto/DataModelDtos.java | 60 ++++ .../report/data/datasource/ColumnMeta.java | 14 + .../report/data/datasource/DataExtractor.java | 30 ++ .../report/data/datasource/TestResult.java | 13 + .../repository/DataModelRepository.java | 26 ++ report-engine-starter/pom.xml | 4 + .../ReportEngineAutoConfiguration.java | 73 ++++- .../controller/DataModelController.java | 38 --- .../controller/DataModelMgmtController.java | 81 ++++++ .../controller/DataSourceController.java | 52 ++++ .../starter/controller/DatasetController.java | 128 ++------- .../controller/ReportConfigController.java | 39 +-- .../controller/ReportRenderController.java | Bin 8205 -> 2874 bytes .../report/starter/dto/DatasetDtos.java | 21 ++ .../report/starter/dto/RenderDtos.java | 23 +- .../starter/service/DataModelService.java | 178 ++++++++++++ .../starter/service/DataSourceService.java | 117 ++++++++ .../starter/service/ReportConfigService.java | 50 ++++ .../starter/service/ReportRenderService.java | 135 +++++++++ 33 files changed, 2094 insertions(+), 235 deletions(-) create mode 100644 report-engine-datasource/pom.xml create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/converter/DataModelConfigConverter.java create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/credential/CredentialService.java create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/db/DbDataExtractor.java create mode 100644 report-engine-datasource/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports create mode 100644 report-engine-datasource/src/test/java/com/codingapi/report/datasource/converter/DataModelConfigConverterTest.java create mode 100644 report-engine-datasource/src/test/java/com/codingapi/report/datasource/credential/CredentialServiceTest.java create mode 100644 report-engine-datasource/src/test/java/com/codingapi/report/datasource/db/DbDataExtractorTest.java rename report-engine-example/src/main/java/com/example/report/config/{DatasetConfig.java => DataModelSeeder.java} (75%) create mode 100644 report-engine-example/src/main/java/com/example/report/repository/InMemoryDataModelRepository.java create mode 100644 report-engine-framework/src/main/java/com/codingapi/report/config/DataModelConfig.java create mode 100644 report-engine-framework/src/main/java/com/codingapi/report/config/dto/DataModelDtos.java create mode 100644 report-engine-framework/src/main/java/com/codingapi/report/data/datasource/ColumnMeta.java create mode 100644 report-engine-framework/src/main/java/com/codingapi/report/data/datasource/TestResult.java create mode 100644 report-engine-framework/src/main/java/com/codingapi/report/repository/DataModelRepository.java delete mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelController.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelMgmtController.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataSourceController.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/dto/DatasetDtos.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataModelService.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataSourceService.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportConfigService.java create mode 100644 report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportRenderService.java diff --git a/pom.xml b/pom.xml index 802336b..c06916c 100644 --- a/pom.xml +++ b/pom.xml @@ -83,6 +83,12 @@ ${project.version} + + com.codingapi.report + report-engine-datasource + ${project.version} + + org.apache.poi poi-ooxml @@ -257,6 +263,7 @@ report-engine-example report-engine-framework report-engine-excel + report-engine-datasource report-engine-starter @@ -267,6 +274,7 @@ report-engine-framework report-engine-excel + report-engine-datasource report-engine-starter @@ -317,6 +325,7 @@ report-engine-framework report-engine-excel + report-engine-datasource report-engine-starter diff --git a/report-engine-datasource/pom.xml b/report-engine-datasource/pom.xml new file mode 100644 index 0000000..833d4cd --- /dev/null +++ b/report-engine-datasource/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.codingapi.report + report-engine-parent + 0.0.1 + + + report-engine-datasource + report-engine-datasource + 数据源管理模块:连接管理 + 凭证加密/脱敏 + 连接测试/元数据探查 + DB 提取器实现 + + + 17 + 17 + UTF-8 + + + + + com.codingapi.report + report-engine-framework + + + org.springframework.boot + spring-boot-starter + + + com.h2database + h2 + test + + + diff --git a/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java new file mode 100644 index 0000000..2f0222e --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java @@ -0,0 +1,47 @@ +package com.codingapi.report.datasource; + +import com.codingapi.report.data.datasource.DataExtractor; +import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.datasource.converter.DataModelConfigConverter; +import com.codingapi.report.datasource.credential.CredentialService; +import com.codingapi.report.datasource.db.DbDataExtractor; +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 DataModelConfigConverter dataModelConfigConverter(CredentialService credentials) { + return new DataModelConfigConverter(credentials); + } + + @Bean + @ConditionalOnMissingBean + public DbDataExtractor dbDataExtractor() { + return new DbDataExtractor(); + } + + /** CSV 提取器(framework 内置,Bean 注册下放至此,example 不再单独声明)。 */ + @Bean + @ConditionalOnMissingBean + public CsvDataExtractor csvDataExtractor() { + return new CsvDataExtractor(); + } +} diff --git a/report-engine-datasource/src/main/java/com/codingapi/report/datasource/converter/DataModelConfigConverter.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/converter/DataModelConfigConverter.java new file mode 100644 index 0000000..81a05ab --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/converter/DataModelConfigConverter.java @@ -0,0 +1,258 @@ +package com.codingapi.report.datasource.converter; + +import com.codingapi.report.config.DataModelConfig; +import com.codingapi.report.config.dto.ConfigDtos.FieldRefDTO; +import com.codingapi.report.config.dto.DataModelDtos.DataSourceDTO; +import com.codingapi.report.config.dto.DataModelDtos.DatasetDTO; +import com.codingapi.report.config.dto.DataModelDtos.FieldDTO; +import com.codingapi.report.config.dto.DataModelDtos.RelationshipDTO; +import com.codingapi.report.config.dto.DataModelDtos.UnionMemberDTO; +import com.codingapi.report.data.datamodel.DataModel; +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.DataSourceType; +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.datasource.credential.CredentialService; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * {@link DataModel}(运行时领域对象)与 {@link DataModelConfig}(持久化实体)互转。 + * + *

转换处衔接凭证三态: + * + *

    + *
  • {@link #toConfig(DataModel)}:domain → DTO,{@code DataSource.config} 走 {@link + * CredentialService#encryptConfig} + *
  • {@link #toDataModel(DataModelConfig)}:DTO → domain,{@code config} 走 {@link + * CredentialService#decryptConfig} + *
+ * + *

{@link Dataset} 是 sealed({@code TableDataset}/{@code UnionDataset}),DTO 用 {@code kind} 字段扁平区分。 + */ +public class DataModelConfigConverter { + + private final CredentialService credentials; + + public DataModelConfigConverter(CredentialService credentials) { + this.credentials = credentials; + } + + // ============================================================ + // domain → 持久化实体 + // ============================================================ + + public DataModelConfig toConfig(DataModel dm) { + DataModelConfig cfg = new DataModelConfig(); + cfg.setId(dm.getId()); + cfg.setName(dm.getName()); + cfg.setStatus("PUBLISHED"); + cfg.setDatasources(toDataSourceDtoList(dm.getDatasources())); + cfg.setDatasets(toDatasetDtoList(dm.getDatasets())); + cfg.setRelationships(toRelationshipDtoList(dm.getRelationships())); + return cfg; + } + + private List toDataSourceDtoList(List sources) { + if (sources == null) return List.of(); + List out = new ArrayList<>(sources.size()); + for (DataSource s : sources) { + out.add( + new DataSourceDTO( + s.getId(), + s.getName(), + s.getType() != null ? s.getType().name() : null, + credentials.encryptConfig(s.getConfig()))); + } + return out; + } + + private List toDatasetDtoList(List datasets) { + if (datasets == null) return List.of(); + List out = new ArrayList<>(datasets.size()); + for (Dataset ds : datasets) { + if (ds instanceof TableDataset t) { + out.add( + new DatasetDTO( + t.getId(), + t.getAlias(), + "TABLE", + t.getDatasourceId(), + t.getSourceTable(), + toFieldDtoList(t.getFields()), + null)); + } else if (ds instanceof UnionDataset u) { + out.add( + new DatasetDTO( + u.getId(), + u.getAlias(), + "UNION", + null, + null, + toFieldDtoList(u.getFields()), + toUnionMemberDtoList(u.getMembers()))); + } + } + return out; + } + + private List toFieldDtoList(List fields) { + if (fields == null) return List.of(); + List out = new ArrayList<>(fields.size()); + for (Field f : fields) { + out.add( + new FieldDTO( + f.getName(), + f.getAlias(), + f.getDataType() != null ? f.getDataType().name() : null, + f.isPrimaryKey())); + } + return out; + } + + private List toUnionMemberDtoList(List members) { + if (members == null) return List.of(); + List out = new ArrayList<>(members.size()); + for (UnionMember m : members) { + out.add(new UnionMemberDTO(m.datasetId(), m.mapping())); + } + return out; + } + + private List toRelationshipDtoList(List rels) { + if (rels == null) return List.of(); + List out = new ArrayList<>(rels.size()); + for (Relationship r : rels) { + 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 FieldRefDTO toFieldRefDto(FieldRef ref) { + return ref == null ? null : new FieldRefDTO(ref.datasetId(), ref.field()); + } + + // ============================================================ + // 持久化实体 → domain + // ============================================================ + + public DataModel toDataModel(DataModelConfig cfg) { + if (cfg == null) return null; + return DataModel.builder() + .id(cfg.getId()) + .name(cfg.getName()) + .datasources(toDataSourceList(cfg.getDatasources())) + .datasets(toDatasetList(cfg.getDatasets())) + .relationships(toRelationshipList(cfg.getRelationships())) + .build(); + } + + private List toDataSourceList(List sources) { + if (sources == null) return List.of(); + List out = new ArrayList<>(sources.size()); + for (DataSourceDTO s : sources) { + out.add( + DataSource.builder() + .id(s.id()) + .name(s.name()) + .type(s.type() != null ? DataSourceType.valueOf(s.type()) : null) + .config(credentials.decryptConfig(s.config())) + .build()); + } + return out; + } + + private List toDatasetList(List datasets) { + if (datasets == null) return List.of(); + List out = new ArrayList<>(datasets.size()); + for (DatasetDTO d : datasets) { + if ("UNION".equals(d.kind())) { + out.add( + UnionDataset.builder() + .id(d.id()) + .alias(d.alias()) + .fields(toFieldList(d.fields())) + .members(toUnionMemberList(d.members())) + .build()); + } else { + out.add( + TableDataset.builder() + .id(d.id()) + .alias(d.alias()) + .datasourceId(d.datasourceId()) + .sourceTable(d.sourceTable()) + .fields(toFieldList(d.fields())) + .build()); + } + } + return out; + } + + private List toFieldList(List fields) { + if (fields == null) return List.of(); + List out = new ArrayList<>(fields.size()); + 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 List toUnionMemberList(List members) { + if (members == null) return List.of(); + List out = new ArrayList<>(members.size()); + for (UnionMemberDTO m : members) { + out.add(new UnionMember(m.datasetId(), m.mapping() != null ? m.mapping() : Map.of())); + } + return out; + } + + private List toRelationshipList(List rels) { + if (rels == null) return List.of(); + List out = new ArrayList<>(rels.size()); + 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 FieldRef toFieldRef(FieldRefDTO ref) { + return ref == null ? null : new FieldRef(ref.datasetId(), ref.field()); + } +} diff --git a/report-engine-datasource/src/main/java/com/codingapi/report/datasource/credential/CredentialService.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/credential/CredentialService.java new file mode 100644 index 0000000..7858d00 --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/credential/CredentialService.java @@ -0,0 +1,151 @@ +package com.codingapi.report.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-datasource/src/main/java/com/codingapi/report/datasource/db/DbDataExtractor.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/db/DbDataExtractor.java new file mode 100644 index 0000000..a522fed --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/db/DbDataExtractor.java @@ -0,0 +1,206 @@ +package com.codingapi.report.datasource.db; + +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.DataSourceType; +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 + * 里,不构成类型差异(与 {@link DataSourceType#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(DataSourceType type) { + return type == DataSourceType.DB; + } + + @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-datasource/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/report-engine-datasource/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..c44a364 --- /dev/null +++ b/report-engine-datasource/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.codingapi.report.datasource.DataModelMgmtAutoConfiguration diff --git a/report-engine-datasource/src/test/java/com/codingapi/report/datasource/converter/DataModelConfigConverterTest.java b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/converter/DataModelConfigConverterTest.java new file mode 100644 index 0000000..a3ee7f0 --- /dev/null +++ b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/converter/DataModelConfigConverterTest.java @@ -0,0 +1,110 @@ +package com.codingapi.report.datasource.converter; + +import static org.junit.jupiter.api.Assertions.*; + +import com.codingapi.report.config.DataModelConfig; +import com.codingapi.report.data.datamodel.DataModel; +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.DataSourceType; +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.datasource.credential.CredentialService; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class DataModelConfigConverterTest { + + private final DataModelConfigConverter converter = + new DataModelConfigConverter(new CredentialService("test-key")); + + @Test + void roundTripPreservesSemanticsAndEncryptsCredentials() { + DataModel dm = + DataModel.builder() + .id("m1") + .name("模型") + .datasources( + List.of( + DataSource.builder() + .id("ds") + .name("db") + .type(DataSourceType.DB) + .config( + Map.of( + "url", "jdbc:h2:mem:x", + "password", "s3cret")) + .build())) + .datasets( + List.of( + TableDataset.builder() + .id("emp") + .datasourceId("ds") + .sourceTable("EMP") + .alias("员工") + .fields( + List.of( + Field.builder() + .name("id") + .dataType(DataType.NUMBER) + .primaryKey(true) + .build())) + .build(), + 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())) + .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(); + + DataModelConfig cfg = converter.toConfig(dm); + // 凭证已加密 + Object pwd = cfg.getDatasources().get(0).config().get("password"); + assertTrue(((String) pwd).startsWith("enc:")); + assertEquals("jdbc:h2:mem:x", cfg.getDatasources().get(0).config().get("url")); + // Dataset 两种形态用 kind 区分 + assertEquals("TABLE", cfg.getDatasets().get(0).kind()); + assertEquals("UNION", cfg.getDatasets().get(1).kind()); + assertEquals("LEFT", cfg.getRelationships().get(0).joinType()); + + // 还原 + DataModel back = converter.toDataModel(cfg); + assertEquals("m1", back.getId()); + assertEquals("s3cret", back.getDatasources().get(0).getConfig().get("password")); + assertEquals(2, back.getDatasets().size()); + assertTrue(back.getDatasets().get(0) instanceof TableDataset); + assertTrue(back.getDatasets().get(1) instanceof UnionDataset); + assertEquals(JoinType.LEFT, back.getRelationships().get(0).getJoinType()); + } + + @Test + void toDataModelHandlesNull() { + assertNull(converter.toDataModel(null)); + } +} diff --git a/report-engine-datasource/src/test/java/com/codingapi/report/datasource/credential/CredentialServiceTest.java b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/credential/CredentialServiceTest.java new file mode 100644 index 0000000..9497a39 --- /dev/null +++ b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/credential/CredentialServiceTest.java @@ -0,0 +1,62 @@ +package com.codingapi.report.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-datasource/src/test/java/com/codingapi/report/datasource/db/DbDataExtractorTest.java b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/db/DbDataExtractorTest.java new file mode 100644 index 0000000..f1f1e99 --- /dev/null +++ b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/db/DbDataExtractorTest.java @@ -0,0 +1,124 @@ +package com.codingapi.report.datasource.db; + +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.DataSourceType; +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(DataSourceType.DB) + .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(DataSourceType.DB)); + assertFalse(extractor.supports(DataSourceType.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(DataSourceType.DB) + .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-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 75% 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..af01fb5 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,5 +1,6 @@ package com.example.report.config; +import com.codingapi.report.config.DataModelConfig; import com.codingapi.report.data.datamodel.DataModel; import com.codingapi.report.data.dataset.DataType; import com.codingapi.report.data.dataset.Dataset; @@ -8,10 +9,11 @@ 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.relation.JoinType; import com.codingapi.report.data.relation.RelationOrigin; import com.codingapi.report.data.relation.Relationship; +import com.codingapi.report.datasource.converter.DataModelConfigConverter; +import com.codingapi.report.repository.DataModelRepository; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.io.InputStream; @@ -20,13 +22,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,58 +43,41 @@ * */ @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; + private final DataModelConfigConverter converter; + + public DataModelSeeder(DataModelRepository repository, DataModelConfigConverter converter) { + this.repository = repository; + this.converter = converter; } - 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(); + DataModelConfig cfg = converter.toConfig(dm); + cfg.setId("default"); + repository.save(cfg); + 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); @@ -143,7 +133,6 @@ public DataModel dataModel() throws Exception { } } - // 加载关系 List relationships = loadRelationships(mapper); return DataModel.builder() @@ -154,4 +143,43 @@ public DataModel dataModel() throws Exception { .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/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..9c9793d --- /dev/null +++ b/report-engine-example/src/main/java/com/example/report/repository/InMemoryDataModelRepository.java @@ -0,0 +1,66 @@ +package com.example.report.repository; + +import com.codingapi.report.config.DataModelConfig; +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.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * {@link DataModelRepository} 的内存实现(example 演示用)。 + * + *

数据模型配置以强类型 {@link DataModelConfig} 实体保存;进程内存储,重启丢失。 生产环境应由使用方提供持久化实现。 范式同 {@link + * InMemoryReportRepository}。 + */ +public class InMemoryDataModelRepository implements DataModelRepository { + + private final Map store = new ConcurrentHashMap<>(); + + @Override + public String save(DataModelConfig config) { + Object idObj = config.getId(); + String id = (idObj instanceof String s && !s.isBlank()) ? s : UUID.randomUUID().toString(); + config.setId(id); + + long now = System.currentTimeMillis(); + DataModelConfig existing = store.get(id); + config.setCreateTime(existing != null ? existing.getCreateTime() : now); + config.setUpdateTime(now); + + // null 列表归一为空,避免前端拿到 null + if (config.getDatasources() == null) config.setDatasources(List.of()); + if (config.getDatasets() == null) config.setDatasets(List.of()); + if (config.getRelationships() == null) config.setRelationships(List.of()); + + store.put(id, config); + return id; + } + + @Override + public DataModelConfig 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-framework/src/main/java/com/codingapi/report/config/DataModelConfig.java b/report-engine-framework/src/main/java/com/codingapi/report/config/DataModelConfig.java new file mode 100644 index 0000000..5a85f97 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/config/DataModelConfig.java @@ -0,0 +1,50 @@ +package com.codingapi.report.config; + +import com.codingapi.report.config.dto.DataModelDtos.DataSourceDTO; +import com.codingapi.report.config.dto.DataModelDtos.DatasetDTO; +import com.codingapi.report.config.dto.DataModelDtos.RelationshipDTO; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import lombok.Data; + +/** + * 数据模型配置实体(持久化契约):可复用语义层的强类型 POJO,描述"数据长什么样、表之间怎么关联", 与具体报表无关,建一次可被多个报表引用。 + * + *

字段含元数据(id/name/status/createTime/updateTime)与内容(datasources/datasets/relationships)。 内容引用 + * {@link DataModelDtos} 的 DTO record(Jackson 可序列化,不依赖无注解的 sealed {@code Dataset})。 + * + *

{@code resolvedDataModel} 为响应富化字段:仅 {@code GET /api/datamodels/{id}} 返回时由 datasource 模块 + * 填充解密后的运行时 {@link com.codingapi.report.data.datamodel.DataModel} 视图,不参与持久化 ({@link + * JsonInclude.Include#NON_NULL} 省略空值)。 + * + *

存储交给 {@link com.codingapi.report.repository.DataModelRepository}(使用方提供实现)。 + */ +@Data +public class DataModelConfig { + + private String id; + + private String name; + + /** 状态:{@code "DRAFT"}/{@code "PUBLISHED"}(String 存枚举名,保持实体不依赖 Spring) */ + private String status; + + /** 创建时间(epoch 毫秒) */ + private long createTime; + + /** 修改时间(epoch 毫秒) */ + private long updateTime; + + /** 连接列表(config 含加密后的敏感字段) */ + private List datasources; + + /** 数据集列表(TableDataset / UnionDataset 两种形态,用 DatasetDTO.kind 区分) */ + private List datasets; + + /** 跨数据集关联关系 */ + private List relationships; + + /** 响应富化字段:仅加载时填充解密后的运行时 DataModel,不持久化 */ + @JsonInclude(JsonInclude.Include.NON_NULL) + private Object resolvedDataModel; +} diff --git a/report-engine-framework/src/main/java/com/codingapi/report/config/dto/DataModelDtos.java b/report-engine-framework/src/main/java/com/codingapi/report/config/dto/DataModelDtos.java new file mode 100644 index 0000000..2ce1fd6 --- /dev/null +++ b/report-engine-framework/src/main/java/com/codingapi/report/config/dto/DataModelDtos.java @@ -0,0 +1,60 @@ +package com.codingapi.report.config.dto; + +import java.util.List; +import java.util.Map; + +/** + * 数据模型配置的 DTO 契约容器(前端 JSON ↔ 这些 record ↔ framework 领域对象)。 + * + *

与 {@link ConfigDtos} 同范式:{@code Dataset} 是 sealed interface({@code TableDataset}/ {@code + * UnionDataset}),未加 Jackson 多态注解,无法直接反序列化,故用这些 record 承接持久化与前端 JSON, 再由 datasource 模块的 {@code + * DataModelConfigConverter} 转为 framework 领域对象。 + * + *

这些 record 同时是 {@code com.codingapi.report.config.DataModelConfig} 实体的字段类型(持久化契约), 全字段强类型且 + * Jackson 可序列化。枚举值统一用 {@code String} 存 {@code name()}({@link + * com.codingapi.report.data.datasource.DataSourceType}/{@link + * com.codingapi.report.data.dataset.DataType}/ {@link + * com.codingapi.report.data.relation.JoinType}/{@link + * com.codingapi.report.data.relation.RelationOrigin})。 + * + *

{@link DataSourceDTO#config()} 中敏感字段(password/secret/token/apiKey 等)存加密值, 由 datasource + * 模块的 {@code CredentialService} 加解密、Controller 出口脱敏。 + */ +public final class DataModelDtos { + + private DataModelDtos() {} + + /** 数据源(连接)持久化契约。{@code config} 含加密后的敏感字段。 */ + public record DataSourceDTO(String id, String name, String type, Map config) {} + + /** 字段定义持久化契约。 */ + public record FieldDTO(String name, String alias, String dataType, boolean primaryKey) {} + + /** + * 数据集持久化契约。用 {@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) {} + + /** UNION 成员契约:{@code mapping} 为"统一列名 → 成员实际字段名"。 */ + public record UnionMemberDTO(String datasetId, Map mapping) {} + + /** 跨数据集关系持久化契约。{@code left}/{@code right} 复用 {@link ConfigDtos.FieldRefDTO}。 */ + public record RelationshipDTO( + String id, + ConfigDtos.FieldRefDTO left, + ConfigDtos.FieldRefDTO right, + String joinType, + String origin) {} +} 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..9329607 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}。 @@ -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/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/repository/DataModelRepository.java b/report-engine-framework/src/main/java/com/codingapi/report/repository/DataModelRepository.java new file mode 100644 index 0000000..7891b25 --- /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.config.DataModelConfig; + +/** + * 数据模型仓库:以强类型 {@link DataModelConfig} 实体存取可复用的数据模型配置。 + * + *

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

与 {@link ReportRepository} 同范式:报表配置只存 {@code dataModelId} 引用, 数据模型本身独立存取、多处复用。 + */ +public interface DataModelRepository { + + /** 保存(无 id 则生成),返回数据模型 id。 */ + String save(DataModelConfig config); + + /** 按 id 加载完整配置,不存在返回 null。 */ + DataModelConfig find(String id); + + /** 分页查询数据模型(按 {@link PageQuery})。 */ + PageResult page(PageQuery query); + + /** 删除指定数据模型配置。 */ + void delete(String id); +} diff --git a/report-engine-starter/pom.xml b/report-engine-starter/pom.xml index e937f76..3404f96 100644 --- a/report-engine-starter/pom.xml +++ b/report-engine-starter/pom.xml @@ -27,6 +27,10 @@ com.codingapi.report report-engine-excel + + com.codingapi.report + report-engine-datasource + org.springframework.boot spring-boot-starter 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..6f616c9 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,13 @@ 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.datasource.converter.DataModelConfigConverter; +import com.codingapi.report.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 +15,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 +33,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 +60,43 @@ 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, + DataModelConfigConverter converter) { + return new DataModelService(dataModelRepository, credentials, converter); + } + + @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 +109,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 +129,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..114d334 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/controller/DataModelMgmtController.java @@ -0,0 +1,81 @@ +package com.codingapi.report.starter.controller; + +import com.codingapi.report.config.DataModelConfig; +import com.codingapi.report.config.dto.DataModelDtos.RelationshipDTO; +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(), + 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 DataModelConfig cfg) { + return SingleResponse.of(dataModelService.save(cfg)); + } + + @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..478d745 --- /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.config.dto.DataModelDtos.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..2a70be4 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,8 @@ 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.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 +19,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)); + 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); + 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 00fa66d12e2a84fedd2f9355c7979f0f53d9b80a..f4dd2c85342d30f91bbecab453e8210ba1b4cb77 100644 GIT binary patch delta 535 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 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..2265eeb 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 @@ -8,20 +8,37 @@ 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 定义不同)。 + * com.codingapi.report.config.dto.ConfigDtos}(同时作为 {@code ReportConfig} 实体的持久化契约), 本类保留渲染请求 {@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..6d071bb --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataModelService.java @@ -0,0 +1,178 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.config.DataModelConfig; +import com.codingapi.report.config.dto.DataModelDtos.DataSourceDTO; +import com.codingapi.report.config.dto.DataModelDtos.RelationshipDTO; +import com.codingapi.report.data.datamodel.DataModel; +import com.codingapi.report.data.dataset.TableDataset; +import com.codingapi.report.datasource.converter.DataModelConfigConverter; +import com.codingapi.report.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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 数据模型业务:CRUD + 凭证三态(merge/encrypt/mask)+ 运行时模型加载 + 数据集列表视图。 + * + *

凭证安全在此层闭环:GET 出口脱敏、POST 入口回填 {@code ***} + 加密明文。 渲染/富化通过 {@link #loadDataModel} 复用同一加载与解密逻辑。 + */ +public class DataModelService { + + private final DataModelRepository repository; + private final CredentialService credentials; + private final DataModelConfigConverter converter; + + public DataModelService( + DataModelRepository repository, + CredentialService credentials, + DataModelConfigConverter converter) { + this.repository = repository; + this.credentials = credentials; + this.converter = converter; + } + + public PageResult page(int current, int pageSize) { + return repository.page(new PageQuery(current, pageSize)); + } + + /** 详情({@code datasources.config} 脱敏)。 */ + public DataModelConfig getMasked(String id) { + DataModelConfig cfg = repository.find(id); + if (cfg == null) return null; + cfg.setDatasources(maskAll(cfg.getDatasources())); + return cfg; + } + + /** 新建/更新:{@code ***} 凭证回填旧值,明文凭证加密后入库。 */ + public String save(DataModelConfig cfg) { + DataModelConfig old = + cfg.getId() != null && !cfg.getId().isBlank() ? repository.find(cfg.getId()) : null; + mergeMaskedCredentials(cfg, old); + encryptDatasourceConfigs(cfg); + return repository.save(cfg); + } + + public void delete(String id) { + repository.delete(id); + } + + /** 批量替换关系(列表式整体替换)。 */ + public void saveRelationships(String dataModelId, List relationships) { + DataModelConfig cfg = repository.find(dataModelId); + if (cfg == null) { + throw new IllegalArgumentException("数据模型不存在: " + dataModelId); + } + cfg.setRelationships(relationships); + repository.save(cfg); + } + + /** 加载运行时 {@link DataModel}(解密);不存在返回 null(富化容错用)。 */ + public DataModel findDataModel(String dataModelId) { + if (dataModelId == null || dataModelId.isBlank()) return null; + DataModelConfig cfg = repository.find(dataModelId); + return cfg == null ? null : converter.toDataModel(cfg); + } + + /** 加载运行时 {@link DataModel}(解密);不存在或 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); + return dm.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(), + sourceTypeOf(dm, tds.getDatasourceId()), + fields); + }) + .toList(); + } + + private String sourceTypeOf(DataModel dm, String datasourceId) { + return dm.getDatasources().stream() + .filter(s -> s.getId().equals(datasourceId)) + .map(s -> s.getType().name()) + .findFirst() + .orElse("CSV"); + } + + // ============================================================ + // 凭证 merge / 加密 / 脱敏 + // ============================================================ + + private List maskAll(List sources) { + if (sources == null) return null; + return sources.stream() + .map( + s -> + new DataSourceDTO( + s.id(), + s.name(), + s.type(), + credentials.maskConfig(s.config()))) + .toList(); + } + + /** 前端回传的 {@code ***} 占位用旧 config 的(已加密)值回填,避免覆盖真实凭证。 */ + private void mergeMaskedCredentials(DataModelConfig cfg, DataModelConfig old) { + if (old == null || cfg.getDatasources() == null) return; + Map oldById = new LinkedHashMap<>(); + for (DataSourceDTO s : old.getDatasources()) { + oldById.put(s.id(), s); + } + for (DataSourceDTO neu : cfg.getDatasources()) { + DataSourceDTO o = oldById.get(neu.id()); + if (o == null || neu.config() == null) continue; + Map merged = new LinkedHashMap<>(neu.config()); + for (Map.Entry e : merged.entrySet()) { + if (credentials.isMasked(e.getValue()) && o.config() != null) { + Object oldVal = o.config().get(e.getKey()); + if (oldVal != null) { + merged.put(e.getKey(), oldVal); + } + } + } + neu.config().clear(); + neu.config().putAll(merged); + } + } + + private void encryptDatasourceConfigs(DataModelConfig cfg) { + if (cfg.getDatasources() == null) return; + for (DataSourceDTO s : cfg.getDatasources()) { + Map encrypted = credentials.encryptConfig(s.config()); + s.config().clear(); + s.config().putAll(encrypted); + } + } +} 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..d11e02d --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/DataSourceService.java @@ -0,0 +1,117 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.config.dto.DataModelDtos.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.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.valueOf(dto.type()); + 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 = + dm.getDatasources().stream() + .filter(s -> s.getId().equals(tds.getDatasourceId())) + .findFirst() + .orElseThrow( + () -> + 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)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("无提取器支持类型: " + type)); + } + + private DataSource loadDataSource(String dataModelId, String datasourceId) { + DataModel dm = dataModelService.loadDataModel(dataModelId); + return dm.getDatasources().stream() + .filter(s -> s.getId().equals(datasourceId)) + .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..a5e55c9 --- /dev/null +++ b/report-engine-starter/src/main/java/com/codingapi/report/starter/service/ReportConfigService.java @@ -0,0 +1,50 @@ +package com.codingapi.report.starter.service; + +import com.codingapi.report.config.ReportConfig; +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 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(ReportConfig config) { + return repository.save(config); + } + + /** 加载配置并富化数据模型视图。 */ + public ReportConfig get(String id) { + ReportConfig config = repository.find(id); + if (config == null) return null; + if (config.getDataModelId() != null) { + DataModel dm = dataModelService.findDataModel(config.getDataModelId()); + if (dm != null) { + config.setDataModel(DataModelDtoAssembler.assemble(dm)); + } + } + return config; + } + + 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..969b6c8 --- /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.render.Report; +import com.codingapi.report.render.engine.DrillCollector; +import com.codingapi.report.render.engine.ReportRenderer; +import com.codingapi.report.render.grid.CellBinding; +import com.codingapi.report.render.grid.LoopBlock; +import com.codingapi.report.render.grid.SummaryRow; +import com.codingapi.report.starter.converter.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); + } +} From 6e53fb11cebbc1532540ccb0a96554b4b73fbc58 Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 25 Jun 2026 19:23:15 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20ApiDataExtractor=20=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0Issue=20#32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/decisions.md | 28 +++ .../DataModelMgmtAutoConfiguration.java | 8 + .../report/datasource/api/ApiConfig.java | 76 ++++++++ .../datasource/api/ApiDataExtractor.java | 183 ++++++++++++++++++ .../datasource/api/ApiDataExtractorTest.java | 153 +++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 .hermes/decisions.md create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiConfig.java create mode 100644 report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiDataExtractor.java create mode 100644 report-engine-datasource/src/test/java/com/codingapi/report/datasource/api/ApiDataExtractorTest.java 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/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java index 2f0222e..e87867e 100644 --- a/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/DataModelMgmtAutoConfiguration.java @@ -2,6 +2,7 @@ import com.codingapi.report.data.datasource.DataExtractor; import com.codingapi.report.data.datasource.csv.CsvDataExtractor; +import com.codingapi.report.datasource.api.ApiDataExtractor; import com.codingapi.report.datasource.converter.DataModelConfigConverter; import com.codingapi.report.datasource.credential.CredentialService; import com.codingapi.report.datasource.db.DbDataExtractor; @@ -44,4 +45,11 @@ public DbDataExtractor dbDataExtractor() { public CsvDataExtractor csvDataExtractor() { return new CsvDataExtractor(); } + + /** API 提取器:HTTP JSON 接口取数,JDK HttpClient + Jackson。 */ + @Bean + @ConditionalOnMissingBean + public ApiDataExtractor apiDataExtractor() { + return new ApiDataExtractor(); + } } diff --git a/report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiConfig.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiConfig.java new file mode 100644 index 0000000..96d053e --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiConfig.java @@ -0,0 +1,76 @@ +package com.codingapi.report.datasource.api; + +import com.codingapi.report.data.datasource.DataSource; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * API 数据源连接配置:从 {@link DataSource#getConfig()} 解析而来。 + * + *

配置项约定

+ * + *
    + *
  • {@code url}(必填):完整请求 URL + *
  • {@code method}:默认 {@code GET} + *
  • {@code headers}:{@code Map},请求头 + *
  • {@code body}:POST/PUT 请求体 + *
  • {@code dataPath}:JSON 数组在响应中的位置,默认 {@code $}(根) + *
      + *
    • {@code $} 或空 → 根节点 + *
    • {@code $.data.items} → Jackson JsonPointer {@code /data/items} + *
    • {@code /data/items} → 原样当 JsonPointer + *
    + *
  • {@code timeoutMs}:连接 + 读取超时,默认 15000 + *
+ */ +public record ApiConfig( + String url, + String method, + Map headers, + String body, + String dataPath, + int timeoutMs) { + + private static final int DEFAULT_TIMEOUT_MS = 15000; + + public static ApiConfig from(DataSource source) { + Map raw = source.getConfig() != null ? source.getConfig() : new HashMap<>(); + String url = str(raw.get("url")); + if (url == null || url.isBlank()) { + throw new IllegalStateException("API 数据源缺少 config.url"); + } + String method = strOr(raw.get("method"), "GET"); + Map headers = new LinkedHashMap<>(); + Object h = raw.get("headers"); + if (h instanceof Map hm) { + for (Map.Entry e : hm.entrySet()) { + headers.put(String.valueOf(e.getKey()), String.valueOf(e.getValue())); + } + } + String body = str(raw.get("body")); + String dataPath = strOr(raw.get("dataPath"), "$"); + int timeout = intOr(raw.get("timeoutMs"), DEFAULT_TIMEOUT_MS); + return new ApiConfig(url, method, headers, body, dataPath, timeout); + } + + private static String str(Object v) { + return v == null ? null : String.valueOf(v); + } + + private static String strOr(Object v, String def) { + if (v == null) return def; + String s = String.valueOf(v); + return s.isBlank() ? def : s; + } + + 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-datasource/src/main/java/com/codingapi/report/datasource/api/ApiDataExtractor.java b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiDataExtractor.java new file mode 100644 index 0000000..75cf1b5 --- /dev/null +++ b/report-engine-datasource/src/main/java/com/codingapi/report/datasource/api/ApiDataExtractor.java @@ -0,0 +1,183 @@ +package com.codingapi.report.datasource.api; + +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.TableDataset; +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 com.codingapi.report.data.datasource.TestResult; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; + +/** + * HTTP API 数据提取器:发请求拿 JSON,按 {@code dataPath} 取数组,按 {@link Dataset#getFields()} 映射为 {@link RawTable}。 + * + *

设计要点

+ * + *
    + *
  • 使用 JDK 内置 {@link java.net.http.HttpClient},不引入第三方 HTTP 库 + *
  • JSON 解析用项目已有的 Jackson {@link ObjectMapper} + *
  • {@code dataPath} 支持 {@code $} / {@code $.a.b} / {@code /a/b} 三种写法,统一转为 Jackson JsonPointer + *
  • 列名用限定名 {@code datasetId.field}(与 {@code DbDataExtractor} 一致) + *
  • 类型归一:{@code NUMBER→Double}、{@code BOOLEAN→Boolean}、其余 → {@code String} + *
  • {@link TableDataset#getSourceTable()} 在 API 场景当作 endpoint path,拼到 {@code url} 后面 + *
+ */ +@Slf4j +public class ApiDataExtractor implements DataExtractor { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public boolean supports(DataSourceType type) { + return type == DataSourceType.API; + } + + @Override + public RawTable extract(DataSource source, Dataset dataset) { + ApiConfig config = ApiConfig.from(source); + String url = resolveUrl(config, dataset); + log.debug("API 提取: {} url={}", dataset.getId(), url); + try { + JsonNode root = sendRequest(config, url); + JsonNode dataNode = selectByPath(root, config.dataPath()); + if (!dataNode.isArray()) { + throw new IllegalStateException("dataPath 指向的不是数组: " + config.dataPath()); + } + return readRows((ArrayNode) dataNode, dataset); + } catch (Exception e) { + throw new IllegalStateException("API 提取失败: " + dataset.getId(), e); + } + } + + @Override + public TestResult test(DataSource source) { + long start = System.currentTimeMillis(); + try { + ApiConfig config = ApiConfig.from(source); + sendRequest(config, config.url()); + 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 String resolveUrl(ApiConfig config, Dataset dataset) { + if (dataset instanceof TableDataset t) { + String path = t.getSourceTable(); + if (path != null && !path.isBlank()) { + String base = config.url(); + if (base.endsWith("/")) { + return base + path; + } + return base + "/" + path; + } + } + return config.url(); + } + + private JsonNode sendRequest(ApiConfig config, String url) throws Exception { + HttpRequest.Builder builder = + HttpRequest.newBuilder().uri(URI.create(url)).timeout(Duration.ofMillis(config.timeoutMs())); + String method = config.method().toUpperCase(); + if ("GET".equals(method)) { + builder.GET(); + } else if ("POST".equals(method) || "PUT".equals(method) || "PATCH".equals(method)) { + HttpRequest.BodyPublisher body = + config.body() == null || config.body().isEmpty() + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(config.body()); + builder.method(method, body); + } else if ("DELETE".equals(method)) { + builder.DELETE(); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + if (config.headers() != null) { + config.headers().forEach(builder::header); + } + HttpClient client = + HttpClient.newBuilder().connectTimeout(Duration.ofMillis(config.timeoutMs())).build(); + HttpResponse resp = client.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() < 200 || resp.statusCode() >= 300) { + throw new IllegalStateException("HTTP 状态码 " + resp.statusCode()); + } + return MAPPER.readTree(resp.body()); + } + + private RawTable readRows(ArrayNode dataNode, Dataset dataset) { + List columns = new ArrayList<>(); + for (Field f : dataset.getFields()) { + columns.add(dataset.getId() + "." + f.getName()); + } + List> rows = new ArrayList<>(); + for (JsonNode row : dataNode) { + Map r = new LinkedHashMap<>(); + for (Field f : dataset.getFields()) { + JsonNode cell = row.get(f.getName()); + r.put(dataset.getId() + "." + f.getName(), coerce(cell, f.getDataType())); + } + rows.add(r); + } + return new RawTable(columns, rows); + } + + 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 JsonNode selectByPath(JsonNode root, String dataPath) { + if (dataPath == null || dataPath.isBlank() || "$".equals(dataPath)) { + return root; + } + return root.at(toPointer(dataPath)); + } + + /** 把 {@code $.a.b} / {@code a.b} / {@code /a/b} 都转成 Jackson JsonPointer 字符串。 */ + private static String toPointer(String dataPath) { + if (dataPath.startsWith("/")) { + return dataPath; + } + String p = dataPath; + if (p.startsWith("$.")) { + p = p.substring(2); + } else if (p.startsWith("$")) { + p = p.substring(1); + } + if (p.startsWith(".")) { + p = p.substring(1); + } + StringBuilder sb = new StringBuilder(); + for (String seg : p.split("\\.")) { + if (!seg.isEmpty()) { + sb.append("/").append(seg.replace("~", "~0").replace("/", "~1")); + } + } + return sb.toString(); + } +} diff --git a/report-engine-datasource/src/test/java/com/codingapi/report/datasource/api/ApiDataExtractorTest.java b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/api/ApiDataExtractorTest.java new file mode 100644 index 0000000..3023075 --- /dev/null +++ b/report-engine-datasource/src/test/java/com/codingapi/report/datasource/api/ApiDataExtractorTest.java @@ -0,0 +1,153 @@ +package com.codingapi.report.datasource.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.DataSourceType; +import com.codingapi.report.data.datasource.RawTable; +import com.codingapi.report.data.datasource.TestResult; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +/** + * 用 JDK 内置 {@link com.sun.net.httpserver.HttpServer} 起一个最小 HTTP 服务做测试, 不引入 MockWebServer 等额外依赖。 + */ +class ApiDataExtractorTest { + + private static HttpServer server; + private static int port; + private static final ApiDataExtractor extractor = new ApiDataExtractor(); + + @BeforeAll + static void startServer() throws IOException { + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/items", new StringHandler( + "{\"data\":[{\"id\":1,\"name\":\"alice\",\"price\":10.5,\"active\":true}," + + "{\"id\":2,\"name\":\"bob\",\"price\":20,\"active\":false}]}")); + server.createContext("/flat", new StringHandler( + "[{\"id\":1,\"name\":\"alice\"},{\"id\":2,\"name\":\"bob\"}]")); + server.start(); + port = server.getAddress().getPort(); + } + + @AfterAll + static void stopServer() { + if (server != null) { + server.stop(0); + } + } + + private DataSource apiSource(String path, String dataPath) { + return DataSource.builder() + .id("api") + .name("test-api") + .type(DataSourceType.API) + .config(Map.of( + "url", "http://localhost:" + port + path, + "dataPath", dataPath, + "timeoutMs", 5000)) + .build(); + } + + private TableDataset dataset() { + return TableDataset.builder() + .id("items") + .datasourceId("api") + .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_onlyApi() { + assertTrue(extractor.supports(DataSourceType.API)); + assertFalse(extractor.supports(DataSourceType.DB)); + assertFalse(extractor.supports(DataSourceType.CSV)); + } + + @Test + void extract_nestedDataPath() { + RawTable table = extractor.extract(apiSource("/items", "$.data"), 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")); + } + + @Test + void extract_rootDataPath() { + RawTable table = extractor.extract(apiSource("/flat", "$"), dataset()); + assertEquals(2, table.getRows().size()); + assertEquals("alice", table.getRows().get(0).get("items.name")); + // flat 端点没有 price/active,对应字段为 null + assertFalse(table.getRows().get(0).containsKey("items.price") + && table.getRows().get(0).get("items.price") != null); + } + + @Test + void extract_pointerStylePath() { + // /data 风格的 JsonPointer + RawTable table = extractor.extract(apiSource("/items", "/data"), dataset()); + assertEquals(2, table.getRows().size()); + assertEquals("bob", table.getRows().get(1).get("items.name")); + } + + @Test + void test_ok() { + TestResult result = extractor.test(apiSource("/items", "$.data")); + assertTrue(result.ok(), result.message()); + } + + @Test + void test_fail() { + DataSource bad = DataSource.builder() + .id("api") + .name("bad") + .type(DataSourceType.API) + .config(Map.of("url", "http://localhost:1/nope", "timeoutMs", 500)) + .build(); + TestResult result = extractor.test(bad); + assertFalse(result.ok()); + } + + private static class StringHandler implements HttpHandler { + private final byte[] body; + + StringHandler(String body) { + this.body = body.getBytes(); + } + + @Override + public void handle(HttpExchange ex) throws IOException { + ex.getResponseHeaders().add("Content-Type", "application/json"); + ex.sendResponseHeaders(200, body.length); + try (OutputStream os = ex.getResponseBody()) { + os.write(body); + } + } + } +} From ced4baa1d98d399653759c4c097dc31723441c0e Mon Sep 17 00:00:00 2001 From: lorne <1991wangliang@gmail.com> Date: Thu, 25 Jun 2026 19:40:44 +0800 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=E5=89=8D=E7=AB=AF=E6=96=B0?= =?UTF-8?q?=E5=8C=85report-datasource=20#28?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- report-frontend/package.json | 6 +- .../dist/components/connection-form.d.ts | 7 + .../dist/components/connection-form.js | 141 +++++++++++ .../dist/components/dataset-manager.d.ts | 7 + .../dist/components/dataset-manager.js | 209 ++++++++++++++++ .../dist/components/explore-tree.d.ts | 6 + .../dist/components/explore-tree.js | 79 ++++++ .../dist/components/relation-editor.d.ts | 6 + .../dist/components/relation-editor.js | 231 ++++++++++++++++++ .../dist/hooks/use-datasource.d.ts | 16 ++ .../dist/hooks/use-datasource.js | 82 +++++++ .../dist/hooks/use-explore.d.ts | 17 ++ .../dist/hooks/use-explore.js | 78 ++++++ .../report-datasource/dist/index.d.ts | 7 + .../packages/report-datasource/dist/index.js | 6 + .../report-datasource/dist/types.d.ts | 154 ++++++++++++ .../packages/report-datasource/dist/types.js | 0 .../report-datasource/node_modules/.bin/msw | 21 ++ .../report-datasource/node_modules/.bin/tsc | 21 ++ .../node_modules/.bin/tsserver | 21 ++ .../rspack/2004aed734fc8637/meta/0.pack | Bin 0 -> 15 bytes .../.cache/rspack/2004aed734fc8637/meta/_meta | 2 + .../occasion_make_module_graph/0.pack | Bin 0 -> 307633 bytes .../occasion_make_module_graph/_meta | 2 + .../2004aed734fc8637/snapshot_build/0.pack | Bin 0 -> 1541 bytes .../2004aed734fc8637/snapshot_build/_meta | 2 + .../2004aed734fc8637/snapshot_file/0.pack | Bin 0 -> 1443 bytes .../2004aed734fc8637/snapshot_file/_meta | 2 + .../2004aed734fc8637/snapshot_missing/0.pack | Bin 0 -> 473 bytes .../2004aed734fc8637/snapshot_missing/_meta | 2 + .../node_modules/.cache/rspack/_meta | 1 + .../node_modules/@ant-design/icons | 1 + .../node_modules/@coding-report/report-api | 1 + .../node_modules/@testing-library/jest-dom | 1 + .../node_modules/@testing-library/react | 1 + .../node_modules/@testing-library/user-event | 1 + .../report-datasource/node_modules/antd | 1 + .../report-datasource/node_modules/happy-dom | 1 + .../report-datasource/node_modules/msw | 1 + .../report-datasource/node_modules/react | 1 + .../report-datasource/node_modules/react-dom | 1 + .../packages/report-datasource/package.json | 53 ++++ .../report-datasource/rslib.config.ts | 28 +++ .../report-datasource/rstest.config.ts | 20 ++ .../src/components/connection-form.tsx | 102 ++++++++ .../src/components/dataset-manager.tsx | 178 ++++++++++++++ .../src/components/explore-tree.tsx | 101 ++++++++ .../src/components/relation-editor.tsx | 153 ++++++++++++ .../src/hooks/use-datasource.ts | 87 +++++++ .../src/hooks/use-explore.ts | 92 +++++++ .../packages/report-datasource/src/index.ts | 26 ++ .../packages/report-datasource/src/types.ts | 175 +++++++++++++ .../test/components/connection-form.test.tsx | 60 +++++ .../test/components/explore-tree.test.tsx | 44 ++++ .../report-datasource/test/globals.d.ts | 3 + .../report-datasource/test/handlers.ts | 14 ++ .../report-datasource/test/msw-server.ts | 4 + .../packages/report-datasource/test/setup.ts | 33 +++ .../report-datasource/test/tsconfig.json | 4 + .../packages/report-datasource/tsconfig.json | 18 ++ report-frontend/pnpm-lock.yaml | 34 +++ 61 files changed, 2363 insertions(+), 2 deletions(-) create mode 100644 report-frontend/packages/report-datasource/dist/components/connection-form.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/components/connection-form.js create mode 100644 report-frontend/packages/report-datasource/dist/components/dataset-manager.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/components/dataset-manager.js create mode 100644 report-frontend/packages/report-datasource/dist/components/explore-tree.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/components/explore-tree.js create mode 100644 report-frontend/packages/report-datasource/dist/components/relation-editor.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/components/relation-editor.js create mode 100644 report-frontend/packages/report-datasource/dist/hooks/use-datasource.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/hooks/use-datasource.js create mode 100644 report-frontend/packages/report-datasource/dist/hooks/use-explore.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/hooks/use-explore.js create mode 100644 report-frontend/packages/report-datasource/dist/index.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/index.js create mode 100644 report-frontend/packages/report-datasource/dist/types.d.ts create mode 100644 report-frontend/packages/report-datasource/dist/types.js create mode 100755 report-frontend/packages/report-datasource/node_modules/.bin/msw create mode 100755 report-frontend/packages/report-datasource/node_modules/.bin/tsc create mode 100755 report-frontend/packages/report-datasource/node_modules/.bin/tsserver create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/0.pack create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/_meta create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/0.pack create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/_meta create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/0.pack create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/_meta create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_file/0.pack create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_file/_meta create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_missing/0.pack create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_missing/_meta create mode 100644 report-frontend/packages/report-datasource/node_modules/.cache/rspack/_meta create mode 120000 report-frontend/packages/report-datasource/node_modules/@ant-design/icons create mode 120000 report-frontend/packages/report-datasource/node_modules/@coding-report/report-api create mode 120000 report-frontend/packages/report-datasource/node_modules/@testing-library/jest-dom create mode 120000 report-frontend/packages/report-datasource/node_modules/@testing-library/react create mode 120000 report-frontend/packages/report-datasource/node_modules/@testing-library/user-event create mode 120000 report-frontend/packages/report-datasource/node_modules/antd create mode 120000 report-frontend/packages/report-datasource/node_modules/happy-dom create mode 120000 report-frontend/packages/report-datasource/node_modules/msw create mode 120000 report-frontend/packages/report-datasource/node_modules/react create mode 120000 report-frontend/packages/report-datasource/node_modules/react-dom create mode 100644 report-frontend/packages/report-datasource/package.json create mode 100644 report-frontend/packages/report-datasource/rslib.config.ts create mode 100644 report-frontend/packages/report-datasource/rstest.config.ts create mode 100644 report-frontend/packages/report-datasource/src/components/connection-form.tsx create mode 100644 report-frontend/packages/report-datasource/src/components/dataset-manager.tsx create mode 100644 report-frontend/packages/report-datasource/src/components/explore-tree.tsx create mode 100644 report-frontend/packages/report-datasource/src/components/relation-editor.tsx create mode 100644 report-frontend/packages/report-datasource/src/hooks/use-datasource.ts create mode 100644 report-frontend/packages/report-datasource/src/hooks/use-explore.ts create mode 100644 report-frontend/packages/report-datasource/src/index.ts create mode 100644 report-frontend/packages/report-datasource/src/types.ts create mode 100644 report-frontend/packages/report-datasource/test/components/connection-form.test.tsx create mode 100644 report-frontend/packages/report-datasource/test/components/explore-tree.test.tsx create mode 100644 report-frontend/packages/report-datasource/test/globals.d.ts create mode 100644 report-frontend/packages/report-datasource/test/handlers.ts create mode 100644 report-frontend/packages/report-datasource/test/msw-server.ts create mode 100644 report-frontend/packages/report-datasource/test/setup.ts create mode 100644 report-frontend/packages/report-datasource/test/tsconfig.json create mode 100644 report-frontend/packages/report-datasource/tsconfig.json diff --git a/report-frontend/package.json b/report-frontend/package.json index 3b3b354..eed71f2 100644 --- a/report-frontend/package.json +++ b/report-frontend/package.json @@ -7,8 +7,9 @@ "build:report-univer": "pnpm -F @coding-report/report-univer build", "build:report-api": "pnpm -F @coding-report/report-api build", "build:report-engine": "pnpm -F @coding-report/report-engine build", + "build:report-datasource": "pnpm -F @coding-report/report-datasource build", "build:app-pc": "pnpm -F @report-example/app-pc build", - "build": "pnpm run lint && pnpm run build:report-univer && pnpm run build:report-api && pnpm run build:report-engine && pnpm run build:app-pc", + "build": "pnpm run lint && pnpm run build:report-univer && pnpm run build:report-api && pnpm run build:report-engine && pnpm run build:report-datasource && pnpm run build:app-pc", "watch:report-engine": "pnpm -F @coding-report/report-engine dev", "watch:report-univer": "pnpm -F @coding-report/report-univer dev", "watch:report-api": "pnpm -F @coding-report/report-api dev", @@ -21,7 +22,8 @@ "lint:fix": "rslint --fix", "test:report-engine": "pnpm -F @coding-report/report-engine test", "test:report-univer": "pnpm -F @coding-report/report-univer test", - "test": "pnpm run test:report-univer && pnpm run test:report-engine", + "test:report-datasource": "pnpm -F @coding-report/report-datasource test", + "test": "pnpm run test:report-univer && pnpm run test:report-engine && pnpm run test:report-datasource", "clean": "pnpm cleaninstall-node" }, "keywords": [], diff --git a/report-frontend/packages/report-datasource/dist/components/connection-form.d.ts b/report-frontend/packages/report-datasource/dist/components/connection-form.d.ts new file mode 100644 index 0000000..2c5c789 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/connection-form.d.ts @@ -0,0 +1,7 @@ +import type { ConnectionFormProps } from '../types'; +/** + * 数据源连接配置表单(受控 + antd Form)。 + * 字段:name / type / url / username / password / options(JSON 文本) + * 「测试连接」依赖注入的 service;未注入时按钮禁用。 + */ +export default function ConnectionForm({ value, onChange, service, testResult, onTestResultChange, disabled, }: ConnectionFormProps): import("react").JSX.Element; diff --git a/report-frontend/packages/report-datasource/dist/components/connection-form.js b/report-frontend/packages/report-datasource/dist/components/connection-form.js new file mode 100644 index 0000000..3014c42 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/connection-form.js @@ -0,0 +1,141 @@ +import { jsx, jsxs } from "react/jsx-runtime"; +import { App, Button, Form, Input, Select } from "antd"; +import { useState } from "react"; +const DATASOURCE_TYPE_OPTIONS = [ + { + label: 'CSV', + value: 'CSV' + }, + { + label: 'JSON', + value: 'JSON' + }, + { + label: 'DB', + value: 'DB' + }, + { + label: 'API', + value: 'API' + }, + { + label: 'EXCEL', + value: 'EXCEL' + } +]; +function ConnectionForm({ value, onChange, service, testResult, onTestResultChange, disabled }) { + const { message } = App.useApp(); + const [testing, setTesting] = useState(false); + const handleValuesChange = (_, all)=>{ + onChange?.(all); + }; + const handleTest = async ()=>{ + if (!service?.testConnection || !value) return void onTestResultChange?.(null); + 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 = value ?? {}; + const canTest = !!service?.testConnection && !!value?.type && !disabled && !testing; + return /*#__PURE__*/ jsxs(Form, { + layout: "vertical", + initialValues: merged, + onValuesChange: handleValuesChange, + disabled: disabled, + children: [ + /*#__PURE__*/ jsx(Form.Item, { + label: "名称", + name: "name", + rules: [ + { + required: true, + message: '请输入名称' + } + ], + children: /*#__PURE__*/ jsx(Input, { + placeholder: "数据源名称" + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "类型", + name: "type", + rules: [ + { + required: true, + message: '请选择类型' + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: DATASOURCE_TYPE_OPTIONS, + placeholder: "选择数据源类型" + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "连接 URL", + name: "url", + children: /*#__PURE__*/ jsx(Input, { + placeholder: "JDBC URL / CSV 路径 / JSON URL / API endpoint" + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "用户名", + name: "username", + children: /*#__PURE__*/ jsx(Input, { + autoComplete: "off" + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "密码 / Token", + name: "password", + children: /*#__PURE__*/ jsx(Input.Password, { + autoComplete: "new-password" + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "额外选项 (JSON)", + name: "options", + children: /*#__PURE__*/ jsx(Input.TextArea, { + rows: 2, + placeholder: '{"key":"value"}' + }) + }), + /*#__PURE__*/ jsxs(Form.Item, { + children: [ + /*#__PURE__*/ jsx(Button, { + loading: testing, + disabled: !canTest, + onClick: handleTest, + children: "测试连接" + }), + testResult ? /*#__PURE__*/ jsx("span", { + style: { + marginLeft: 12, + color: testResult.ok ? 'green' : 'red' + }, + children: testResult.ok ? '连接成功' : `失败:${testResult.message ?? ''}` + }) : null + ] + }) + ] + }); +} +export default ConnectionForm; diff --git a/report-frontend/packages/report-datasource/dist/components/dataset-manager.d.ts b/report-frontend/packages/report-datasource/dist/components/dataset-manager.d.ts new file mode 100644 index 0000000..76f0b9e --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/dataset-manager.d.ts @@ -0,0 +1,7 @@ +import type { DatasetManagerProps } from '../types'; +/** + * 数据集管理:物理表数据集 + UNION 派生数据集。 + * 列表展示 + 新建(物理/UNION)/编辑/删除。 + * 字段编辑(fields)此版暂以只读展示,后续接入 ExploreTree 联动选择。 + */ +export default function DatasetManager({ datasets, dataSources, onChange, }: DatasetManagerProps): import("react").JSX.Element; diff --git a/report-frontend/packages/report-datasource/dist/components/dataset-manager.js b/report-frontend/packages/report-datasource/dist/components/dataset-manager.js new file mode 100644 index 0000000..70b1c2a --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/dataset-manager.js @@ -0,0 +1,209 @@ +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table } from "antd"; +import { useState } from "react"; +function DatasetManager({ datasets, dataSources, onChange }) { + 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 = [ + { + title: '别名', + dataIndex: 'alias', + key: 'alias', + render: (_, r)=>r.alias ?? r.id + }, + { + title: '类型', + key: 'kind', + render: (_, r)=>'PHYSICAL' === r.kind ? '物理表' : 'UNION' + }, + { + title: '来源', + key: 'source', + render: (_, r)=>{ + if ('PHYSICAL' === r.kind) { + 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)=>/*#__PURE__*/ jsxs(Space, { + children: [ + /*#__PURE__*/ jsx("a", { + onClick: ()=>{ + setEditing(r); + form.setFieldsValue(r); + setModalOpen(true); + }, + children: "编辑" + }), + /*#__PURE__*/ jsx(Popconfirm, { + title: "确认删除?", + onConfirm: ()=>{ + const next = datasets.filter((d)=>d.id !== r.id); + onChange?.(next); + }, + children: /*#__PURE__*/ jsx("a", { + children: "删除" + }) + }) + ] + }) + } + ]; + const handleAdd = (kind)=>{ + const newId = `ds-${Date.now()}`; + setEditing(null); + if ('PHYSICAL' === kind) { + const base = { + 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(); + const next = editing ? datasets.map((d)=>d.id === values.id ? values : d) : [ + ...datasets, + values + ]; + onChange?.(next); + setModalOpen(false); + }; + return /*#__PURE__*/ jsxs(Fragment, { + children: [ + /*#__PURE__*/ jsxs(Space, { + style: { + marginBottom: 8 + }, + children: [ + /*#__PURE__*/ jsx(Button, { + onClick: ()=>handleAdd('PHYSICAL'), + children: "新建物理数据集" + }), + /*#__PURE__*/ jsx(Button, { + onClick: ()=>handleAdd('UNION'), + children: "新建 UNION 数据集" + }) + ] + }), + /*#__PURE__*/ jsx(Table, { + rowKey: "id", + columns: columns, + dataSource: datasets, + pagination: false, + size: "small" + }), + /*#__PURE__*/ jsx(Modal, { + title: editing ? '编辑数据集' : '新建数据集', + open: modalOpen, + onOk: handleOk, + onCancel: ()=>setModalOpen(false), + destroyOnClose: true, + children: /*#__PURE__*/ jsxs(Form, { + form: form, + layout: "vertical", + children: [ + /*#__PURE__*/ jsx(Form.Item, { + name: "id", + hidden: true, + children: /*#__PURE__*/ jsx(Input, {}) + }), + /*#__PURE__*/ jsx(Form.Item, { + name: "kind", + hidden: true, + children: /*#__PURE__*/ jsx(Input, {}) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "别名", + name: "alias", + children: /*#__PURE__*/ jsx(Input, {}) + }), + /*#__PURE__*/ jsx(Form.Item, { + noStyle: true, + shouldUpdate: (p, n)=>p?.kind !== n?.kind, + children: ({ getFieldValue })=>{ + const kind = getFieldValue('kind'); + if ('PHYSICAL' === kind) return /*#__PURE__*/ jsxs(Fragment, { + children: [ + /*#__PURE__*/ jsx(Form.Item, { + label: "数据源", + name: "sourceId", + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: dataSources.map((d)=>({ + label: d.name, + value: d.id + })) + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "表名", + name: "table", + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Input, {}) + }) + ] + }); + return /*#__PURE__*/ jsx(Form.Item, { + label: "参与数据集", + name: "baseDatasetIds", + rules: [ + { + required: true, + type: 'array', + min: 2 + } + ], + children: /*#__PURE__*/ jsx(Select, { + mode: "multiple", + options: datasets.filter((d)=>'PHYSICAL' === d.kind).map((d)=>({ + label: d.alias ?? d.id, + value: d.id + })) + }) + }); + } + }) + ] + }) + }) + ] + }); +} +export default DatasetManager; diff --git a/report-frontend/packages/report-datasource/dist/components/explore-tree.d.ts b/report-frontend/packages/report-datasource/dist/components/explore-tree.d.ts new file mode 100644 index 0000000..0ce4290 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/explore-tree.d.ts @@ -0,0 +1,6 @@ +import type { ExploreTreeProps } from '../types'; +/** + * 表/列探查树。 + * 选中表 → 触发 onSelectTable;展开表节点 → 异步拉取列;选中列 → onSelectColumn。 + */ +export default function ExploreTree({ sourceId, service, onSelectTable, onSelectColumn, defaultExpandedTables, }: ExploreTreeProps): import("react").JSX.Element; diff --git a/report-frontend/packages/report-datasource/dist/components/explore-tree.js b/report-frontend/packages/report-datasource/dist/components/explore-tree.js new file mode 100644 index 0000000..0c1b1bc --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/explore-tree.js @@ -0,0 +1,79 @@ +import { jsx } from "react/jsx-runtime"; +import { Empty, Spin, Tree } from "antd"; +import { useMemo } from "react"; +import { useExplore } from "../hooks/use-explore.js"; +function ExploreTree({ sourceId, service, onSelectTable, onSelectColumn, defaultExpandedTables }) { + const { tables, columns, activeTable, loadingTables, loadingColumns, error, selectTable } = useExplore(service, sourceId); + const treeData = useMemo(()=>tables.map((t)=>{ + const isActive = activeTable === t.name; + return { + key: `table:${t.name}`, + title: t.comment ? `${t.comment} (${t.name})` : t.name, + isLeaf: false, + children: isActive ? columns.map((c)=>({ + key: `column:${t.name}:${c.name}`, + title: c.comment ?? c.name, + isLeaf: true + })) : void 0 + }; + }), [ + tables, + columns, + activeTable + ]); + const handleExpand = (keys)=>{ + const last = keys.length ? keys[keys.length - 1] : null; + if ('string' == typeof last && last.startsWith('table:')) { + const tableName = last.slice(6); + selectTable(tableName); + const table = tables.find((t)=>t.name === tableName) ?? null; + onSelectTable?.(table); + } else { + selectTable(null); + onSelectTable?.(null); + } + }; + const handleSelect = (keys)=>{ + const key = keys[0]; + if ('string' != typeof key) { + onSelectTable?.(null); + onSelectColumn?.(null); + return; + } + if (key.startsWith('column:')) { + const [, tableName, colName] = key.split(':'); + const col = columns.find((c)=>c.name === colName) ?? null; + onSelectTable?.(tables.find((t)=>t.name === tableName) ?? null); + onSelectColumn?.(col); + } else if (key.startsWith('table:')) { + const tableName = key.slice(6); + const table = tables.find((t)=>t.name === tableName) ?? null; + onSelectTable?.(table); + onSelectColumn?.(null); + } + }; + if (!sourceId) return /*#__PURE__*/ jsx(Empty, { + description: "请先选择数据源" + }); + if (loadingTables) return /*#__PURE__*/ jsx(Spin, {}); + if (error) return /*#__PURE__*/ jsx(Empty, { + description: error.message + }); + if (!tables.length) return /*#__PURE__*/ jsx(Empty, { + description: "无可用表" + }); + const expandedKeys = defaultExpandedTables?.map((t)=>`table:${t}`) ?? (activeTable ? [ + `table:${activeTable}` + ] : []); + return /*#__PURE__*/ jsx(Spin, { + spinning: loadingColumns, + children: /*#__PURE__*/ jsx(Tree, { + treeData: treeData, + onExpand: handleExpand, + onSelect: handleSelect, + expandedKeys: expandedKeys, + showLine: true + }) + }); +} +export default ExploreTree; diff --git a/report-frontend/packages/report-datasource/dist/components/relation-editor.d.ts b/report-frontend/packages/report-datasource/dist/components/relation-editor.d.ts new file mode 100644 index 0000000..89876d6 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/relation-editor.d.ts @@ -0,0 +1,6 @@ +import type { RelationEditorProps } from '../types'; +/** + * 关系列表编辑:在数据集之间定义 JOIN。 + * 字段下拉依赖 datasets 中各数据集的字段定义。 + */ +export default function RelationEditor({ datasets, relationships, onChange, disabled, }: RelationEditorProps): import("react").JSX.Element; diff --git a/report-frontend/packages/report-datasource/dist/components/relation-editor.js b/report-frontend/packages/report-datasource/dist/components/relation-editor.js new file mode 100644 index 0000000..d5e927f --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/components/relation-editor.js @@ -0,0 +1,231 @@ +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; +import { Button, Form, Modal, Popconfirm, Select, Space, Table } from "antd"; +import { useState } from "react"; +const JOIN_OPTIONS = [ + { + label: 'INNER', + value: 'INNER' + }, + { + label: 'LEFT', + value: 'LEFT' + }, + { + label: 'RIGHT', + value: 'RIGHT' + }, + { + label: 'FULL', + value: 'FULL' + } +]; +function RelationEditor({ datasets, relationships, onChange, disabled }) { + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const leftDatasetId = Form.useWatch([ + 'left', + 'datasetId' + ], form); + const rightDatasetId = Form.useWatch([ + 'right', + 'datasetId' + ], form); + const datasetOptions = datasets.map((d)=>({ + label: d.alias ?? d.id, + value: d.id + })); + const fieldOptionsOf = (datasetId)=>{ + if (!datasetId) return []; + const ds = datasets.find((d)=>d.id === datasetId); + return (ds?.fields ?? []).map((f)=>({ + label: f.alias ?? f.name, + value: f.name + })); + }; + const columns = [ + { + title: '左侧', + key: 'left', + render: (_, r)=>{ + const ds = datasets.find((d)=>d.id === r.left.datasetId); + return `${ds?.alias ?? r.left.datasetId}.${r.left.field}`; + } + }, + { + title: 'JOIN', + dataIndex: 'joinType', + key: 'joinType', + width: 90 + }, + { + title: '右侧', + key: 'right', + render: (_, r)=>{ + const ds = datasets.find((d)=>d.id === r.right.datasetId); + return `${ds?.alias ?? r.right.datasetId}.${r.right.field}`; + } + }, + { + title: '操作', + key: 'actions', + width: 100, + render: (_, r)=>/*#__PURE__*/ jsxs(Space, { + children: [ + /*#__PURE__*/ jsx("a", { + onClick: ()=>{ + setEditing(r); + form.setFieldsValue(r); + setModalOpen(true); + }, + children: "编辑" + }), + /*#__PURE__*/ jsx(Popconfirm, { + title: "确认删除?", + onConfirm: ()=>onChange?.(relationships.filter((x)=>x.id !== r.id)), + children: /*#__PURE__*/ jsx("a", { + children: "删除" + }) + }) + ] + }) + } + ]; + const handleAdd = ()=>{ + setEditing(null); + form.setFieldsValue({ + id: `rel-${Date.now()}`, + left: { + datasetId: datasets[0]?.id ?? '', + field: '' + }, + right: { + datasetId: datasets[1]?.id ?? datasets[0]?.id ?? '', + field: '' + }, + joinType: 'INNER' + }); + setModalOpen(true); + }; + const handleOk = async ()=>{ + const values = await form.validateFields(); + const next = editing ? relationships.map((r)=>r.id === values.id ? values : r) : [ + ...relationships, + values + ]; + onChange?.(next); + setModalOpen(false); + }; + return /*#__PURE__*/ jsxs(Fragment, { + children: [ + /*#__PURE__*/ jsx(Space, { + style: { + marginBottom: 8 + }, + children: /*#__PURE__*/ jsx(Button, { + onClick: handleAdd, + disabled: disabled || datasets.length < 2, + children: "新建关系" + }) + }), + /*#__PURE__*/ jsx(Table, { + rowKey: "id", + columns: columns, + dataSource: relationships, + pagination: false, + size: "small" + }), + /*#__PURE__*/ jsx(Modal, { + title: editing ? '编辑关系' : '新建关系', + open: modalOpen, + onOk: handleOk, + onCancel: ()=>setModalOpen(false), + destroyOnClose: true, + children: /*#__PURE__*/ jsxs(Form, { + form: form, + layout: "vertical", + children: [ + /*#__PURE__*/ jsx(Form.Item, { + name: "id", + hidden: true, + children: /*#__PURE__*/ jsx("input", {}) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "左侧数据集", + name: [ + 'left', + 'datasetId' + ], + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: datasetOptions + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "左侧字段", + name: [ + 'left', + 'field' + ], + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: fieldOptionsOf(leftDatasetId) + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "JOIN 类型", + name: "joinType", + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: JOIN_OPTIONS + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "右侧数据集", + name: [ + 'right', + 'datasetId' + ], + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: datasetOptions + }) + }), + /*#__PURE__*/ jsx(Form.Item, { + label: "右侧字段", + name: [ + 'right', + 'field' + ], + rules: [ + { + required: true + } + ], + children: /*#__PURE__*/ jsx(Select, { + options: fieldOptionsOf(rightDatasetId) + }) + }) + ] + }) + }) + ] + }); +} +export default RelationEditor; diff --git a/report-frontend/packages/report-datasource/dist/hooks/use-datasource.d.ts b/report-frontend/packages/report-datasource/dist/hooks/use-datasource.d.ts new file mode 100644 index 0000000..b8973d7 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/hooks/use-datasource.d.ts @@ -0,0 +1,16 @@ +import type { DataSourceConfig, DatasourceService } from '../types'; +/** + * 数据源 CRUD hook(接口先定义、API 调用由 #30 接入)。 + * + * 通过 service 注入实现,本包不直接 import report-api 的 HTTP 客户端; + * service 各方法可选,未注入时对应动作会被拒(抛错或返回 null)。 + */ +export declare function useDatasource(service?: DatasourceService): { + list: DataSourceConfig[]; + loading: boolean; + error: Error | null; + refresh: () => Promise; + create: (config: Omit) => Promise; + update: (config: DataSourceConfig) => Promise; + remove: (id: string) => Promise; +}; diff --git a/report-frontend/packages/report-datasource/dist/hooks/use-datasource.js b/report-frontend/packages/report-datasource/dist/hooks/use-datasource.js new file mode 100644 index 0000000..573a5b5 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/hooks/use-datasource.js @@ -0,0 +1,82 @@ +import { useCallback, useState } from "react"; +function useDatasource(service) { + const [list, setList] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const refresh = useCallback(async ()=>{ + if (!service?.listDataSources) return void setError(new Error('listDataSources 未注入')); + setLoading(true); + setError(null); + try { + const data = await service.listDataSources(); + setList(data); + } catch (e) { + setError(e); + } finally{ + setLoading(false); + } + }, [ + service + ]); + const create = useCallback(async (config)=>{ + if (!service?.createDataSource) { + setError(new Error('createDataSource 未注入')); + return null; + } + try { + const id = await service.createDataSource(config); + await refresh(); + return id; + } catch (e) { + setError(e); + return null; + } + }, [ + service, + refresh + ]); + const update = useCallback(async (config)=>{ + if (!service?.updateDataSource) { + setError(new Error('updateDataSource 未注入')); + return false; + } + try { + await service.updateDataSource(config); + await refresh(); + return true; + } catch (e) { + setError(e); + return false; + } + }, [ + service, + refresh + ]); + const remove = useCallback(async (id)=>{ + if (!service?.deleteDataSource) { + setError(new Error('deleteDataSource 未注入')); + return false; + } + try { + await service.deleteDataSource(id); + await refresh(); + return true; + } catch (e) { + setError(e); + return false; + } + }, [ + service, + refresh + ]); + return { + list, + loading, + error, + refresh, + create, + update, + remove + }; +} +export { useDatasource }; diff --git a/report-frontend/packages/report-datasource/dist/hooks/use-explore.d.ts b/report-frontend/packages/report-datasource/dist/hooks/use-explore.d.ts new file mode 100644 index 0000000..1c0e15e --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/hooks/use-explore.d.ts @@ -0,0 +1,17 @@ +import type { ColumnInfo, DatasourceService, TableInfo } from '../types'; +/** + * 表/列探查 hook(依赖 DatasourceService.exploreTables / exploreColumns)。 + * + * service 各方法可选;未注入时返回空数组并设 error。 + * sourceId 变化自动重新拉表列表;调用 selectTable(table) 触发列拉取。 + */ +export declare function useExplore(service?: DatasourceService, sourceId?: string): { + tables: TableInfo[]; + columns: ColumnInfo[]; + activeTable: string | null; + loadingTables: boolean; + loadingColumns: boolean; + error: Error | null; + refreshTables: () => Promise; + selectTable: (table: string | null) => void; +}; diff --git a/report-frontend/packages/report-datasource/dist/hooks/use-explore.js b/report-frontend/packages/report-datasource/dist/hooks/use-explore.js new file mode 100644 index 0000000..7e4ea0d --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/hooks/use-explore.js @@ -0,0 +1,78 @@ +import { useCallback, useEffect, useState } from "react"; +function useExplore(service, sourceId) { + const [tables, setTables] = useState([]); + const [columns, setColumns] = useState([]); + const [activeTable, setActiveTable] = useState(null); + const [loadingTables, setLoadingTables] = useState(false); + const [loadingColumns, setLoadingColumns] = useState(false); + const [error, setError] = useState(null); + const refreshTables = useCallback(async ()=>{ + if (!sourceId) return void setTables([]); + if (!service?.exploreTables) { + setError(new Error('exploreTables 未注入')); + setTables([]); + return; + } + setLoadingTables(true); + setError(null); + try { + const data = await service.exploreTables(sourceId); + setTables(data); + } catch (e) { + setError(e); + setTables([]); + } finally{ + setLoadingTables(false); + } + }, [ + service, + sourceId + ]); + const refreshColumns = useCallback(async (table)=>{ + if (!sourceId || !table) return void setColumns([]); + if (!service?.exploreColumns) { + setError(new Error('exploreColumns 未注入')); + setColumns([]); + return; + } + setLoadingColumns(true); + setError(null); + try { + const data = await service.exploreColumns(sourceId, table); + setColumns(data); + } catch (e) { + setError(e); + setColumns([]); + } finally{ + setLoadingColumns(false); + } + }, [ + service, + sourceId + ]); + const selectTable = useCallback((table)=>{ + setActiveTable(table); + if (table) refreshColumns(table); + else setColumns([]); + }, [ + refreshColumns + ]); + useEffect(()=>{ + refreshTables(); + setActiveTable(null); + setColumns([]); + }, [ + refreshTables + ]); + return { + tables, + columns, + activeTable, + loadingTables, + loadingColumns, + error, + refreshTables, + selectTable + }; +} +export { useExplore }; diff --git a/report-frontend/packages/report-datasource/dist/index.d.ts b/report-frontend/packages/report-datasource/dist/index.d.ts new file mode 100644 index 0000000..fd8d178 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/index.d.ts @@ -0,0 +1,7 @@ +export { default as ConnectionForm } from './components/connection-form'; +export { default as ExploreTree } from './components/explore-tree'; +export { default as DatasetManager } from './components/dataset-manager'; +export { default as RelationEditor } from './components/relation-editor'; +export { useDatasource } from './hooks/use-datasource'; +export { useExplore } from './hooks/use-explore'; +export type { DataSourceConfig, DataSourceType, JoinType, TableInfo, ColumnInfo, PhysicalDataset, UnionDatasetDef, DatasetDef, DatasetFieldDef, FieldRef, Relationship, DatasourceService, ConnectionFormProps, ExploreTreeProps, DatasetManagerProps, RelationEditorProps, } from './types'; diff --git a/report-frontend/packages/report-datasource/dist/index.js b/report-frontend/packages/report-datasource/dist/index.js new file mode 100644 index 0000000..9b0b35d --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/index.js @@ -0,0 +1,6 @@ +export { default as ConnectionForm } from "./components/connection-form.js"; +export { default as ExploreTree } from "./components/explore-tree.js"; +export { default as DatasetManager } from "./components/dataset-manager.js"; +export { default as RelationEditor } from "./components/relation-editor.js"; +export { useDatasource } from "./hooks/use-datasource.js"; +export { useExplore } from "./hooks/use-explore.js"; diff --git a/report-frontend/packages/report-datasource/dist/types.d.ts b/report-frontend/packages/report-datasource/dist/types.d.ts new file mode 100644 index 0000000..f6df797 --- /dev/null +++ b/report-frontend/packages/report-datasource/dist/types.d.ts @@ -0,0 +1,154 @@ +import type { DataType } from '@coding-report/report-api'; +/** 数据源类型(对齐后端 DataSourceType 枚举) */ +export type DataSourceType = 'CSV' | 'JSON' | 'DB' | 'API' | 'EXCEL'; +/** JOIN 类型(对齐后端 JoinType 枚举) */ +export type JoinType = 'INNER' | 'LEFT' | 'RIGHT' | 'FULL'; +/** 数据源连接配置(对齐后端 DataSource 实体,#30 完成 API 客户端时再校准字段名) */ +export interface DataSourceConfig { + id: string; + name: string; + type: DataSourceType; + /** 连接 URL(DB JDBC/CSV 路径/JSON URL/API endpoint) */ + url?: string; + /** DB 用户名 / API 认证用户名 */ + username?: string; + /** DB 密码 / API Token(前端只读展示,不回显明文) */ + password?: string; + /** 额外选项(JSON 字符串或键值对,交给后端解析) */ + options?: Record; + /** 创建时间戳 */ + createTime?: number; + /** 更新时间戳 */ + updateTime?: number; +} +/** 表信息(探查树叶子) */ +export interface TableInfo { + /** 物理表名 */ + name: string; + /** 表别名/注释(可空) */ + comment?: string; + /** 行数估算(可空) */ + rowCount?: number; +} +/** 列信息(探查树展开后的字段) */ +export interface ColumnInfo { + name: string; + dataType: DataType; + comment?: string; + nullable?: boolean; + primaryKey?: boolean; +} +/** 物理数据集:从某数据源选某张表 */ +export interface PhysicalDataset { + id: string; + alias?: string; + sourceId: string; + table: string; + fields: DatasetFieldDef[]; +} +/** UNION 派生数据集:基于多个物理数据集纵向合并 */ +export interface UnionDatasetDef { + id: string; + alias?: string; + /** 参与并集的物理数据集 id 列表(≥2) */ + baseDatasetIds: string[]; + fields: DatasetFieldDef[]; +} +/** 数据集统一描述(sealed-like:kind 区分物理 / UNION) */ +export type DatasetDef = ({ + kind: 'PHYSICAL'; +} & PhysicalDataset) | ({ + kind: 'UNION'; +} & UnionDatasetDef); +/** 数据集字段定义(数据源管理视角,与报表引擎复用) */ +export interface DatasetFieldDef { + name: string; + alias?: string; + dataType: DataType; + primaryKey?: boolean; +} +/** 字段引用 */ +export interface FieldRef { + datasetId: string; + field: string; +} +/** 数据集间关系(JOIN) */ +export interface Relationship { + id: string; + left: FieldRef; + right: FieldRef; + joinType: JoinType; +} +export interface DatasourceService { + listDataSources?(): Promise; + createDataSource?(config: Omit): Promise; + updateDataSource?(config: DataSourceConfig): Promise; + deleteDataSource?(id: string): Promise; + /** 测试连接:返回 ok=true/false + 失败信息 */ + testConnection(config: { + type?: DataSourceType; + url?: string; + username?: string; + password?: string; + options?: Record; + }): Promise<{ + ok: boolean; + message?: string; + }>; + /** 表列表 */ + exploreTables(sourceId: string): Promise; + /** 列信息 */ + exploreColumns(sourceId: string, table: string): Promise; +} +export interface ConnectionFormProps { + /** 受控值 */ + value?: Partial; + /** 值变更回调(antd Form.Item 语义,回调为 form values 整体) */ + onChange?: (value: Partial) => void; + /** 注入服务;不传则禁用「测试连接」按钮 */ + service?: DatasourceService; + /** 受控测试结果(可选;不传则内部自管) */ + testResult?: { + ok: boolean; + message?: string; + }; + /** 测试结果变更回调(受控模式配套) */ + onTestResultChange?: (result: { + ok: boolean; + message?: string; + } | null) => void; + /** 是否禁用 */ + disabled?: boolean; +} +export interface ExploreTreeProps { + /** 当前选中数据源 id */ + sourceId?: string; + /** 注入服务(必须提供 exploreTables/exploreColumns) */ + service?: DatasourceService; + /** 选中表时回调 */ + onSelectTable?: (table: TableInfo | null) => void; + /** 选中列时回调 */ + onSelectColumn?: (column: ColumnInfo | null) => void; + /** 默认展开的表名列表 */ + defaultExpandedTables?: string[]; +} +export interface DatasetManagerProps { + /** 数据源下已有数据集列表 */ + datasets: DatasetDef[]; + /** 可选数据源列表(创建物理数据集时选源) */ + dataSources: DataSourceConfig[]; + /** 注入服务(探查表/列用,可选) */ + service?: DatasourceService; + /** 数据集变更回调(增删改) */ + onChange?: (datasets: DatasetDef[]) => void; +} +export interface RelationEditorProps { + /** 数据集列表(关系引用其中的 id/alias) */ + datasets: DatasetDef[]; + /** 已有关系列表 */ + relationships: Relationship[]; + /** 关系变更回调 */ + onChange?: (relationships: Relationship[]) => void; + /** 是否禁用 */ + disabled?: boolean; +} diff --git a/report-frontend/packages/report-datasource/dist/types.js b/report-frontend/packages/report-datasource/dist/types.js new file mode 100644 index 0000000..e69de29 diff --git a/report-frontend/packages/report-datasource/node_modules/.bin/msw b/report-frontend/packages/report-datasource/node_modules/.bin/msw new file mode 100755 index 0000000..7807b01 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.bin/msw @@ -0,0 +1,21 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/msw@2.14.6_@types+node@25.9.4_typescript@5.9.3/node_modules/msw/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/msw@2.14.6_@types+node@25.9.4_typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/msw@2.14.6_@types+node@25.9.4_typescript@5.9.3/node_modules/msw/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/msw@2.14.6_@types+node@25.9.4_typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../msw/cli/index.js" "$@" +else + exec node "$basedir/../msw/cli/index.js" "$@" +fi diff --git a/report-frontend/packages/report-datasource/node_modules/.bin/tsc b/report-frontend/packages/report-datasource/node_modules/.bin/tsc new file mode 100755 index 0000000..264e4bb --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.bin/tsc @@ -0,0 +1,21 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsc" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsc" "$@" +fi diff --git a/report-frontend/packages/report-datasource/node_modules/.bin/tsserver b/report-frontend/packages/report-datasource/node_modules/.bin/tsserver new file mode 100755 index 0000000..ab69ac3 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.bin/tsserver @@ -0,0 +1,21 @@ +#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')") + +case `uname` in + *CYGWIN*|*MINGW*|*MSYS*) + if command -v cygpath > /dev/null 2>&1; then + basedir=`cygpath -w "$basedir"` + fi + ;; +esac + +if [ -z "$NODE_PATH" ]; then + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules" +else + export NODE_PATH="/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/typescript@5.9.3/node_modules:/Users/lorne/developer/github/java/report-engine/report-frontend/node_modules/.pnpm/node_modules:$NODE_PATH" +fi +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsserver" "$@" +else + exec node "$basedir/../../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsserver" "$@" +fi diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/0.pack b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/0.pack new file mode 100644 index 0000000000000000000000000000000000000000..512ca3acaa8b3ae23850f5cac8fbf89182f5d804 GIT binary patch literal 15 WcmXq4FyTr`O-n4zDM@5xU;qFhh6CLI literal 0 HcmV?d00001 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/_meta b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/_meta new file mode 100644 index 0000000..6412902 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/meta/_meta @@ -0,0 +1,2 @@ +1 +0 4588461511994568930 0 0 0 137438953472 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/0.pack b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/0.pack new file mode 100644 index 0000000000000000000000000000000000000000..1e28bad0de563521fb5313f6d3353b1acba5daee GIT binary patch literal 307633 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 O^6^=pobUIA)Bgkfdw2x^ literal 0 HcmV?d00001 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/_meta b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/_meta new file mode 100644 index 0000000..8a3230d --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/occasion_make_module_graph/_meta @@ -0,0 +1,2 @@ +1 +0 8383173958488308308 36170776293933440 4785074807248896 2314850208468566016 13262859272320 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/0.pack b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/0.pack new file mode 100644 index 0000000000000000000000000000000000000000..37a27d477138f82269dc00c100e7951095c53d87 GIT binary patch literal 1541 zcmd^TYX|94nBDBwR8eAq*km0wjs3K1q8d_J=-^ zAh^^Pu!x#7#i}nk)4)Q)cnIAA57Pt`!Geha@ucddGzl9bNzENgf*)wl!7beBmU{u0 z;JMhwwj~2>cRd&|U(iNgcd%prvL;ie=>W_Vc{w{`#mZ~q){ePZl`w5UW}6e?2d91n zI98mY$5n*Z$Kx?7zFgVNIx9QQV(cjztEq3HuYJW+89+tTT@9g8Az`JEb$NIYZ*1bL zVu@ULcZtLj+M9T9W7o#b()-`@(B}eFAgRz^8}H%f7e1yH$(|)4Rhm#1!kGaxGE}x^ zC`@#GP|9rhdlpoJc{7Ivj%zO@1wRB+Dnkedob_phwL%XjoY$Yixql&}yV~t*8P0z- iPAb8DnrECWpPE($v3$lMbpH*tqwV@zBYA$AQhNtjRsH<{ literal 0 HcmV?d00001 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/_meta b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/_meta new file mode 100644 index 0000000..e9e4c59 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_build/_meta @@ -0,0 +1,2 @@ +1 +0 3445096613188050660 16384 2199023255552 1152921508910202881 4398046511104 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_file/0.pack b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_file/0.pack new file mode 100644 index 0000000000000000000000000000000000000000..0f0e67a71ccf157189bf36d5ddb7b4aedc426318 GIT binary patch literal 1443 zcmchXJxBvF6oAjc!A^>H6|t_@c(qhJsDnB+}jlj`_L2v%20+-49J98$PgPS zN?1JU#866~)e!}oP_6<_SYl5ji&KI*U=!K8k=7Hyz6z$!bw(U&0AkcY2Fcn41Lh35 z72s-C$Kn7Da+Xt+(StG4^I?&4HMlWvnjM&}m6Dwq~0|JWS9soShXUe52UgZo-&K(0;5 z*d(ucL4#lnhC79ELbI;CUte_dZQWi^ z8Cn*f?#hzv@w^S}4}0-w-QL5|O0ZCPJCT6Y_$q@bM(}S9wIz%(#$5e^Vi(_ie!NqYtcMvlS6!tp|o=m0|M);CzP-W$G5 zZ`k>l{TO6BuoJ*2WM)mCTL*!YTDgyz3ZmtEb>uK079)@MC-?;$?*R E0{W$tC;$Ke literal 0 HcmV?d00001 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_missing/_meta b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_missing/_meta new file mode 100644 index 0000000..df4bff6 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/2004aed734fc8637/snapshot_missing/_meta @@ -0,0 +1,2 @@ +1 +0 15527010172558247972 0 8796093022464 0 36028797018963968 diff --git a/report-frontend/packages/report-datasource/node_modules/.cache/rspack/_meta b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/_meta new file mode 100644 index 0000000..91654db --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/.cache/rspack/_meta @@ -0,0 +1 @@ +2004aed734fc8637 1782387627 diff --git a/report-frontend/packages/report-datasource/node_modules/@ant-design/icons b/report-frontend/packages/report-datasource/node_modules/@ant-design/icons new file mode 120000 index 0000000..82e10f1 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/@ant-design/icons @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@ant-design+icons@6.2.5_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@ant-design/icons \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/@coding-report/report-api b/report-frontend/packages/report-datasource/node_modules/@coding-report/report-api new file mode 120000 index 0000000..44351ba --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/@coding-report/report-api @@ -0,0 +1 @@ +../../../report-api \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/@testing-library/jest-dom b/report-frontend/packages/report-datasource/node_modules/@testing-library/jest-dom new file mode 120000 index 0000000..c1182f9 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/@testing-library/jest-dom @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@testing-library+jest-dom@6.9.1/node_modules/@testing-library/jest-dom \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/@testing-library/react b/report-frontend/packages/report-datasource/node_modules/@testing-library/react new file mode 120000 index 0000000..223f4eb --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/@testing-library/react @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@testing-library+react@16.3.2_@testing-library+dom@10.4.1_@types+react-dom@18.3.7_@type_b0bbe147884ef4cbbf95e64a97c782e8/node_modules/@testing-library/react \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/@testing-library/user-event b/report-frontend/packages/report-datasource/node_modules/@testing-library/user-event new file mode 120000 index 0000000..dd8a2d5 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/@testing-library/user-event @@ -0,0 +1 @@ +../../../../node_modules/.pnpm/@testing-library+user-event@14.6.1_@testing-library+dom@10.4.1/node_modules/@testing-library/user-event \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/antd b/report-frontend/packages/report-datasource/node_modules/antd new file mode 120000 index 0000000..ba7f8fa --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/antd @@ -0,0 +1 @@ +../../../node_modules/.pnpm/antd@6.4.5_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/antd \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/happy-dom b/report-frontend/packages/report-datasource/node_modules/happy-dom new file mode 120000 index 0000000..dcda797 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/happy-dom @@ -0,0 +1 @@ +../../../node_modules/.pnpm/happy-dom@20.10.6/node_modules/happy-dom \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/msw b/report-frontend/packages/report-datasource/node_modules/msw new file mode 120000 index 0000000..0cc1bcd --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/msw @@ -0,0 +1 @@ +../../../node_modules/.pnpm/msw@2.14.6_@types+node@25.9.4_typescript@5.9.3/node_modules/msw \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/react b/report-frontend/packages/report-datasource/node_modules/react new file mode 120000 index 0000000..b816850 --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/react @@ -0,0 +1 @@ +../../../node_modules/.pnpm/react@18.3.1/node_modules/react \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/node_modules/react-dom b/report-frontend/packages/report-datasource/node_modules/react-dom new file mode 120000 index 0000000..bf3739a --- /dev/null +++ b/report-frontend/packages/report-datasource/node_modules/react-dom @@ -0,0 +1 @@ +../../../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom \ No newline at end of file diff --git a/report-frontend/packages/report-datasource/package.json b/report-frontend/packages/report-datasource/package.json new file mode 100644 index 0000000..88a0798 --- /dev/null +++ b/report-frontend/packages/report-datasource/package.json @@ -0,0 +1,53 @@ +{ + "name": "@coding-report/report-datasource", + "version": "0.0.1", + "description": "report-datasource management UI", + "keywords": [ + "coding-report", + "report-datasource" + ], + "homepage": "https://github.com/codingapi/report-engine", + "bugs": { + "url": "https://github.com/codingapi/report-engine/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/codingapi/report-engine.git" + }, + "license": "Apache-2.0", + "author": "1024lorne@gmail.com", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rslib build", + "dev": "rslib build --watch", + "test": "rstest", + "test:node": "rstest --project node", + "push": "pnpm run build && pnpm publish --access public" + }, + "dependencies": { + "@coding-report/report-api": "workspace:*" + }, + "peerDependencies": { + "@ant-design/icons": ">=5", + "antd": ">=5", + "react": ">=18", + "react-dom": ">=18" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "happy-dom": "^20.8.3", + "msw": "^2.14.6" + } +} diff --git a/report-frontend/packages/report-datasource/rslib.config.ts b/report-frontend/packages/report-datasource/rslib.config.ts new file mode 100644 index 0000000..1908914 --- /dev/null +++ b/report-frontend/packages/report-datasource/rslib.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from '@rslib/core'; +import { pluginReact } from '@rsbuild/plugin-react'; + +import * as path from 'node:path'; + +export default defineConfig({ + source: { + entry: { + index: ['./src/**'], + }, + }, + lib: [ + { + bundle: false, + dts: true, + format: 'esm', + }, + ], + resolve: { + alias: { + '@/': path.resolve(__dirname, 'src'), + }, + }, + output: { + target: 'web', + }, + plugins: [pluginReact()], +}); diff --git a/report-frontend/packages/report-datasource/rstest.config.ts b/report-frontend/packages/report-datasource/rstest.config.ts new file mode 100644 index 0000000..256ff6d --- /dev/null +++ b/report-frontend/packages/report-datasource/rstest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from '@rstest/core'; +import { withRslibConfig } from '@rstest/adapter-rslib'; + +/** + * rstest 配置:通过 adapter-rslib 继承 rslib.config.ts(alias `@/`、define、source 等)。 + * 仅 node project(happy-dom):本包测试聚焦交互逻辑与结构层,无 computed-style 样式层测试。 + * 测试代码置于与 src 平级的 test/ 目录,保持 src 纯净。 + */ +export default defineConfig({ + projects: [ + { + name: 'node', + extends: withRslibConfig(), + testEnvironment: 'happy-dom', + setupFiles: ['./test/setup.ts'], + globals: true, + include: ['test/**/*.{test,spec}.{ts,tsx}'], + }, + ], +}); diff --git a/report-frontend/packages/report-datasource/src/components/connection-form.tsx b/report-frontend/packages/report-datasource/src/components/connection-form.tsx new file mode 100644 index 0000000..2c96b2a --- /dev/null +++ b/report-frontend/packages/report-datasource/src/components/connection-form.tsx @@ -0,0 +1,102 @@ +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: 'CSV', value: 'CSV' }, + { label: 'JSON', value: 'JSON' }, + { label: 'DB', value: 'DB' }, + { label: 'API', value: 'API' }, + { label: 'EXCEL', value: 'EXCEL' }, +]; + +/** + * 数据源连接配置表单(受控 + 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-datasource/src/components/dataset-manager.tsx b/report-frontend/packages/report-datasource/src/components/dataset-manager.tsx new file mode 100644 index 0000000..0a50b7c --- /dev/null +++ b/report-frontend/packages/report-datasource/src/components/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 ( + + + + + + + + + + +