T0.9: 注册表三表 DDL(01_schema.sql) + JPA 实体/Repository + H2 MODE=MySQL round-trip 与唯一约束测试
This commit is contained in:
@@ -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)
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,字段与列逐一对齐)。
|
||||||
|
* <p>
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<OpenApiLog, Long> {
|
||||||
|
}
|
||||||
@@ -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<OpenApiRoute, Long> {
|
||||||
|
|
||||||
|
List<OpenApiRoute> findByEnabledTrue();
|
||||||
|
}
|
||||||
@@ -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<OpenApiStatDaily, Long> {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user