diff --git a/docs/sql/01_schema.sql b/docs/sql/01_schema.sql new file mode 100644 index 0000000..36a902e --- /dev/null +++ b/docs/sql/01_schema.sql @@ -0,0 +1,53 @@ +-- ===================================================================== +-- 开放接口中心 网关库表(T0.9) +-- 库:open_gateway(projman provision 建;本脚本由 T1.7 生产导入,可重复执行) +-- 方言:MySQL +-- ===================================================================== + +DROP TABLE IF EXISTS open_api_route; +CREATE TABLE open_api_route ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + target_code VARCHAR(32) NOT NULL COMMENT '目标应用 ctx(usermsg/notice/...)', + path_pattern VARCHAR(256) NOT NULL COMMENT 'Ant 风格;匹配剥掉 target 首段后的路径,如 /oapi/message/**', + http_method VARCHAR(8) NOT NULL DEFAULT '*' COMMENT 'GET/POST/PUT/DELETE/*', + visibility VARCHAR(16) NOT NULL DEFAULT 'INTERNAL' COMMENT 'INTERNAL/EXTERNAL', + auth_mode VARCHAR(16) NOT NULL DEFAULT 'SIGNED' COMMENT 'SIGNED/ANON/OPTIONAL', + cache_ttl_seconds INT NOT NULL DEFAULT 0, + cache_scope VARCHAR(16) NOT NULL DEFAULT 'CALLER' COMMENT 'CALLER/GLOBAL', + rate_limit_qps INT NOT NULL DEFAULT 0 COMMENT '0=不限', + timeout_ms INT NOT NULL DEFAULT 30000, + request_body_limit_mb INT NOT NULL DEFAULT 10, + enabled TINYINT NOT NULL DEFAULT 1, + remark VARCHAR(256), + gmt_create DATETIME NOT NULL, + gmt_modified DATETIME NOT NULL, + UNIQUE KEY uk_route (target_code, path_pattern, http_method) +); + +DROP TABLE IF EXISTS open_api_log; +CREATE TABLE open_api_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + ts DATETIME(3) NOT NULL, + caller_app_key VARCHAR(64) COMMENT 'ANON 记 anonymous;401 且无 appKey 记 NULL', + target_code VARCHAR(32) NOT NULL, + method VARCHAR(8) NOT NULL, + path VARCHAR(256) NOT NULL COMMENT '剥掉 target 首段后的路径(与注册表 path_pattern 同一坐标系)', + status INT NOT NULL COMMENT '网关出口 HTTP 状态', + backend_code INT COMMENT '提供方业务 code(响应为 JSON 信封时尽力解析,否则 NULL)', + cost_ms INT NOT NULL, + KEY idx_ts (ts), + KEY idx_caller_ts (caller_app_key, ts), + KEY idx_target_ts (target_code, ts) +); + +DROP TABLE IF EXISTS open_api_stat_daily; +CREATE TABLE open_api_stat_daily ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + stat_date DATE NOT NULL, + target_code VARCHAR(32) NOT NULL, + caller_app_key VARCHAR(64) NOT NULL, + total INT NOT NULL DEFAULT 0, + err_total INT NOT NULL DEFAULT 0, + avg_cost_ms INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_stat (stat_date, target_code, caller_app_key) +); diff --git a/src/main/java/com/yunhai/platform/gateway/entity/OpenApiLog.java b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiLog.java new file mode 100644 index 0000000..1821987 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiLog.java @@ -0,0 +1,59 @@ +package com.yunhai.platform.gateway.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.Table; +import java.time.LocalDateTime; + +/** + * 网关访问台账(T0.9;观测设施,只插入不改写;索引 idx_ts / idx_caller_ts / idx_target_ts 与 DDL 对齐)。 + */ +@Entity +@Table(name = "open_api_log", indexes = { + @Index(name = "idx_ts", columnList = "ts"), + @Index(name = "idx_caller_ts", columnList = "caller_app_key, ts"), + @Index(name = "idx_target_ts", columnList = "target_code, ts") +}) +@Getter +@Setter +public class OpenApiLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "ts", nullable = false, columnDefinition = "DATETIME(3)") + private LocalDateTime ts; + + /** ANON 记 anonymous;401 且无 appKey 记 NULL */ + @Column(name = "caller_app_key", length = 64) + private String callerAppKey; + + @Column(name = "target_code", nullable = false, length = 32) + private String targetCode; + + @Column(name = "method", nullable = false, length = 8) + private String method; + + /** 剥掉 target 首段后的路径(与注册表 path_pattern 同一坐标系) */ + @Column(name = "path", nullable = false, length = 256) + private String path; + + /** 网关出口 HTTP 状态 */ + @Column(name = "status", nullable = false) + private int status; + + /** 提供方业务 code(响应为 JSON 信封时尽力解析,否则 NULL) */ + @Column(name = "backend_code") + private Integer backendCode; + + @Column(name = "cost_ms", nullable = false) + private int costMs; +} diff --git a/src/main/java/com/yunhai/platform/gateway/entity/OpenApiRoute.java b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiRoute.java new file mode 100644 index 0000000..e5cfff6 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiRoute.java @@ -0,0 +1,96 @@ +package com.yunhai.platform.gateway.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDateTime; + +/** + * 路由注册表(T0.9;DDL 见 docs/sql/01_schema.sql,字段与列逐一对齐)。 + *

+ * Java 字段默认值与 DDL DEFAULT 逐列一致——插入即生效,不依赖 DB 默认兜底 + * (提供方注册初版由 02_init_routes.sql 直插,运行时仅读)。 + */ +@Entity +@Table(name = "open_api_route", uniqueConstraints = @UniqueConstraint( + name = "uk_route", columnNames = {"target_code", "path_pattern", "http_method"})) +@Getter +@Setter +public class OpenApiRoute { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "target_code", nullable = false, length = 32) + private String targetCode; + + @Column(name = "path_pattern", nullable = false, length = 256) + private String pathPattern; + + /** GET/POST/PUT/DELETE/* */ + @Column(name = "http_method", nullable = false, length = 8) + private String httpMethod = "*"; + + /** INTERNAL/EXTERNAL */ + @Column(name = "visibility", nullable = false, length = 16) + private String visibility = "INTERNAL"; + + /** SIGNED/ANON/OPTIONAL */ + @Column(name = "auth_mode", nullable = false, length = 16) + private String authMode = "SIGNED"; + + @Column(name = "cache_ttl_seconds", nullable = false) + private int cacheTtlSeconds = 0; + + /** CALLER/GLOBAL */ + @Column(name = "cache_scope", nullable = false, length = 16) + private String cacheScope = "CALLER"; + + /** 0=不限 */ + @Column(name = "rate_limit_qps", nullable = false) + private int rateLimitQps = 0; + + @Column(name = "timeout_ms", nullable = false) + private int timeoutMs = 30000; + + @Column(name = "request_body_limit_mb", nullable = false) + private int requestBodyLimitMb = 10; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "remark", length = 256) + private String remark; + + @Column(name = "gmt_create", nullable = false) + private LocalDateTime gmtCreate; + + @Column(name = "gmt_modified", nullable = false) + private LocalDateTime gmtModified; + + @PrePersist + void preCreate() { + LocalDateTime now = LocalDateTime.now(); + if (gmtCreate == null) { + gmtCreate = now; + } + if (gmtModified == null) { + gmtModified = now; + } + } + + @PreUpdate + void preUpdate() { + gmtModified = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/yunhai/platform/gateway/entity/OpenApiStatDaily.java b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiStatDaily.java new file mode 100644 index 0000000..357edee --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/entity/OpenApiStatDaily.java @@ -0,0 +1,46 @@ +package com.yunhai.platform.gateway.entity; + +import lombok.Getter; +import lombok.Setter; + +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import java.time.LocalDate; + +/** + * 日统计(T0.9;由台账刷盘线程按日 upsert,uk_stat = stat_date + target_code + caller_app_key)。 + */ +@Entity +@Table(name = "open_api_stat_daily", uniqueConstraints = @UniqueConstraint( + name = "uk_stat", columnNames = {"stat_date", "target_code", "caller_app_key"})) +@Getter +@Setter +public class OpenApiStatDaily { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "stat_date", nullable = false) + private LocalDate statDate; + + @Column(name = "target_code", nullable = false, length = 32) + private String targetCode; + + @Column(name = "caller_app_key", nullable = false, length = 64) + private String callerAppKey; + + @Column(name = "total", nullable = false) + private int total = 0; + + @Column(name = "err_total", nullable = false) + private int errTotal = 0; + + @Column(name = "avg_cost_ms", nullable = false) + private int avgCostMs = 0; +} diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java new file mode 100644 index 0000000..bfde09e --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiLogRepository.java @@ -0,0 +1,7 @@ +package com.yunhai.platform.gateway.repository; + +import com.yunhai.platform.gateway.entity.OpenApiLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OpenApiLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiRouteRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiRouteRepository.java new file mode 100644 index 0000000..d257ed3 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiRouteRepository.java @@ -0,0 +1,11 @@ +package com.yunhai.platform.gateway.repository; + +import com.yunhai.platform.gateway.entity.OpenApiRoute; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface OpenApiRouteRepository extends JpaRepository { + + List findByEnabledTrue(); +} diff --git a/src/main/java/com/yunhai/platform/gateway/repository/OpenApiStatDailyRepository.java b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiStatDailyRepository.java new file mode 100644 index 0000000..4af15a7 --- /dev/null +++ b/src/main/java/com/yunhai/platform/gateway/repository/OpenApiStatDailyRepository.java @@ -0,0 +1,7 @@ +package com.yunhai.platform.gateway.repository; + +import com.yunhai.platform.gateway.entity.OpenApiStatDaily; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OpenApiStatDailyRepository extends JpaRepository { +} diff --git a/src/test/java/com/yunhai/platform/gateway/entity/OpenApiRegistrySchemaTest.java b/src/test/java/com/yunhai/platform/gateway/entity/OpenApiRegistrySchemaTest.java new file mode 100644 index 0000000..1acccc0 --- /dev/null +++ b/src/test/java/com/yunhai/platform/gateway/entity/OpenApiRegistrySchemaTest.java @@ -0,0 +1,117 @@ +package com.yunhai.platform.gateway.entity; + +import com.yunhai.platform.gateway.repository.OpenApiLogRepository; +import com.yunhai.platform.gateway.repository.OpenApiRouteRepository; +import com.yunhai.platform.gateway.repository.OpenApiStatDailyRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 注册表 DDL ↔ 实体一致性(T0.9 TDD): + * H2 MODE=MySQL 直接执行交付的 docs/sql/01_schema.sql(非 Hibernate 生成表), + * 三实体 round-trip 全字段一致(含 Java 侧默认值 = DDL DEFAULT 逐列对齐)+ 两个唯一键冲突。 + */ +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class OpenApiRegistrySchemaTest { + + @Autowired + private OpenApiRouteRepository routeRepo; + @Autowired + private OpenApiLogRepository logRepo; + @Autowired + private OpenApiStatDailyRepository statRepo; + + @Test + void 三实体round_trip_默认值与DDL逐列一致() { + OpenApiRoute route = new OpenApiRoute(); + route.setTargetCode("usermsg"); + route.setPathPattern("/oapi/message/**"); + routeRepo.saveAndFlush(route); + OpenApiRoute loaded = routeRepo.findById(route.getId()).orElse(null); + assertThat(loaded).isNotNull(); + assertThat(loaded.getTargetCode()).isEqualTo("usermsg"); + assertThat(loaded.getPathPattern()).isEqualTo("/oapi/message/**"); + assertThat(loaded.getHttpMethod()).isEqualTo("*"); + assertThat(loaded.getVisibility()).isEqualTo("INTERNAL"); + assertThat(loaded.getAuthMode()).isEqualTo("SIGNED"); + assertThat(loaded.getCacheTtlSeconds()).isZero(); + assertThat(loaded.getCacheScope()).isEqualTo("CALLER"); + assertThat(loaded.getRateLimitQps()).isZero(); + assertThat(loaded.getTimeoutMs()).isEqualTo(30000); + assertThat(loaded.getRequestBodyLimitMb()).isEqualTo(10); + assertThat(loaded.isEnabled()).isTrue(); + assertThat(loaded.getRemark()).isNull(); + assertThat(loaded.getGmtCreate()).isNotNull(); + assertThat(loaded.getGmtModified()).isNotNull(); + + OpenApiLog logRow = new OpenApiLog(); + logRow.setTs(LocalDateTime.now()); + logRow.setTargetCode("usermsg"); + logRow.setMethod("GET"); + logRow.setPath("/oapi/message/list"); + logRow.setStatus(200); + logRow.setBackendCode(null); + logRow.setCostMs(12); + logRepo.saveAndFlush(logRow); + OpenApiLog loadedLog = logRepo.findById(logRow.getId()).orElse(null); + assertThat(loadedLog).isNotNull(); + assertThat(loadedLog.getCallerAppKey()).as("401 且无 appKey 记 NULL").isNull(); + assertThat(loadedLog.getBackendCode()).as("非信封响应记 NULL").isNull(); + assertThat(loadedLog.getStatus()).isEqualTo(200); + assertThat(loadedLog.getCostMs()).isEqualTo(12); + assertThat(loadedLog.getTs()).isNotNull(); + + OpenApiStatDaily stat = new OpenApiStatDaily(); + stat.setStatDate(LocalDate.now()); + stat.setTargetCode("usermsg"); + stat.setCallerAppKey("100617"); + statRepo.saveAndFlush(stat); + OpenApiStatDaily loadedStat = statRepo.findById(stat.getId()).orElse(null); + assertThat(loadedStat).isNotNull(); + assertThat(loadedStat.getTotal()).isZero(); + assertThat(loadedStat.getErrTotal()).isZero(); + assertThat(loadedStat.getAvgCostMs()).isZero(); + } + + @Test + void uk_route_同三元组二次插入冲突() { + OpenApiRoute a = new OpenApiRoute(); + a.setTargetCode("notice"); + a.setPathPattern("/oapi/notice/list"); + routeRepo.saveAndFlush(a); + + OpenApiRoute b = new OpenApiRoute(); + b.setTargetCode("notice"); + b.setPathPattern("/oapi/notice/list"); + assertThatThrownBy(() -> routeRepo.saveAndFlush(b)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void uk_stat_同三元组二次插入冲突() { + OpenApiStatDaily a = new OpenApiStatDaily(); + a.setStatDate(LocalDate.of(2026, 9, 21)); + a.setTargetCode("notice"); + a.setCallerAppKey("100617"); + statRepo.saveAndFlush(a); + + OpenApiStatDaily b = new OpenApiStatDaily(); + b.setStatDate(LocalDate.of(2026, 9, 21)); + b.setTargetCode("notice"); + b.setCallerAppKey("100617"); + assertThatThrownBy(() -> statRepo.saveAndFlush(b)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 0000000..18a0fa5 --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +spring: + datasource: + url: jdbc:h2:mem:gatewaytest;MODE=MySQL;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + sql: + init: + # 直接跑交付 DDL(docs/sql/01_schema.sql)——round-trip 钉死「DDL ↔ 实体」一致 + mode: always + schema-locations: file:docs/sql/01_schema.sql + jpa: + hibernate: + ddl-auto: none