Compare commits
4 Commits
v0.23.0-pr
...
v0.25.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a177744ab | |||
| 8eddef9cc8 | |||
| 800974cfff | |||
| 9fade3bedb |
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,5 +1,21 @@
|
||||
# Changelog
|
||||
|
||||
## v0.25.0-preview(2026-09-26)
|
||||
|
||||
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
|
||||
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
|
||||
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
|
||||
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
|
||||
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
|
||||
|
||||
## v0.24.0-preview(2026-09-26)
|
||||
|
||||
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。
|
||||
- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。
|
||||
- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。
|
||||
- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。
|
||||
- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。
|
||||
|
||||
## v0.23.0-preview(2026-09-26)
|
||||
|
||||
- MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。
|
||||
|
||||
@@ -189,3 +189,7 @@ Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量
|
||||
5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。
|
||||
|
||||
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
|
||||
|
||||
## 跨域配置
|
||||
|
||||
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.23.0-preview)
|
||||
# 通用业务开发指南(v0.25.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ app.GET("/metrics", measurements.Handler())
|
||||
// 也可以直接读取 measurements.Snapshot()。
|
||||
```
|
||||
|
||||
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。
|
||||
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。
|
||||
|
||||
## 缓存:cache 与 cache/redis
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 生成项目与文件维护边界
|
||||
|
||||
v0.23.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
v0.25.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
## 创建和日常开发
|
||||
|
||||
@@ -215,7 +215,7 @@ get /download (DownloadReq)
|
||||
|
||||
## 已有服务接入步骤
|
||||
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.23.0-preview:
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.25.0-preview:
|
||||
|
||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||
|
||||
35
README.md
35
README.md
@@ -2,7 +2,11 @@
|
||||
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.23.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.25.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
|
||||
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
||||
|
||||
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
|
||||
|
||||
**v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。
|
||||
|
||||
@@ -33,6 +37,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
|
||||
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
|
||||
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
|
||||
| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) |
|
||||
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
|
||||
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
|
||||
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
|
||||
@@ -42,7 +47,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
|
||||
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
|
||||
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
|
||||
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
|
||||
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
|
||||
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
|
||||
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
||||
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
||||
@@ -56,6 +61,26 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
|
||||
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
|
||||
|
||||
## 浏览器跨域 CORS
|
||||
|
||||
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
cors:
|
||||
enabled: true
|
||||
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
|
||||
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
|
||||
allowedHeaders: [Authorization, Content-Type]
|
||||
exposedHeaders: [Content-Disposition, X-Request-ID]
|
||||
allowCredentials: false
|
||||
maxAge: 10m
|
||||
```
|
||||
|
||||
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
|
||||
|
||||
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
|
||||
|
||||
## 带认证的出站 HTTP
|
||||
|
||||
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
|
||||
@@ -128,7 +153,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
||||
|
||||
```sh
|
||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -285,7 +310,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -611,7 +636,7 @@ go test ./...
|
||||
|
||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||
|
||||
CLI 与业务 module 固定使用 `v0.23.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
CLI 与业务 module 固定使用 `v0.25.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
51
RELEASE.md
51
RELEASE.md
@@ -1,4 +1,51 @@
|
||||
# v0.23.0-preview
|
||||
# v0.25.0-preview
|
||||
|
||||
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
|
||||
|
||||
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
|
||||
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
|
||||
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
|
||||
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
|
||||
|
||||
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
|
||||
|
||||
---
|
||||
|
||||
# v0.24.0-preview(历史版本)
|
||||
|
||||
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
|
||||
|
||||
- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。
|
||||
- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。
|
||||
- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。
|
||||
- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go run . -config etc/config.yaml -check-config
|
||||
```
|
||||
|
||||
现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。
|
||||
|
||||
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
|
||||
|
||||
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
|
||||
|
||||
---
|
||||
|
||||
# v0.23.0-preview(历史版本)
|
||||
|
||||
减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。
|
||||
|
||||
@@ -19,6 +66,8 @@ go run . -config etc/config.yaml -check-config
|
||||
|
||||
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
|
||||
|
||||
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
|
||||
|
||||
---
|
||||
|
||||
# v0.22.0-preview(历史版本)
|
||||
|
||||
18
app/app.go
18
app/app.go
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
// httpx.Router, leaving the network implementation replaceable.
|
||||
type App struct {
|
||||
router httpx.Router
|
||||
handler http.Handler
|
||||
config config.Config
|
||||
middlewares []httpx.Middleware
|
||||
closers []func() error
|
||||
@@ -67,6 +69,11 @@ func configure(options ...config.Option) config.Config {
|
||||
}
|
||||
|
||||
func newApp(router httpx.Router, settings config.Config) *App {
|
||||
settings.CORS = settings.CORS.Clone()
|
||||
corsPolicy, corsErr := cors.New(settings.CORS)
|
||||
if corsErr != nil {
|
||||
panic(config.AtField("cors", corsErr))
|
||||
}
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
encoder, responseErr := response.New(settings.Response)
|
||||
@@ -93,6 +100,7 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
}
|
||||
middlewares = append(middlewares, middleware.Recovery())
|
||||
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
||||
application.handler = corsPolicy.Wrap(router)
|
||||
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
||||
adaptError := func(status int, code, message string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -104,21 +112,23 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
||||
return application
|
||||
}
|
||||
|
||||
// Router returns the transport contract used by this application.
|
||||
// Router exposes the underlying routing contract for advanced registration.
|
||||
// Serve requests through App or Engine so application CORS policy is applied.
|
||||
func (a *App) Router() httpx.Router { return a.router }
|
||||
|
||||
// Engine is kept as a compatibility name for the old API. It returns a
|
||||
// standard http.Handler, so callers do not depend on a router type.
|
||||
func (a *App) Engine() http.Handler { return a.router }
|
||||
func (a *App) Engine() http.Handler { return a.handler }
|
||||
|
||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||
a.router.ServeHTTP(writer, request)
|
||||
a.handler.ServeHTTP(writer, request)
|
||||
}
|
||||
|
||||
// Config returns the effective application configuration.
|
||||
func (a *App) Config() config.Config {
|
||||
settings := a.config
|
||||
settings.CORS = settings.CORS.Clone()
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
@@ -177,7 +187,7 @@ func (a *App) NewServer(address string) *http.Server {
|
||||
}
|
||||
}
|
||||
cfg := a.config.HTTP
|
||||
return &http.Server{Addr: address, Handler: a.router,
|
||||
return &http.Server{Addr: address, Handler: a.handler,
|
||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||
|
||||
187
app/cors_test.go
Normal file
187
app/cors_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func corsApp() *omni.App {
|
||||
return omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST", "PUT"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 999, MessageField: "msg", DataMode: "always"}))
|
||||
}
|
||||
func TestAppCORSRoutingAndErrors(t *testing.T) {
|
||||
a := corsApp()
|
||||
defer a.Close()
|
||||
var middlewareCalls int
|
||||
a.Use(func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { middlewareCalls++; return next(c) }
|
||||
})
|
||||
a.GET("/secure", func(c *omni.Context) error { return c.OK("private") }, func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { return omni.Unauthorized("LOGIN_REQUIRED", "login required") }
|
||||
})
|
||||
a.GET("/failure", func(c *omni.Context) error { return omni.Internal(errors.New("private detail")) })
|
||||
a.GET("/panic", func(c *omni.Context) error { panic("private panic") })
|
||||
a.Handle("OPTIONS", "/options", func(c *omni.Context) error { return c.Text(200, "ordinary options") })
|
||||
for name, entry := range map[string]http.Handler{"App": a, "Engine": a.Engine(), "NewServer": a.NewServer("").Handler} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{{"GET", "/secure", 401}, {"GET", "/failure", 500}, {"GET", "/panic", 500}, {"GET", "/absent", 404}, {"PUT", "/secure", 405}} {
|
||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
var body map[string]any
|
||||
err := json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if err != nil || w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" || body["code"] != float64(999) || body["data"] != nil || body["msg"] == nil || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal(w.Code, w.Header(), w.Body.String(), err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/secure", "/absent"} {
|
||||
before := middlewareCalls
|
||||
r := httptest.NewRequest("OPTIONS", path, nil)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
if w.Code != 204 || w.Body.Len() != 0 || middlewareCalls != before {
|
||||
t.Fatal(w.Code, w.Body.String(), middlewareCalls, before)
|
||||
}
|
||||
}
|
||||
r := httptest.NewRequest("OPTIONS", "/options", nil)
|
||||
w := httptest.NewRecorder()
|
||||
entry.ServeHTTP(w, r)
|
||||
if w.Code != 200 || w.Body.String() != "ordinary options" {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppCORSConfigIsolation(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.CORS = omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://one.example"}, AllowedHeaders: []string{"Authorization"}}
|
||||
option := omni.WithConfig(cfg)
|
||||
cfg.CORS.AllowedOrigins[0] = "https://evil.example"
|
||||
a := omni.New(option)
|
||||
defer a.Close()
|
||||
b := omni.New(option)
|
||||
defer b.Close()
|
||||
a.Config().CORS.AllowedOrigins[0] = "https://evil.example"
|
||||
for _, app := range []*omni.App{a, b} {
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest("GET", "/absent", nil)
|
||||
r.Header.Set("Origin", "https://one.example")
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "https://one.example" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Error("invalid CORS passed direct New")
|
||||
}
|
||||
}()
|
||||
omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true}))
|
||||
}
|
||||
|
||||
func TestCORSTransports(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||
cfg := omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}
|
||||
a := omni.New(omni.WithCORS(cfg), omni.WithTransport(transport), omni.WithTLS(cert, key))
|
||||
var called atomic.Int32
|
||||
a.Use(func(next omni.Handler) omni.Handler {
|
||||
return func(c *omni.Context) error { called.Add(1); return next(c) }
|
||||
})
|
||||
a.POST("/upload", func(c *omni.Context) error {
|
||||
b, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Text(200, string(b))
|
||||
})
|
||||
a.GET("/download", func(c *omni.Context) error {
|
||||
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: io.NopCloser(strings.NewReader("file bytes")), Name: "example.txt"})
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- a.ServeContext(ctx, listener) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("server did not close")
|
||||
listener.Close()
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||
defer client.CloseIdleConnections()
|
||||
url := "https://" + listener.Addr().String()
|
||||
for _, tc := range []struct {
|
||||
method, path, body string
|
||||
status int
|
||||
}{{"OPTIONS", "/upload", "", 204}, {"POST", "/upload", "raw upload", 200}, {"GET", "/download", "file bytes", 200}, {"GET", "/missing", "", 404}} {
|
||||
var input io.Reader
|
||||
if tc.method == "POST" {
|
||||
input = strings.NewReader(tc.body)
|
||||
}
|
||||
r, _ := http.NewRequest(tc.method, url+tc.path, input)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
if tc.method == "OPTIONS" {
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||
}
|
||||
res, err := client.Do(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") != "https://web.example" {
|
||||
t.Fatal(res.StatusCode, res.Header)
|
||||
}
|
||||
if tc.status == 200 && string(body) != tc.body {
|
||||
t.Fatal(string(body))
|
||||
}
|
||||
if tc.status == 204 && (len(body) != 0 || called.Load() != 0) {
|
||||
t.Fatal("preflight reached middleware")
|
||||
}
|
||||
if tc.path == "/download" && (!strings.Contains(res.Header.Get("Content-Disposition"), "example.txt") || res.Header.Get("Access-Control-Expose-Headers") != "Content-Disposition") {
|
||||
t.Fatal(res.Header)
|
||||
}
|
||||
if transport == omni.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||
t.Fatal(res.Proto)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -49,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.23.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.25.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
当前模板与依赖以 v0.23.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
当前模板与依赖以 v0.25.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal file
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedCORSConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
api := filepath.Join(t.TempDir(), "service.api")
|
||||
writeFile(t, api, "service corsdemo {\n@handler ping\nget /ping\n}\n")
|
||||
o := Options{API: api, Dir: dir, Module: "example.com/corsdemo", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/cors/consumer_test.go"))
|
||||
writeFile(t, filepath.Join(dir, "etc/config.yaml"), "server:\n log:\n output: discard\n cors:\n enabled: true\n allowedOrigins: [https://web.example]\n allowedMethods: [GET, POST, PUT]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n")
|
||||
runConsumerGo(t, dir, "mod", "tidy")
|
||||
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||
runConsumerGo(t, dir, "build", ".")
|
||||
o.API = filepath.Join(dir, "api/service.api")
|
||||
before := snapshot(t, dir)
|
||||
for _, mode := range []string{"dry", "generate", "check"} {
|
||||
o.DryRun = mode == "dry"
|
||||
o.Check = mode == "check"
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("regeneration changed project files")
|
||||
}
|
||||
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||
cmd := exec.Command("go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(out))
|
||||
}
|
||||
var dependency struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err := json.Unmarshal(out, &dependency); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||
const minimumVersion = "v0.22.0-preview"
|
||||
const currentVersion = "v0.23.0-preview"
|
||||
const currentVersion = "v0.25.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
|
||||
@@ -62,7 +62,10 @@ Ordinary Logic uses the generated Request/Response types directly. Where an
|
||||
independent business type is needed, use ordinary Go or the optional copyx package.
|
||||
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation
|
||||
identifies fields such as server.log.level and database.port. server.log.timeZone
|
||||
optionally selects a per-logger IANA zone. See the framework CONFIG.md.
|
||||
optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed
|
||||
automatically before routing/authentication; disabled by default. Configure allowedOrigins,
|
||||
allowedMethods and allowedHeaders in the selected file; no main/handler edits.
|
||||
See the framework CONFIG.md and cors/README.md.
|
||||
|
||||
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
|
||||
See COPY.md in the framework repository for copy policies and migration steps,
|
||||
|
||||
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal file
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"example.com/corsdemo/internal/config"
|
||||
"example.com/corsdemo/internal/handler"
|
||||
"example.com/corsdemo/internal/svc"
|
||||
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedCORSConfiguration(t *testing.T) {
|
||||
cfg, err := config.Load("etc/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger, err := logx.New(cfg.Server.Log)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
a, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Close()
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
status int
|
||||
}{{"GET", "/ping", 501}, {"GET", "/missing", 404}, {"PUT", "/ping", 405}, {"GET", "/health/live", 200}, {"GET", "/health/ready", 200}, {"GET", "/openapi.json", 200}, {"OPTIONS", "/ping", 204}} {
|
||||
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||
r.Header.Set("Origin", "https://web.example")
|
||||
if tc.method == "OPTIONS" {
|
||||
r.Header.Set("Access-Control-Request-Method", "GET")
|
||||
r.Header.Set("Access-Control-Request-Headers", "authorization")
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
a.ServeHTTP(w, r)
|
||||
if w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" {
|
||||
t.Fatal(tc, w.Code, w.Header(), w.Body.String())
|
||||
}
|
||||
if tc.status == 204 && (w.Body.Len() != 0 || w.Header().Get("Access-Control-Max-Age") != "600") {
|
||||
t.Fatal(w)
|
||||
}
|
||||
}
|
||||
// Startup config validation uses the normal generated run/Load, before log
|
||||
// files or dependencies. No changes to generated main/HTTP composition.
|
||||
path := filepath.Join(t.TempDir(), "invalid.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins: ['*']\n allowCredentials: true\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = run(context.Background(), path)
|
||||
var d *omniConfig.Error
|
||||
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || !strings.Contains(d.Reason, "allowCredentials") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
# Only fields listed here override Default(). Environment names are explicit.
|
||||
server:
|
||||
# Optional cross-origin access: server.cors (disabled by default). See framework cors/README.md.
|
||||
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
|
||||
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
|
||||
{{if ne .Storage "none"}}storage:
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.23.0-preview"
|
||||
const defaultFrameworkVersion = "v0.25.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
@@ -28,6 +29,7 @@ type TLSConfig struct {
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
CORS cors.Config `json:"cors"`
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
@@ -61,6 +63,9 @@ func Default() Config {
|
||||
|
||||
// Validate checks configuration values before an application starts.
|
||||
func (c Config) Validate() error {
|
||||
if err := c.CORS.Validate(); err != nil {
|
||||
return AtField("cors", err)
|
||||
}
|
||||
if err := c.Lifecycle.Validate(); err != nil {
|
||||
return AtField("lifecycle", err)
|
||||
}
|
||||
@@ -188,12 +193,14 @@ func WithTrustedProxies(proxies ...string) Option {
|
||||
// WithConfig applies a complete configuration loaded from a file or
|
||||
// environment. Later options can still override individual fields.
|
||||
func WithConfig(settings Config) Option {
|
||||
settings.CORS = settings.CORS.Clone()
|
||||
settings.Response = settings.Response.Clone()
|
||||
settings.Errors = settings.Errors.Clone()
|
||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||
return func(cfg *Config) {
|
||||
*cfg = settings
|
||||
cfg.CORS = settings.CORS.Clone()
|
||||
cfg.Response = settings.Response.Clone()
|
||||
cfg.Errors = settings.Errors.Clone()
|
||||
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||
@@ -201,6 +208,12 @@ func WithConfig(settings Config) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithCORS installs an app-local CORS policy, before routing and authentication.
|
||||
func WithCORS(settings cors.Config) Option {
|
||||
settings = settings.Clone()
|
||||
return func(cfg *Config) { cfg.CORS = settings.Clone() }
|
||||
}
|
||||
|
||||
// WithResponse configures this application's default JSON envelope.
|
||||
func WithResponse(settings response.Config) Option {
|
||||
settings = settings.Clone()
|
||||
|
||||
51
config/cors_test.go
Normal file
51
config/cors_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
)
|
||||
|
||||
type corsProject struct {
|
||||
Server config.Config `json:"server"`
|
||||
}
|
||||
|
||||
func (c corsProject) Validate() error { return config.AtField("server", c.Server.Validate()) }
|
||||
|
||||
func TestCORSConfiguration(t *testing.T) {
|
||||
t.Setenv("WEB_ORIGIN", "https://web.example")
|
||||
for ext, source := range map[string]string{
|
||||
"yaml": "server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n allowedMethods: [GET, POST]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n",
|
||||
"json": `{"server":{"cors":{"enabled":true,"allowedOrigins":["${WEB_ORIGIN}"],"allowedMethods":["GET","POST"],"allowedHeaders":["Authorization","Content-Type"],"maxAge":"10m"}}}`,
|
||||
"xml": `<config><server><cors><enabled type="bool">true</enabled><allowedOrigins type="array"><item>${WEB_ORIGIN}</item></allowedOrigins><allowedMethods type="array"><item>GET</item><item>POST</item></allowedMethods><allowedHeaders type="array"><item>Authorization</item><item>Content-Type</item></allowedHeaders><maxAge>10m</maxAge></cors></server></config>`,
|
||||
} {
|
||||
t.Run(ext, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config."+ext)
|
||||
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadProject(path, corsProject{Server: config.Default()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Server.CORS.Enabled || cfg.Server.CORS.AllowedOrigins[0] != "https://web.example" || cfg.Server.CORS.MaxAge != 10*time.Minute {
|
||||
t.Fatal(cfg.Server.CORS)
|
||||
}
|
||||
})
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "bad.yaml")
|
||||
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("WEB_ORIGIN", "https://user:secret@web.example")
|
||||
_, err := config.LoadProject(path, corsProject{Server: config.Default()})
|
||||
var d *config.Error
|
||||
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || d.Env != "WEB_ORIGIN" || d.Line != 5 || strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
82
cors/README.md
Normal file
82
cors/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 可选 CORS
|
||||
|
||||
CORS 控制浏览器能否跨源读取 HTTP 响应。默认关闭;同源反向代理可以保持关闭。模块无 Gin 或具体业务依赖,标准库/native/HTTP2 与自定义 Runner 使用相同 App 接入策略。
|
||||
|
||||
## 生成项目只改配置
|
||||
|
||||
在既有 `etc/config.yaml` 的 `server` 中加入,无需修改 main、svc 或每条 `.api` 路由:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
cors:
|
||||
enabled: true
|
||||
allowedOrigins:
|
||||
- https://admin.example.com
|
||||
- https://merchant.example.com
|
||||
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
|
||||
allowedHeaders: [Authorization, Content-Type]
|
||||
exposedHeaders: [X-Request-ID, Content-Disposition]
|
||||
allowCredentials: false
|
||||
maxAge: 10m
|
||||
```
|
||||
|
||||
文件也可显式引用环境变量:`allowedOrigins: ["${WEB_ORIGIN}"]`。一个数组元素代表一个来源,不把逗号分隔环境文本隐式拆成多个来源。JSON/YAML/XML 走同一加载和校验;非法配置定位 `server.cors.allowedOrigins[0]` 等字段、文件及可用环境名/行列,不回显值。
|
||||
|
||||
```sh
|
||||
go run . -config etc/config.yaml -check-config
|
||||
go run ./examples/cors # 在框架源码根目录运行独立示例
|
||||
```
|
||||
|
||||
## 手写项目与独立 HTTP 服务
|
||||
|
||||
```go
|
||||
app := omni.New(omni.WithCORS(omni.CORSConfig{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://web.example.com"},
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
AllowedHeaders: []string{"Authorization", "Content-Type"},
|
||||
}))
|
||||
```
|
||||
|
||||
已有 net/http 服务也可单独使用,不需要创建 App:
|
||||
|
||||
```go
|
||||
policy, err := cors.New(cors.Config{
|
||||
Enabled: true,
|
||||
AllowedOrigins: []string{"https://web.example.com"},
|
||||
})
|
||||
if err != nil { return err }
|
||||
handler := policy.Wrap(existingHandler)
|
||||
```
|
||||
|
||||
Config/New/WithCORS 在初始化时校验并快照配置;同一 Policy 可并发复用。`Config.Validate()` 返回 error,直接向 `omni.New` 传非法选项沿用框架初始化 panic 约定;生成项目在创建 App 前已校验。
|
||||
|
||||
## 配置规则
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| enabled | 默认 false;关闭时直接使用原 Handler,不添加头、不拦截 OPTIONS |
|
||||
| allowedOrigins | 启用时必填。完整 HTTP(S) origin(协议+主机+可选端口),无路径、尾斜杠、userinfo、查询或片段。配置中的协议/主机转小写、默认 80/443 端口省略;请求匹配浏览器序列化后的来源 |
|
||||
| allowedMethods | 空列表默认 GET、HEAD、POST;填写后使用指定集合。大写方法名,拒绝浏览器禁止的 CONNECT/TRACE/TRACK,不提供方法通配符 |
|
||||
| allowedHeaders | 空列表不批准额外预检请求头。Authorization、JSON Content-Type 等按需要列出;名称忽略大小写,不提供通配符 |
|
||||
| exposedHeaders | 默认空;额外允许前端 JS 读取的响应头,例如下载文件名用 Content-Disposition。浏览器本来就可读取的安全列表不需重复填写 |
|
||||
| allowCredentials | 默认 false;跨源 Cookie 等凭据模式需要显式 true 和具体来源。单纯手动设置 Authorization 头只需列入 allowedHeaders,不必因此开启 Cookie 凭据模式 |
|
||||
| maxAge | 浏览器缓存预检结果的时长,必须非负且为整秒;0 不发送该头。浏览器可按自身上限截短 |
|
||||
|
||||
公开无凭据 API 可使用唯一来源 `"*"`;与 `allowCredentials: true` 或其他来源并存会报错。此版本只支持 HTTP(S) 来源,拒绝 `null`、子域通配符、正则、file:// 和不透明来源。国际化域名填写浏览器发送的 ASCII/Punycode 形式;IP 使用完整标准形式。不会为匹配来源查询 DNS。
|
||||
|
||||
## 预检、错误响应与安全边界
|
||||
|
||||
- CORS 位于路由/业务中间件外层。只有 OPTIONS 且同时带 Origin 和 Access-Control-Request-Method 才视为预检;普通 OPTIONS 继续执行项目逻辑。
|
||||
- 合法预检返回 **204 空响应**,不进入业务中间件、鉴权、Logic 或业务响应包装。拒绝预检返回 **403 空响应**,不暴露内部原因;两者是协议响应,不受业务“失败也返回 HTTP 200”的设置影响。
|
||||
- 预检验证全局来源、方法、请求头策略,不探测路由存在性;通过不代表实际接口一定存在。实际请求仍由路由处理,可返回 404/405。
|
||||
- 实际请求继续正常鉴权;来源/方法获准时,正常响应、401/403、404/405、框架恢复的 500 都带 CORS 头,响应体、状态和错误目录不变。协议解析、TLS、网络层过载等在进入 App 前发生的拒绝不由本模块改写。
|
||||
- 未获准或没有 Origin 的实际请求仍正常进入业务,只是不添加 CORS 许可头。CORS 不等于身份认证、服务端访问控制或 CSRF 防护;服务端回调仍需项目自己的验签/鉴权。
|
||||
- 始终追加并去重 `Vary: Origin`;预检再追加请求方法/请求头的 Vary,保留原有 Vary 与 `*`。不要在下游代码覆盖这些头。代理与应用选择一处负责 CORS,避免重复头或冲突策略。
|
||||
- 不包装 ResponseWriter、不读取/缓存 body、不克隆 Request。下载、上传、Flush、Hijack 和请求上下文保留;不增加响应体大小限制或改写统一返回格式。
|
||||
- 使用 App、Engine、NewServer 或 Run/ServeContext/RunWith 接入。Router() 暴露底层路由供高级注册,单独拿 Router().ServeHTTP 服务会绕过应用的外层策略。
|
||||
- App 内业务日志/指标不统计被提前结束的预检;如需记录全部流量,在 App 外层或代理配置访问日志。不要让全局登录校验挡在 CORS 预检之前。
|
||||
|
||||
配置最多 1024 个来源、每个最多 4096 字节;方法/请求头/暴露头各最多 128 个且名称最多 256 字节;预检请求头列表最多 128 项、8192 字节。拒绝 CRLF、歧义的重复 Origin/请求方法及非法 token。WebSocket Origin 校验、Private Network Access 扩展和云桶 CORS 不包含在本模块中,按对应服务配置。
|
||||
|
||||
规则依据:[Fetch CORS 协议](https://fetch.spec.whatwg.org/#http-cors-protocol)、[凭据规则](https://fetch.spec.whatwg.org/#cors-protocol-and-credentials)、[缓存规则](https://fetch.spec.whatwg.org/#cors-protocol-and-http-caches)。可运行示例:[examples/cors](../examples/cors/README.md)。
|
||||
144
cors/config.go
Normal file
144
cors/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package cors implements optional, transport-independent browser CORS policy.
|
||||
// It does not authenticate requests or provide CSRF protection.
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||
)
|
||||
|
||||
// Config is disabled by default. Origins are exact HTTP(S) origins, or a single
|
||||
// "*" for public responses without credentials. Patterns and "null" are rejected.
|
||||
// Empty methods default to GET, HEAD, POST; empty header lists grant no extra
|
||||
// permissions. MaxAge is a nonnegative whole number of seconds; zero omits it.
|
||||
type Config struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AllowedOrigins []string `json:"allowedOrigins"`
|
||||
AllowedMethods []string `json:"allowedMethods"`
|
||||
AllowedHeaders []string `json:"allowedHeaders"`
|
||||
ExposedHeaders []string `json:"exposedHeaders"`
|
||||
AllowCredentials bool `json:"allowCredentials"`
|
||||
MaxAge time.Duration `json:"maxAge"`
|
||||
}
|
||||
|
||||
func (c Config) Clone() Config {
|
||||
c.AllowedOrigins = append([]string(nil), c.AllowedOrigins...)
|
||||
c.AllowedMethods = append([]string(nil), c.AllowedMethods...)
|
||||
c.AllowedHeaders = append([]string(nil), c.AllowedHeaders...)
|
||||
c.ExposedHeaders = append([]string(nil), c.ExposedHeaders...)
|
||||
return c
|
||||
}
|
||||
|
||||
// Validate never echoes configured values in diagnostics. Invalid settings are
|
||||
// rejected even when disabled; an empty origin list is legal only when disabled.
|
||||
func (c Config) Validate() error {
|
||||
if c.Enabled && len(c.AllowedOrigins) == 0 {
|
||||
return configdiag.New("allowedOrigins", "is required when CORS is enabled")
|
||||
}
|
||||
if c.MaxAge < 0 || c.MaxAge%time.Second != 0 {
|
||||
return configdiag.New("maxAge", "must be a nonnegative whole number of seconds")
|
||||
}
|
||||
if len(c.AllowedOrigins) > 1024 {
|
||||
return configdiag.New("allowedOrigins", "must contain at most 1024 entries")
|
||||
}
|
||||
for i, origin := range c.AllowedOrigins {
|
||||
field := "allowedOrigins[" + strconv.Itoa(i) + "]"
|
||||
if origin == "*" {
|
||||
if c.AllowCredentials || len(c.AllowedOrigins) != 1 {
|
||||
return configdiag.New(field, "wildcard requires a single origin entry and allowCredentials=false")
|
||||
}
|
||||
} else if _, ok := normalizeOrigin(origin); !ok {
|
||||
return configdiag.New(field, "must be an HTTP(S) origin without credentials, path, query, fragment or wildcard")
|
||||
}
|
||||
}
|
||||
for _, list := range []struct {
|
||||
name string
|
||||
values []string
|
||||
}{{"allowedMethods", c.AllowedMethods}, {"allowedHeaders", c.AllowedHeaders}, {"exposedHeaders", c.ExposedHeaders}} {
|
||||
if len(list.values) > 128 {
|
||||
return configdiag.New(list.name, "must contain at most 128 entries")
|
||||
}
|
||||
for i, value := range list.values {
|
||||
field := list.name + "[" + strconv.Itoa(i) + "]"
|
||||
if !token(value) || value == "*" {
|
||||
return configdiag.New(field, "must be an explicit HTTP token, not a wildcard")
|
||||
}
|
||||
if list.name == "allowedMethods" && (value != strings.ToUpper(value) || value == "CONNECT" || value == "TRACE" || value == "TRACK") {
|
||||
return configdiag.New(field, "must be an uppercase browser-allowed HTTP method")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize configured scheme/host case and default ports once. Request values
|
||||
// match serialized browser origins; arbitrary URL prefixes/suffixes never match.
|
||||
func normalizeOrigin(origin string) (string, bool) {
|
||||
if len(origin) == 0 || len(origin) > 4096 {
|
||||
return "", false
|
||||
}
|
||||
for _, b := range []byte(origin) {
|
||||
if b <= 32 || b >= 127 || strings.ContainsRune("\\%?#*", rune(b)) {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.User != nil || u.Opaque != "" || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
scheme, host := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname())
|
||||
if (scheme != "http" && scheme != "https") || host == "" || strings.HasSuffix(u.Host, ":") {
|
||||
return "", false
|
||||
}
|
||||
if strings.Contains(host, ":") {
|
||||
ip, err := netip.ParseAddr(host)
|
||||
if err != nil || !ip.Is6() || ip.Zone() != "" {
|
||||
return "", false
|
||||
}
|
||||
host = "[" + ip.String() + "]"
|
||||
if ip.Is4In6() {
|
||||
// Browsers serialize mapped IPv4 as hex IPv6 pieces; netip.String
|
||||
// uses a dotted-decimal tail instead.
|
||||
b := ip.As16()
|
||||
host = "[::ffff:" + strconv.FormatUint(uint64(b[12])<<8|uint64(b[13]), 16) + ":" + strconv.FormatUint(uint64(b[14])<<8|uint64(b[15]), 16) + "]"
|
||||
}
|
||||
} else {
|
||||
if strings.ContainsAny(u.Host, "[]") {
|
||||
return "", false
|
||||
}
|
||||
for _, b := range []byte(host) {
|
||||
if !(b >= 'a' && b <= 'z' || b >= '0' && b <= '9' || b == '.' || b == '-') {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
}
|
||||
port := u.Port()
|
||||
if port != "" {
|
||||
n, err := strconv.Atoi(port)
|
||||
if err != nil || n < 1 || n > 65535 {
|
||||
return "", false
|
||||
}
|
||||
if !(scheme == "https" && n == 443 || scheme == "http" && n == 80) {
|
||||
host = net.JoinHostPort(strings.Trim(host, "[]"), strconv.Itoa(n))
|
||||
}
|
||||
}
|
||||
return scheme + "://" + host, true
|
||||
}
|
||||
|
||||
func token(s string) bool {
|
||||
if s == "" || len(s) > 256 {
|
||||
return false
|
||||
}
|
||||
for _, b := range []byte(s) {
|
||||
if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
187
cors/cors.go
Normal file
187
cors/cors.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Policy is immutable after New and safe to share between concurrent requests.
|
||||
type Policy struct {
|
||||
enabled, wildcard, credentials bool
|
||||
origins, methods, headers map[string]struct{}
|
||||
allowMethods, allowHeaders, exposeHeaders, maxAge string
|
||||
}
|
||||
|
||||
// New validates and snapshots policy. It performs no network or DNS operations.
|
||||
func New(c Config) (*Policy, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := &Policy{enabled: c.Enabled, credentials: c.AllowCredentials}
|
||||
if !c.Enabled {
|
||||
return p, nil
|
||||
}
|
||||
p.origins = make(map[string]struct{}, len(c.AllowedOrigins))
|
||||
for _, origin := range c.AllowedOrigins {
|
||||
if origin == "*" {
|
||||
p.wildcard = true
|
||||
continue
|
||||
}
|
||||
normalized, _ := normalizeOrigin(origin)
|
||||
p.origins[normalized] = struct{}{}
|
||||
}
|
||||
methods := c.AllowedMethods
|
||||
if len(methods) == 0 {
|
||||
methods = []string{http.MethodGet, http.MethodHead, http.MethodPost}
|
||||
}
|
||||
p.methods, p.allowMethods = compileList(methods, false)
|
||||
p.headers, p.allowHeaders = compileList(c.AllowedHeaders, true)
|
||||
_, p.exposeHeaders = compileList(c.ExposedHeaders, true)
|
||||
if c.MaxAge > 0 {
|
||||
p.maxAge = strconv.FormatInt(int64(c.MaxAge/time.Second), 10)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func compileList(values []string, header bool) (map[string]struct{}, string) {
|
||||
set := make(map[string]struct{}, len(values))
|
||||
list := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
key := value
|
||||
if header {
|
||||
key = strings.ToLower(value)
|
||||
value = http.CanonicalHeaderKey(value)
|
||||
}
|
||||
if _, exists := set[key]; exists {
|
||||
continue
|
||||
}
|
||||
set[key] = struct{}{}
|
||||
list = append(list, value)
|
||||
}
|
||||
return set, strings.Join(list, ", ")
|
||||
}
|
||||
|
||||
// Wrap installs CORS outside routing/authentication. Only actual CORS preflights
|
||||
// are short-circuited. Actual requests always continue through normal handlers;
|
||||
// an unlisted Origin receives no permission to read the response in a browser.
|
||||
// The original ResponseWriter, Request and body pass through without buffering.
|
||||
func (p *Policy) Wrap(next http.Handler) http.Handler {
|
||||
if next == nil {
|
||||
panic("cors: nil handler")
|
||||
}
|
||||
if p == nil {
|
||||
panic("cors: nil policy")
|
||||
}
|
||||
if !p.enabled {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
vary(h, "Origin")
|
||||
origins := r.Header.Values("Origin")
|
||||
// An OPTIONS request without Origin or Access-Control-Request-Method is
|
||||
// ordinary application traffic, including user-defined OPTIONS handlers.
|
||||
requestedMethods := r.Header.Values("Access-Control-Request-Method")
|
||||
preflight := r.Method == http.MethodOptions && len(origins) > 0 && len(requestedMethods) > 0
|
||||
if preflight {
|
||||
vary(h, "Access-Control-Request-Method")
|
||||
vary(h, "Access-Control-Request-Headers")
|
||||
}
|
||||
if len(origins) != 1 || !p.allowsOrigin(origins[0]) {
|
||||
if preflight {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if preflight {
|
||||
if len(requestedMethods) != 1 || !p.allowsMethod(requestedMethods[0]) || !p.allowsHeaders(r.Header.Values("Access-Control-Request-Headers")) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
p.allowOrigin(h, origins[0])
|
||||
h.Set("Access-Control-Allow-Methods", p.allowMethods)
|
||||
if p.allowHeaders != "" {
|
||||
h.Set("Access-Control-Allow-Headers", p.allowHeaders)
|
||||
}
|
||||
if p.maxAge != "" {
|
||||
h.Set("Access-Control-Max-Age", p.maxAge)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
// Match the actual method too: this grants response access, not permission
|
||||
// to execute a side effect. Authentication/CSRF checks remain in the app.
|
||||
if p.allowsMethod(r.Method) {
|
||||
p.allowOrigin(h, origins[0])
|
||||
if p.exposeHeaders != "" {
|
||||
h.Set("Access-Control-Expose-Headers", p.exposeHeaders)
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (p *Policy) allowsOrigin(origin string) bool {
|
||||
if !p.wildcard {
|
||||
_, ok := p.origins[origin]
|
||||
return ok
|
||||
}
|
||||
normalized, ok := normalizeOrigin(origin)
|
||||
return ok && normalized == origin
|
||||
}
|
||||
func (p *Policy) allowsMethod(method string) bool { _, ok := p.methods[method]; return ok }
|
||||
func (p *Policy) allowOrigin(h http.Header, origin string) {
|
||||
if p.wildcard {
|
||||
origin = "*"
|
||||
}
|
||||
h.Set("Access-Control-Allow-Origin", origin)
|
||||
if p.credentials {
|
||||
h.Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
}
|
||||
func (p *Policy) allowsHeaders(lines []string) bool {
|
||||
count, size := 0, 0
|
||||
for _, line := range lines {
|
||||
size += len(line)
|
||||
if size > 8192 {
|
||||
return false
|
||||
}
|
||||
// Parse a header list without allocating an attacker-sized split slice.
|
||||
for {
|
||||
name, rest, more := strings.Cut(line, ",")
|
||||
name = strings.Trim(name, " \t")
|
||||
count++
|
||||
if count > 128 || !token(name) {
|
||||
return false
|
||||
}
|
||||
if _, ok := p.headers[strings.ToLower(name)]; !ok {
|
||||
return false
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
line = rest
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
func vary(h http.Header, name string) {
|
||||
for _, line := range h.Values("Vary") {
|
||||
for {
|
||||
v, rest, more := strings.Cut(line, ",")
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "*" || strings.EqualFold(v, name) {
|
||||
return
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
line = rest
|
||||
}
|
||||
}
|
||||
h.Add("Vary", name)
|
||||
}
|
||||
303
cors/cors_test.go
Normal file
303
cors/cors_test.go
Normal file
@@ -0,0 +1,303 @@
|
||||
package cors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func exampleConfig() Config {
|
||||
return Config{Enabled: true, AllowedOrigins: []string{"https://admin.example", "https://merchant.example"}, AllowedMethods: []string{"GET", "POST", "DELETE"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"X-Request-ID", "Content-Disposition"}, MaxAge: 10 * time.Minute}
|
||||
}
|
||||
|
||||
func TestPolicyPreflightAndActual(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, method, origin, requestedMethod, requestedHeaders string
|
||||
status int
|
||||
called, allowed bool
|
||||
}{
|
||||
{"preflight", "OPTIONS", "https://admin.example", "POST", "authorization, CONTENT-TYPE", 204, false, true},
|
||||
{"no request headers", "OPTIONS", "https://admin.example", "DELETE", "", 204, false, true},
|
||||
{"unknown origin", "OPTIONS", "https://other.example", "POST", "authorization", 403, false, false},
|
||||
{"suffix attack", "OPTIONS", "https://admin.example.evil", "POST", "", 403, false, false},
|
||||
{"scheme mismatch", "OPTIONS", "http://admin.example", "POST", "", 403, false, false},
|
||||
{"port mismatch", "OPTIONS", "https://admin.example:444", "POST", "", 403, false, false},
|
||||
{"opaque origin", "OPTIONS", "null", "POST", "", 403, false, false},
|
||||
{"unknown method", "OPTIONS", "https://admin.example", "PATCH", "", 403, false, false},
|
||||
{"case sensitive method", "OPTIONS", "https://admin.example", "post", "", 403, false, false},
|
||||
{"unknown header", "OPTIONS", "https://admin.example", "POST", "X-Internal", 403, false, false},
|
||||
{"trailing empty header", "OPTIONS", "https://admin.example", "POST", "Authorization,", 403, false, false},
|
||||
{"control header", "OPTIONS", "https://admin.example", "POST", "Authorization\r\nX-Evil", 403, false, false},
|
||||
{"duplicate allowed header", "OPTIONS", "https://admin.example", "POST", "Authorization, authorization", 204, false, true},
|
||||
{"oversized list", "OPTIONS", "https://admin.example", "POST", strings.Repeat("Authorization,", 129) + "Authorization", 403, false, false},
|
||||
{"actual error response", "GET", "https://merchant.example", "", "", 401, true, true},
|
||||
{"actual denied origin still authenticates", "POST", "https://other.example", "", "", 401, true, false},
|
||||
{"actual denied method still authenticates", "PUT", "https://admin.example", "", "", 401, true, false},
|
||||
{"server callback", "POST", "", "", "", 401, true, false},
|
||||
{"ordinary OPTIONS", "OPTIONS", "https://admin.example", "", "", 401, true, false},
|
||||
{"OPTIONS without Origin", "OPTIONS", "", "POST", "", 401, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
policy, err := New(exampleConfig())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := false
|
||||
h := policy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(401)
|
||||
io.WriteString(w, "authentication required")
|
||||
}))
|
||||
r := httptest.NewRequest(tc.method, "http://api.example/resource", nil)
|
||||
if tc.origin != "" {
|
||||
r.Header.Set("Origin", tc.origin)
|
||||
}
|
||||
if tc.requestedMethod != "" {
|
||||
r.Header.Set("Access-Control-Request-Method", tc.requestedMethod)
|
||||
}
|
||||
if tc.requestedHeaders != "" {
|
||||
r.Header.Set("Access-Control-Request-Headers", tc.requestedHeaders)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != tc.status || called != tc.called {
|
||||
t.Fatalf("status=%d called=%v", w.Code, called)
|
||||
}
|
||||
if got := w.Header().Get("Access-Control-Allow-Origin"); (got != "") != tc.allowed || tc.allowed && got != tc.origin {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Origin") || !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Accept-Encoding") {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
if !tc.called {
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatal("preflight wrapped in business response")
|
||||
}
|
||||
if !strings.Contains(strings.Join(w.Header().Values("Vary"), ","), "Access-Control-Request-Headers") {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
if tc.allowed && (w.Header().Get("Access-Control-Max-Age") != "600" || !strings.Contains(w.Header().Get("Access-Control-Allow-Headers"), "Authorization")) {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
} else if w.Body.String() != "authentication required" {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
if tc.called && tc.allowed && w.Header().Get("Access-Control-Expose-Headers") != "X-Request-Id, Content-Disposition" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyValidation(t *testing.T) {
|
||||
for _, origin := range []string{"https://admin.example/", "https://user:password@admin.example", "null", "https://*.example", "https://admin.example?secret", "https://admin.example#secret", "file://host", "https://host:", "https://host:0", "https://host:65536", "https://host%2eevil", "https://host\r\nX: value", "https://例子.example", "https://[localhost]", "https://[::1%25zone]"} {
|
||||
c := exampleConfig()
|
||||
c.AllowedOrigins = []string{origin}
|
||||
err := c.Validate()
|
||||
if err == nil || strings.Contains(err.Error(), origin) {
|
||||
t.Fatalf("invalid origin accepted or exposed: %q %v", origin, err)
|
||||
}
|
||||
}
|
||||
for _, change := range []func(*Config){
|
||||
func(c *Config) { c.AllowedOrigins = nil },
|
||||
func(c *Config) { c.AllowedOrigins = []string{"*"}; c.AllowCredentials = true },
|
||||
func(c *Config) { c.AllowedOrigins = []string{"*", "https://admin.example"} },
|
||||
func(c *Config) { c.AllowedHeaders = []string{"*"} },
|
||||
func(c *Config) { c.ExposedHeaders = []string{"header\r\n"} },
|
||||
func(c *Config) { c.AllowedMethods = []string{"get"} },
|
||||
func(c *Config) { c.AllowedMethods = []string{"TRACE"} },
|
||||
func(c *Config) { c.MaxAge = -time.Second },
|
||||
func(c *Config) { c.MaxAge = time.Millisecond },
|
||||
} {
|
||||
c := exampleConfig()
|
||||
change(&c)
|
||||
if _, err := New(c); err == nil {
|
||||
t.Fatal(c)
|
||||
}
|
||||
}
|
||||
if err := (Config{}).Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for input, want := range map[string]string{"https://[::ffff:192.0.2.1]": "https://[::ffff:c000:201]", "https://[::ffff:c000:201]": "https://[::ffff:c000:201]", "https://ADMIN.EXAMPLE:443": "https://admin.example", "http://localhost:80": "http://localhost", "http://localhost:8080": "http://localhost:8080", "https://[::1]:443": "https://[::1]", "http://[::1]:8080": "http://[::1]:8080"} {
|
||||
got, ok := normalizeOrigin(input)
|
||||
if !ok || got != want {
|
||||
t.Fatalf("%q => %q %v", input, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyCredentialsWildcardAndDuplicates(t *testing.T) {
|
||||
for _, credentials := range []bool{false, true} {
|
||||
c := exampleConfig()
|
||||
c.AllowCredentials = credentials
|
||||
if !credentials {
|
||||
c.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
p, _ := New(c)
|
||||
for _, origin := range []string{"https://admin.example", "null", "https://admin.example/path", "https://bad\r\n"} {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Origin", origin)
|
||||
w := httptest.NewRecorder()
|
||||
p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })).ServeHTTP(w, r)
|
||||
if origin != "https://admin.example" {
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
continue
|
||||
}
|
||||
want := "*"
|
||||
if credentials {
|
||||
want = origin
|
||||
}
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != want || (w.Header().Get("Access-Control-Allow-Credentials") == "true") != credentials {
|
||||
t.Fatal(w.Header())
|
||||
}
|
||||
}
|
||||
}
|
||||
p, _ := New(exampleConfig())
|
||||
for _, header := range []string{"Origin", "Access-Control-Request-Method"} {
|
||||
r := httptest.NewRequest("OPTIONS", "/", nil)
|
||||
r.Header.Set("Origin", "https://admin.example")
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Add(header, r.Header.Get(header))
|
||||
w := httptest.NewRecorder()
|
||||
p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Fatal("ambiguous preflight reached handler") })).ServeHTTP(w, r)
|
||||
if w.Code != 403 || w.Header().Get("Access-Control-Allow-Origin") != "" {
|
||||
t.Fatal(w.Code, w.Header())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyPreservesHTTPAndSnapshot(t *testing.T) {
|
||||
c := exampleConfig()
|
||||
p, _ := New(c)
|
||||
c.AllowedOrigins[0] = "https://evil.example"
|
||||
c.AllowedHeaders[0] = "X-Evil"
|
||||
r := httptest.NewRequest("GET", "/", strings.NewReader("request"))
|
||||
r.Header.Set("Origin", "https://admin.example")
|
||||
type key struct{}
|
||||
r = r.WithContext(context.WithValue(r.Context(), key{}, "kept"))
|
||||
w := httptest.NewRecorder()
|
||||
w.Header().Set("Vary", "Accept-Encoding, origin")
|
||||
next := http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
if writer != w || request != r || request.Context().Value(key{}) != "kept" {
|
||||
t.Fatal("HTTP capabilities replaced")
|
||||
}
|
||||
io.WriteString(writer, "chunk")
|
||||
writer.(http.Flusher).Flush()
|
||||
})
|
||||
p.Wrap(next).ServeHTTP(w, r)
|
||||
if !w.Flushed || w.Body.String() != "chunk" || w.Header().Get("Access-Control-Allow-Origin") != "https://admin.example" || len(w.Header().Values("Vary")) != 1 {
|
||||
t.Fatal(w)
|
||||
}
|
||||
for _, prior := range []string{"*", "Accept-Encoding, Origin"} {
|
||||
h := http.Header{"Vary": {prior}}
|
||||
vary(h, "Origin")
|
||||
if !reflect.DeepEqual(h.Values("Vary"), []string{prior}) {
|
||||
t.Fatal(h)
|
||||
}
|
||||
}
|
||||
disabled, _ := New(Config{})
|
||||
if reflect.ValueOf(disabled.Wrap(next)).Pointer() != reflect.ValueOf(next).Pointer() {
|
||||
t.Fatal("disabled policy not passthrough")
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
origin := "https://admin.example"
|
||||
if i%2 == 1 {
|
||||
origin = "https://merchant.example"
|
||||
}
|
||||
r.Header.Set("Origin", origin)
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Header().Get("Access-Control-Allow-Origin") != origin {
|
||||
t.Error("cross-request origin leak")
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func FuzzCORSHeaders(f *testing.F) {
|
||||
f.Add("https://admin.example", "POST", "authorization, content-type")
|
||||
f.Add("https://admin.example.evil", "POST", "")
|
||||
f.Add("null", "GET", "\r\n")
|
||||
p, _ := New(exampleConfig())
|
||||
h := p.Wrap(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("preflight reached business") }))
|
||||
f.Fuzz(func(t *testing.T, origin, method, headers string) {
|
||||
r := httptest.NewRequest("OPTIONS", "/", nil)
|
||||
r.Header.Set("Origin", origin)
|
||||
r.Header.Set("Access-Control-Request-Method", method)
|
||||
if headers != "" {
|
||||
r.Header.Set("Access-Control-Request-Headers", headers)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
if w.Code != 204 && w.Code != 403 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if w.Body.Len() != 0 {
|
||||
t.Fatal("unexpected body")
|
||||
}
|
||||
if value := w.Header().Get("Access-Control-Allow-Origin"); value != "" {
|
||||
if value != "https://admin.example" && value != "https://merchant.example" {
|
||||
t.Fatal("untrusted reflection")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type benchmarkWriter struct{ header http.Header }
|
||||
|
||||
func (w benchmarkWriter) Header() http.Header { return w.header }
|
||||
func (w benchmarkWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func (w benchmarkWriter) WriteHeader(int) {}
|
||||
func BenchmarkCORS(b *testing.B) {
|
||||
for _, mode := range []string{"disabled", "noOrigin", "allowed", "denied", "preflight", "wildcard"} {
|
||||
b.Run(mode, func(b *testing.B) {
|
||||
c := exampleConfig()
|
||||
if mode == "disabled" {
|
||||
c.Enabled = false
|
||||
}
|
||||
if mode == "wildcard" {
|
||||
c.AllowedOrigins = []string{"*"}
|
||||
}
|
||||
p, _ := New(c)
|
||||
h := p.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }))
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.Header.Set("Origin", "https://admin.example")
|
||||
if mode == "noOrigin" {
|
||||
r.Header.Del("Origin")
|
||||
}
|
||||
if mode == "denied" {
|
||||
r.Header.Set("Origin", "https://other.example")
|
||||
}
|
||||
if mode == "preflight" {
|
||||
r.Method = "OPTIONS"
|
||||
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||
r.Header.Set("Access-Control-Request-Headers", "authorization, content-type")
|
||||
}
|
||||
w := benchmarkWriter{make(http.Header)}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
clear(w.header)
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
- `Config`:驱动、DSN 与连接池参数;嵌入项目配置,通过文件显式引用变量。
|
||||
- `Open` / `OpenConnector`:通过 DSN 或标准 Connector 创建连接池、设置连接上限并在返回前执行 `PingContext`。
|
||||
- `Health`:用于就绪检查,等待连接和 Ping 均受 `PingTimeout` 限制。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时回滚。
|
||||
- `InTx`:统一事务提交和回滚;工作函数返回错误或 panic 时尝试回滚。返回 `TxError` 区分阶段与结果,提交错误不代表已回滚。
|
||||
- `Close`:由嵌入的 `*sql.DB` 提供,应用退出时显式调用。
|
||||
|
||||
框架不导入 MySQL、PostgreSQL 等具体驱动。应用负责注册驱动:
|
||||
@@ -63,6 +63,55 @@ python3 scripts/verifyDatabase.py --version v0.2.0
|
||||
|
||||
配置结构作为项目 Config 的字段,Default() 初始化连接池,文件按需覆盖。DSN 可使用 `dsn: "${DB_DSN}"`;database.Config 的 JSON 序列化不输出 DSN 原值。不要打印原始配置对象。当前 API 脚手架使用结构化 MySQL 适配器,由 svc 统一初始化与关闭。
|
||||
|
||||
## 事务阶段与结果
|
||||
|
||||
v0.25 起,`db.InTx(ctx, options, work)` 签名不变,所有普通错误返回都是 `*database.TxError`。返回 nil 表示 Commit 返回成功;框架不重试回调、提交或整个事务。业务通过标准库 errors.As/Is 判断,不解析错误文本:
|
||||
|
||||
```go
|
||||
err := db.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO objects (id, storage_key) VALUES (?, ?)", id, key)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
var txErr *database.TxError
|
||||
if errors.As(err, &txErr) {
|
||||
switch txErr.Outcome {
|
||||
case database.TxOutcomeNotStarted, database.TxOutcomeRolledBack:
|
||||
// 当前受管理事务未执行回调或已确认回滚。
|
||||
// 业务结合文件归属、并发操作和其他副作用,决定是否清理。
|
||||
default:
|
||||
// 包括 TxOutcomeUnknown:保留文件,交给业务后续核查。
|
||||
// 不因为收到提交错误就删除文件或直接重试整个操作。
|
||||
}
|
||||
// txErr.Stage 是 begin / work / commit。
|
||||
// txErr.Err 是主要原因;txErr.RollbackErr 是回滚调用返回的原因。
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
// 说明错误链包含超时,不意味着数据库没有提交。
|
||||
}
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
`errors` 为标准库包,业务自己的数据库驱动和 SQL 参数规则不变。完整可运行示例见 [examples/transactions](../examples/transactions/README.md)。
|
||||
|
||||
| 情况 | Stage | Outcome | RollbackErr |
|
||||
| --- | --- | --- | --- |
|
||||
| 未初始化数据库、nil 回调、BeginTx 报错 | TxStageBegin | TxOutcomeNotStarted | nil;框架没有拿到事务,不执行回调 |
|
||||
| 回调报错,显式 Rollback 成功 | TxStageWork | TxOutcomeRolledBack | nil |
|
||||
| 回调报错,Rollback 失败或返回 ErrTxDone | TxStageWork | TxOutcomeUnknown | 原始回滚错误 |
|
||||
| Commit 报错,包括取消/断网/驱动错误 | TxStageCommit | TxOutcomeUnknown | 后续清理的 Rollback 返回值,可能为 ErrTxDone 或 nil |
|
||||
|
||||
`Stage` 描述主要失败发生的位置;`Outcome` 只描述框架掌握的结果,二者独立。提交调用返回错误后一律保守标为 unknown,即使某个驱动的某种错误意味着没有提交,通用层也不根据错误文本猜测。标准库在 Commit 后通常已经结束本地事务对象,后续 Rollback 返回 `sql.ErrTxDone`;它不能证明数据库已回滚,框架保留该原因。
|
||||
|
||||
`TxError.Err` 与 `RollbackErr` 分开保存;`Unwrap() []error` 按主要原因、回滚原因顺序暴露,所以 errors.Is/As 可查两条链。需要区分来源时对 `txErr.Err` 或 `txErr.RollbackErr` 单独判断。回调返回另一个事务的 TxError 时外层不把它压平:外层结果只属于外层事务,内部事务的结果仍在 Err 中。
|
||||
|
||||
**结果保证的范围:** 回调使用给定 `*sql.Tx` 完成事务操作,不自行 Commit/Rollback,不执行会隐式提交的 SQL,不将事务交给后台 goroutine。非事务表、其他连接、嵌套的独立事务、文件和第三方调用的副作用不在这份保证内。框架没有删除文件、入队补偿或分布式事务规则。
|
||||
|
||||
**取消与异常边界:** context 取消时 database/sql 可能自行异步回滚,且不公开那次驱动 Rollback 的原始错误。框架若只观察到 ErrTxDone,就返回 unknown,不能补造成功证据或不存在的底层原因。正常返回路径上,框架实际观察到的主要/回滚原因全部保留。panic/runtime.Goexit 保持原有传播行为并尝试回滚,不转换为普通 error;这类展开没有错误返回值,因此不能把回滚原因作为返回值交给调用方。nil context 使用 Background,nil 回调在开启事务前报错。
|
||||
|
||||
**兼容说明:** 已有 errors.Is/As 继续生效;以前直接用 `err == sentinel`、`err.(*SomeError)` 或匹配文本的调用要改成 errors.Is/As。业务 APIError、Go 错误目录和参数提示仍可穿过包装解析,驱动错误不会自动进入公开提示。TxError.Error 包含内部原因,仅供内部诊断。GORM 自己的 Transaction 不经过本方法,也不会自动变成 TxError;可选 gormx 的连接池接入不变。
|
||||
|
||||
## Connector 接入(v0.9.1-preview)
|
||||
|
||||
项目已有 `database/sql/driver.Connector` 时使用 `OpenConnector`。这是标准驱动接口,核心包不增加具体驱动依赖。项目可以在同一进程创建多个使用不同证书、日志、拨号策略的连接池。
|
||||
|
||||
@@ -107,27 +107,3 @@ func (db *DB) Health(ctx context.Context) error {
|
||||
defer cancel()
|
||||
return db.PingContext(checkCtx)
|
||||
}
|
||||
|
||||
// InTx commits only when work returns nil. Any work error or panic rolls back.
|
||||
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) (err error) {
|
||||
if db == nil || db.DB == nil {
|
||||
return errors.New("database is not initialized")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin transaction: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
rollbackErr := tx.Rollback()
|
||||
if rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||
err = errors.Join(err, fmt.Errorf("rollback transaction: %w", rollbackErr))
|
||||
}
|
||||
}()
|
||||
if err = work(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit transaction: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
@@ -166,11 +167,64 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
|
||||
return rollbackErr
|
||||
}); !errors.Is(err, rollbackErr) {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageWork || txErr.Outcome != database.TxOutcomeRolledBack {
|
||||
t.Fatal("real MySQL rollback was not classified", err)
|
||||
}
|
||||
}
|
||||
var count int
|
||||
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check").Scan(&count); err != nil || count != 1 {
|
||||
t.Fatalf("transaction count=%d err=%v", count, err)
|
||||
}
|
||||
// Commit/Rollback run against real MySQL, then a driver-boundary wrapper
|
||||
// suppresses the acknowledgement. This is deterministic fault injection,
|
||||
// not a claim to reproduce every wire-level disconnect timing.
|
||||
for _, loseCommit := range []bool{true, false} {
|
||||
name, id := "commitAcknowledgementLost", 3
|
||||
if !loseCommit {
|
||||
name, id = "rollbackAcknowledgementLost", 4
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
connector, err := mysql.NewConnector(cfg.Clone())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ackErr, workErr := errors.New("injected lost acknowledgement"), errors.New("work rejected")
|
||||
fault := &ackLossConnector{Connector: connector, loseCommit: loseCommit, ackErr: ackErr}
|
||||
faultPool, err := database.OpenConnector(ctx, fault, pool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer faultPool.Close()
|
||||
err = faultPool.InTx(ctx, nil, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(ctx, "INSERT INTO connector_check VALUES (?)", id); err != nil {
|
||||
return err
|
||||
}
|
||||
if !loseCommit {
|
||||
return workErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || !errors.Is(err, ackErr) {
|
||||
t.Fatal("unknown acknowledgement was treated as definite outcome", err)
|
||||
}
|
||||
wantCount := 0
|
||||
if loseCommit {
|
||||
wantCount = 1
|
||||
if txErr.Stage != database.TxStageCommit || txErr.Err != ackErr || txErr.RollbackErr != sql.ErrTxDone || fault.commits.Load() != 1 || fault.rollbacks.Load() != 0 {
|
||||
t.Fatal("commit failure was retried or misclassified", err)
|
||||
}
|
||||
} else if txErr.Stage != database.TxStageWork || !errors.Is(err, workErr) || txErr.RollbackErr != ackErr || fault.commits.Load() != 0 || fault.rollbacks.Load() != 1 {
|
||||
t.Fatal("rollback failure lost its primary/secondary causes", err)
|
||||
}
|
||||
var stored int
|
||||
if err := second.QueryRowContext(ctx, "SELECT COUNT(*) FROM connector_check WHERE id = ?", id).Scan(&stored); err != nil || stored != wantCount {
|
||||
t.Fatal("independent connection observed unexpected persistence", stored, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := first.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -178,3 +232,58 @@ func TestMySQLConnectorTLSAndPoolIsolation(t *testing.T) {
|
||||
t.Fatal("closing first pool affected second:", err)
|
||||
}
|
||||
}
|
||||
|
||||
type ackLossConnector struct {
|
||||
driver.Connector
|
||||
loseCommit bool
|
||||
ackErr error
|
||||
commits, rollbacks atomic.Int32
|
||||
}
|
||||
|
||||
func (c *ackLossConnector) Connect(ctx context.Context) (driver.Conn, error) {
|
||||
conn, err := c.Connector.Connect(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ackLossConn{Conn: conn, owner: c}, nil
|
||||
}
|
||||
|
||||
type ackLossConn struct {
|
||||
driver.Conn
|
||||
owner *ackLossConnector
|
||||
}
|
||||
|
||||
func (c *ackLossConn) Ping(ctx context.Context) error { return c.Conn.(driver.Pinger).Ping(ctx) }
|
||||
func (c *ackLossConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
|
||||
tx, err := c.Conn.(driver.ConnBeginTx).BeginTx(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ackLossTx{Tx: tx, owner: c.owner}, nil
|
||||
}
|
||||
|
||||
type ackLossTx struct {
|
||||
driver.Tx
|
||||
owner *ackLossConnector
|
||||
}
|
||||
|
||||
func (tx *ackLossTx) Commit() error {
|
||||
tx.owner.commits.Add(1)
|
||||
if err := tx.Tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
if tx.owner.loseCommit {
|
||||
return tx.owner.ackErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (tx *ackLossTx) Rollback() error {
|
||||
tx.owner.rollbacks.Add(1)
|
||||
if err := tx.Tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tx.owner.loseCommit {
|
||||
return tx.owner.ackErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
126
database/transaction.go
Normal file
126
database/transaction.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// TxStage identifies the operation which produced the primary error.
|
||||
type TxStage string
|
||||
|
||||
const (
|
||||
TxStageBegin TxStage = "begin"
|
||||
TxStageWork TxStage = "work"
|
||||
TxStageCommit TxStage = "commit"
|
||||
)
|
||||
|
||||
// TxOutcome describes only what InTx can confirm about its managed transaction.
|
||||
// An absent/unrecognized value must be treated as unknown, never as rolled back.
|
||||
type TxOutcome string
|
||||
|
||||
const (
|
||||
TxOutcomeUnknown TxOutcome = "unknown"
|
||||
TxOutcomeNotStarted TxOutcome = "notStarted"
|
||||
TxOutcomeRolledBack TxOutcome = "rolledBack"
|
||||
)
|
||||
|
||||
// TxError retains the primary failure and any rollback failure independently.
|
||||
// Use errors.As to inspect it. errors.Is/As traverse Err first, then RollbackErr;
|
||||
// inspect Err alone when the distinction between the two causes matters.
|
||||
// Error text is for internal diagnostics and may include driver/business details.
|
||||
type TxError struct {
|
||||
Stage TxStage
|
||||
Outcome TxOutcome
|
||||
Err error
|
||||
RollbackErr error
|
||||
}
|
||||
|
||||
func (e *TxError) Error() string {
|
||||
if e == nil {
|
||||
return "transaction error"
|
||||
}
|
||||
message := string(e.Stage) + " transaction (" + string(e.Outcome) + ")"
|
||||
if e.Err != nil {
|
||||
message += ": " + e.Err.Error()
|
||||
}
|
||||
if e.RollbackErr != nil {
|
||||
message += "; rollback transaction: " + e.RollbackErr.Error()
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// Unwrap preserves both causes, including sql.ErrTxDone. That sentinel means
|
||||
// the local transaction is finished, not that the database confirmed rollback.
|
||||
func (e *TxError) Unwrap() []error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
if e.Err == nil {
|
||||
if e.RollbackErr == nil {
|
||||
return nil
|
||||
}
|
||||
return []error{e.RollbackErr}
|
||||
}
|
||||
if e.RollbackErr == nil {
|
||||
return []error{e.Err}
|
||||
}
|
||||
return []error{e.Err, e.RollbackErr}
|
||||
}
|
||||
|
||||
// InTx begins a transaction, calls work once, and commits only if work returns
|
||||
// nil. Every returned error is a *TxError; a nil result confirms Commit succeeded.
|
||||
// Commit failures always have an unknown outcome, even for cancellation or a
|
||||
// driver error normally associated with a lost connection. InTx does not retry.
|
||||
//
|
||||
// work must use only the supplied transaction for transactional operations and
|
||||
// must not Commit/Rollback it, issue transaction-control/implicit-commit SQL, or
|
||||
// let it escape to background goroutines. Outcome describes this transaction,
|
||||
// not files, other connections, nontransactional tables or external side effects.
|
||||
//
|
||||
// Panic and runtime.Goexit still attempt rollback and propagate unchanged; they
|
||||
// do not return a TxError. Rollback errors during those unwinds cannot be returned.
|
||||
// A nil context is treated as context.Background().
|
||||
func (db *DB) InTx(ctx context.Context, options *sql.TxOptions, work func(*sql.Tx) error) error {
|
||||
if db == nil || db.DB == nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("database is not initialized")}
|
||||
}
|
||||
if work == nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: errors.New("transaction callback is nil")}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
return &TxError{Stage: TxStageBegin, Outcome: TxOutcomeNotStarted, Err: err}
|
||||
}
|
||||
finished := false
|
||||
defer func() {
|
||||
if !finished {
|
||||
// Only the panic/Goexit path reaches here without explicit cleanup.
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if err = work(tx); err != nil {
|
||||
failure := txFailure(tx, TxStageWork, err)
|
||||
finished = true
|
||||
return failure
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
failure := txFailure(tx, TxStageCommit, err)
|
||||
finished = true
|
||||
return failure
|
||||
}
|
||||
finished = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func txFailure(tx *sql.Tx, stage TxStage, cause error) *TxError {
|
||||
rollbackErr := tx.Rollback()
|
||||
outcome := TxOutcomeUnknown
|
||||
if stage == TxStageWork && rollbackErr == nil {
|
||||
outcome = TxOutcomeRolledBack
|
||||
}
|
||||
return &TxError{Stage: stage, Outcome: outcome, Err: cause, RollbackErr: rollbackErr}
|
||||
}
|
||||
56
database/transactionResponse_test.go
Normal file
56
database/transactionResponse_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestInTxPublicErrorCompatibility(t *testing.T) {
|
||||
private := errors.New("private database details")
|
||||
for _, scenario := range []string{"business", "businessAndRollback", "commit"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
if scenario == "businessAndRollback" {
|
||||
f.rollbackErr = private
|
||||
} else if scenario == "commit" {
|
||||
f.commitErr = private
|
||||
}
|
||||
pool := txPool(t, f)
|
||||
app := omni.New(omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", DataMode: "always"}),
|
||||
omni.WithErrors(apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
|
||||
"INVALID": {Code: 400002, Message: "{field} is invalid", Params: []apierrors.Parameter{{Name: "field", Type: "string"}}},
|
||||
}}))
|
||||
defer app.Close()
|
||||
app.POST("/record", func(c *omni.Context) error {
|
||||
return pool.InTx(c.Context(), nil, func(*sql.Tx) error {
|
||||
if scenario == "commit" {
|
||||
return nil
|
||||
}
|
||||
return apierrors.ByCode("INVALID").WithParams(map[string]any{"field": "name"}).WithCause(private)
|
||||
})
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("POST", "/record", nil))
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantStatus, wantCode, wantMessage := 200, float64(400002), "name is invalid"
|
||||
if scenario == "commit" {
|
||||
wantStatus, wantCode, wantMessage = 500, 9999, "internal server error"
|
||||
}
|
||||
data, hasData := body["data"]
|
||||
if w.Code != wantStatus || body["code"] != wantCode || body["msg"] != wantMessage || !hasData || data != nil || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal("transaction wrapper changed public error response:", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
441
database/transaction_test.go
Normal file
441
database/transaction_test.go
Normal file
@@ -0,0 +1,441 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
// The driver can persist a write and lose the commit acknowledgement, or fail
|
||||
// before persisting it. Both must be indistinguishable to the generic wrapper.
|
||||
type txFixture struct {
|
||||
beginErr, commitErr, rollbackErr error
|
||||
persistBeforeError bool
|
||||
onBegin func(context.Context, driver.TxOptions)
|
||||
onCommit func()
|
||||
onRollback func()
|
||||
begins, commits, rollbacks, saved atomic.Int64
|
||||
}
|
||||
|
||||
func (f *txFixture) Connect(context.Context) (driver.Conn, error) { return &txConn{owner: f}, nil }
|
||||
func (f *txFixture) Driver() driver.Driver { return f }
|
||||
func (f *txFixture) Open(string) (driver.Conn, error) { return f.Connect(context.Background()) }
|
||||
|
||||
type txConn struct {
|
||||
owner *txFixture
|
||||
pending int64
|
||||
}
|
||||
|
||||
func (c *txConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not used") }
|
||||
func (c *txConn) Close() error { return nil }
|
||||
func (c *txConn) Begin() (driver.Tx, error) {
|
||||
return c.BeginTx(context.Background(), driver.TxOptions{})
|
||||
}
|
||||
func (c *txConn) BeginTx(ctx context.Context, options driver.TxOptions) (driver.Tx, error) {
|
||||
c.owner.begins.Add(1)
|
||||
if c.owner.onBegin != nil {
|
||||
c.owner.onBegin(ctx, options)
|
||||
}
|
||||
if c.owner.beginErr != nil {
|
||||
return nil, c.owner.beginErr
|
||||
}
|
||||
c.pending = 0
|
||||
return &txDriver{conn: c}, nil
|
||||
}
|
||||
func (c *txConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
||||
c.pending++
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
|
||||
type txDriver struct{ conn *txConn }
|
||||
|
||||
func (tx *txDriver) Commit() error {
|
||||
f := tx.conn.owner
|
||||
f.commits.Add(1)
|
||||
if f.commitErr == nil || f.persistBeforeError {
|
||||
f.saved.Add(tx.conn.pending)
|
||||
}
|
||||
if f.onCommit != nil {
|
||||
f.onCommit()
|
||||
}
|
||||
return f.commitErr
|
||||
}
|
||||
func (tx *txDriver) Rollback() error {
|
||||
f := tx.conn.owner
|
||||
f.rollbacks.Add(1)
|
||||
if f.onRollback != nil {
|
||||
f.onRollback()
|
||||
}
|
||||
return f.rollbackErr
|
||||
}
|
||||
|
||||
func txPool(t testing.TB, f *txFixture) *database.DB {
|
||||
t.Helper()
|
||||
pool := sql.OpenDB(f)
|
||||
pool.SetMaxOpenConns(8)
|
||||
t.Cleanup(func() { _ = pool.Close() })
|
||||
return &database.DB{DB: pool}
|
||||
}
|
||||
|
||||
type txCause struct{ name string }
|
||||
|
||||
func (e *txCause) Error() string { return e.name }
|
||||
|
||||
func checkTxError(t *testing.T, err error, stage database.TxStage, outcome database.TxOutcome, cause, rollback error) *database.TxError {
|
||||
t.Helper()
|
||||
var txErr *database.TxError
|
||||
if !errors.As(fmt.Errorf("repository: %w", err), &txErr) {
|
||||
t.Fatalf("missing typed transaction error: %v", err)
|
||||
}
|
||||
if txErr.Stage != stage || txErr.Outcome != outcome || txErr.Err != cause || txErr.RollbackErr != rollback {
|
||||
t.Fatalf("unexpected transaction error: %+v", txErr)
|
||||
}
|
||||
if cause != nil && !errors.Is(err, cause) || rollback != nil && !errors.Is(err, rollback) {
|
||||
t.Fatalf("cause chain lost: %v", err)
|
||||
}
|
||||
return txErr
|
||||
}
|
||||
|
||||
func TestInTxStagesAndCauses(t *testing.T) {
|
||||
primary, rollback := &txCause{"primary"}, &txCause{"rollback"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
beginErr, workErr, commitErr, rb error
|
||||
stage database.TxStage
|
||||
outcome database.TxOutcome
|
||||
wantWork, wantCommit, wantRB int64
|
||||
}{
|
||||
{"begin", primary, nil, nil, nil, database.TxStageBegin, database.TxOutcomeNotStarted, 0, 0, 0},
|
||||
{"work", nil, primary, nil, nil, database.TxStageWork, database.TxOutcomeRolledBack, 1, 0, 1},
|
||||
{"workAndRollback", nil, primary, nil, rollback, database.TxStageWork, database.TxOutcomeUnknown, 1, 0, 1},
|
||||
{"commit", nil, nil, primary, nil, database.TxStageCommit, database.TxOutcomeUnknown, 1, 1, 0},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := &txFixture{beginErr: tc.beginErr, commitErr: tc.commitErr, rollbackErr: tc.rb}
|
||||
pool := txPool(t, f)
|
||||
var calls int64
|
||||
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
|
||||
calls++
|
||||
_, err := tx.ExecContext(context.Background(), "write")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tc.workErr
|
||||
})
|
||||
rb := tc.rb
|
||||
if tc.stage == database.TxStageCommit {
|
||||
rb = sql.ErrTxDone // No driver rollback happened after Commit.
|
||||
}
|
||||
checkTxError(t, err, tc.stage, tc.outcome, primary, rb)
|
||||
var typedCause *txCause
|
||||
if !errors.As(err, &typedCause) || typedCause != primary {
|
||||
t.Fatal("primary typed cause not first in chain")
|
||||
}
|
||||
if calls != tc.wantWork || f.commits.Load() != tc.wantCommit || f.rollbacks.Load() != tc.wantRB || f.saved.Load() != 0 {
|
||||
t.Fatal("unexpected callback/commit/rollback calls or persisted write")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("rollbackTypedCause", func(t *testing.T) {
|
||||
pool := txPool(t, &txFixture{rollbackErr: rollback})
|
||||
cause := errors.New("work failed")
|
||||
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
|
||||
var found *txCause
|
||||
if !errors.As(err, &found) || found != rollback {
|
||||
t.Fatal("typed rollback cause lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInTxCommitOutcomeUnknown(t *testing.T) {
|
||||
for _, cause := range []error{&txCause{"lost acknowledgement"}, context.DeadlineExceeded, driver.ErrBadConn} {
|
||||
for _, persisted := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/persisted=%t", cause, persisted), func(t *testing.T) {
|
||||
f := &txFixture{commitErr: cause, persistBeforeError: persisted}
|
||||
pool := txPool(t, f)
|
||||
calls := 0
|
||||
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
|
||||
calls++
|
||||
_, err := tx.ExecContext(context.Background(), "register object")
|
||||
return err
|
||||
})
|
||||
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
|
||||
wantSaved := int64(0)
|
||||
if persisted {
|
||||
wantSaved = 1
|
||||
}
|
||||
if f.saved.Load() != wantSaved || calls != 1 || f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
|
||||
t.Fatal("commit was retried, rolled back, or incorrectly modeled")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInTxCancellationAndOwnership(t *testing.T) {
|
||||
t.Run("beforeBegin", func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
pool := txPool(t, f)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
err := pool.InTx(ctx, nil, func(*sql.Tx) error { t.Fatal("work called"); return nil })
|
||||
checkTxError(t, err, database.TxStageBegin, database.TxOutcomeNotStarted, context.Canceled, nil)
|
||||
if f.begins.Load() != 0 {
|
||||
t.Fatal("driver began a canceled transaction")
|
||||
}
|
||||
})
|
||||
for _, returnedWorkError := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("automaticRollback/workError=%t", returnedWorkError), func(t *testing.T) {
|
||||
rolledBack := make(chan struct{})
|
||||
rollbackCause := errors.New("rollback connection lost")
|
||||
f := &txFixture{rollbackErr: rollbackCause, onRollback: func() { close(rolledBack) }}
|
||||
pool := txPool(t, f)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
|
||||
cancel()
|
||||
select {
|
||||
case <-rolledBack:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("automatic rollback did not run")
|
||||
}
|
||||
if returnedWorkError {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
})
|
||||
stage, primary := database.TxStageCommit, error(sql.ErrTxDone)
|
||||
if returnedWorkError {
|
||||
stage, primary = database.TxStageWork, context.Canceled
|
||||
}
|
||||
checkTxError(t, err, stage, database.TxOutcomeUnknown, primary, sql.ErrTxDone)
|
||||
// database/sql owns this asynchronous rollback and does not expose
|
||||
// its driver error. Do not pretend ErrTxDone means successful rollback.
|
||||
if errors.Is(err, rollbackCause) || f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
||||
t.Fatal("unexpected automatic rollback accounting")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("cancelDuringCommit", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
f := &txFixture{commitErr: context.Canceled, persistBeforeError: true, onCommit: cancel}
|
||||
pool := txPool(t, f)
|
||||
err := pool.InTx(ctx, nil, func(tx *sql.Tx) error { _, err := tx.ExecContext(ctx, "write"); return err })
|
||||
checkTxError(t, err, database.TxStageCommit, database.TxOutcomeUnknown, context.Canceled, sql.ErrTxDone)
|
||||
if f.saved.Load() != 1 || f.rollbacks.Load() != 0 {
|
||||
t.Fatal("cancellation hid a committed write")
|
||||
}
|
||||
})
|
||||
for _, action := range []string{"commit", "rollback"} {
|
||||
t.Run("manual/"+action, func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
pool := txPool(t, f)
|
||||
cause := errors.New("callback failed")
|
||||
err := pool.InTx(nil, nil, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
if action == "commit" {
|
||||
err = tx.Commit()
|
||||
} else {
|
||||
err = tx.Rollback()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cause
|
||||
})
|
||||
checkTxError(t, err, database.TxStageWork, database.TxOutcomeUnknown, cause, sql.ErrTxDone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInTxSuccessPanicAndConcurrency(t *testing.T) {
|
||||
t.Run("success", func(t *testing.T) {
|
||||
type key struct{}
|
||||
ctx := context.WithValue(context.Background(), key{}, "value")
|
||||
f := &txFixture{onBegin: func(received context.Context, opts driver.TxOptions) {
|
||||
if received.Value(key{}) != "value" || !opts.ReadOnly || opts.Isolation != driver.IsolationLevel(sql.LevelSerializable) {
|
||||
t.Error("context or options changed")
|
||||
}
|
||||
}}
|
||||
pool := txPool(t, f)
|
||||
if err := pool.InTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable, ReadOnly: true}, func(*sql.Tx) error { return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.commits.Load() != 1 || f.rollbacks.Load() != 0 {
|
||||
t.Fatal("unexpected successful transaction calls")
|
||||
}
|
||||
})
|
||||
for _, rbErr := range []error{nil, errors.New("rollback failed during unwind")} {
|
||||
t.Run(fmt.Sprintf("panic/%v", rbErr), func(t *testing.T) {
|
||||
f := &txFixture{rollbackErr: rbErr}
|
||||
pool := txPool(t, f)
|
||||
value := &struct{ text string }{"original panic"}
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() != value {
|
||||
t.Error("panic identity lost")
|
||||
}
|
||||
}()
|
||||
_ = pool.InTx(nil, nil, func(*sql.Tx) error { panic(value) })
|
||||
}()
|
||||
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
||||
t.Fatal("panic did not roll back exactly once")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("goexit", func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
pool := txPool(t, f)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
_ = pool.InTx(nil, nil, func(*sql.Tx) error { runtime.Goexit(); return nil })
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Goexit did not finish")
|
||||
}
|
||||
if f.rollbacks.Load() != 1 || f.commits.Load() != 0 {
|
||||
t.Fatal("Goexit did not roll back exactly once")
|
||||
}
|
||||
})
|
||||
t.Run("concurrent", func(t *testing.T) {
|
||||
f := &txFixture{}
|
||||
pool := txPool(t, f)
|
||||
var group sync.WaitGroup
|
||||
for i := 0; i < 64; i++ {
|
||||
group.Add(1)
|
||||
go func(i int) {
|
||||
defer group.Done()
|
||||
cause := &txCause{fmt.Sprint(i)}
|
||||
err := pool.InTx(nil, nil, func(*sql.Tx) error { return cause })
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Err != cause || txErr.Outcome != database.TxOutcomeRolledBack || !errors.Is(err, cause) {
|
||||
t.Error("cross-request error state")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
group.Wait()
|
||||
if f.rollbacks.Load() != 64 || f.commits.Load() != 0 {
|
||||
t.Fatal("incorrect concurrent calls")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestInTxPreconditionsAndNestedErrors(t *testing.T) {
|
||||
for _, pool := range []*database.DB{nil, {}} {
|
||||
err := pool.InTx(nil, nil, func(*sql.Tx) error { t.Fatal("unexpected callback"); return nil })
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || txErr.Outcome != database.TxOutcomeNotStarted || txErr.Err == nil {
|
||||
t.Fatal("uninitialized database was not classified")
|
||||
}
|
||||
}
|
||||
f := &txFixture{}
|
||||
pool := txPool(t, f)
|
||||
err := pool.InTx(nil, nil, nil)
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Stage != database.TxStageBegin || f.begins.Load() != 0 {
|
||||
t.Fatal("nil callback began a transaction")
|
||||
}
|
||||
inner := &database.TxError{Stage: database.TxStageCommit, Outcome: database.TxOutcomeUnknown, Err: errors.New("other transaction")}
|
||||
err = pool.InTx(nil, nil, func(*sql.Tx) error { return inner })
|
||||
outer := checkTxError(t, err, database.TxStageWork, database.TxOutcomeRolledBack, inner, nil)
|
||||
if outer == inner || !errors.As(outer.Err, &txErr) || txErr != inner {
|
||||
t.Fatal("nested error was flattened")
|
||||
}
|
||||
// Empty or typed-nil error values must not panic or expose nil unwrap entries.
|
||||
for _, value := range []*database.TxError{nil, {}, {RollbackErr: inner}} {
|
||||
_ = value.Error()
|
||||
for _, child := range value.Unwrap() {
|
||||
if child == nil {
|
||||
t.Fatal("nil unwrap child")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInTxCancellationRace(t *testing.T) {
|
||||
const count = 128
|
||||
rollbackCause := errors.New("rollback acknowledgement unavailable")
|
||||
var rollbacks sync.WaitGroup
|
||||
rollbacks.Add(count)
|
||||
f := &txFixture{rollbackErr: rollbackCause, onRollback: rollbacks.Done}
|
||||
pool := txPool(t, f)
|
||||
for i := 0; i < count; i++ {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
workFailure := i%2 == 0
|
||||
err := pool.InTx(ctx, nil, func(*sql.Tx) error {
|
||||
cancel()
|
||||
if i%3 == 0 {
|
||||
runtime.Gosched() // Let the automatic rollback race explicit cleanup.
|
||||
}
|
||||
if workFailure {
|
||||
return context.Canceled
|
||||
}
|
||||
return nil
|
||||
})
|
||||
cancel()
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || txErr.Outcome != database.TxOutcomeUnknown || f.commits.Load() != 0 {
|
||||
t.Fatal("cancellation was treated as confirmed rollback", err)
|
||||
}
|
||||
wantStage := database.TxStageCommit
|
||||
if workFailure {
|
||||
wantStage = database.TxStageWork
|
||||
}
|
||||
if txErr.Stage != wantStage || txErr.RollbackErr != rollbackCause && txErr.RollbackErr != sql.ErrTxDone {
|
||||
t.Fatal("unexpected cancellation diagnostics", err)
|
||||
}
|
||||
if txErr.RollbackErr == rollbackCause && !errors.Is(err, rollbackCause) {
|
||||
t.Fatal("observed rollback failure lost")
|
||||
}
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { rollbacks.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("canceled transactions did not finish rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkInTx(b *testing.B) {
|
||||
cause := errors.New("example failure")
|
||||
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitUnknown"} {
|
||||
b.Run(name, func(b *testing.B) {
|
||||
f := &txFixture{}
|
||||
var workErr error
|
||||
switch name {
|
||||
case "beginFailure":
|
||||
f.beginErr = cause
|
||||
case "workFailure":
|
||||
workErr = cause
|
||||
case "rollbackFailure":
|
||||
workErr, f.rollbackErr = cause, cause
|
||||
case "commitUnknown":
|
||||
f.commitErr = cause
|
||||
}
|
||||
pool := txPool(b, f)
|
||||
work := func(*sql.Tx) error { return workErr }
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
err := pool.InTx(context.Background(), nil, work)
|
||||
if (err == nil) != (name == "success") {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
17
examples/cors/README.md
Normal file
17
examples/cors/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# CORS 与正常鉴权
|
||||
|
||||
在框架根目录运行:
|
||||
|
||||
```sh
|
||||
go run ./examples/cors
|
||||
WEB_ORIGIN=https://admin.example go run ./examples/cors
|
||||
```
|
||||
|
||||
仅监听临时 localhost 端口,不依赖数据库或外部服务。配置由统一 LoadProject 加载,示例模拟浏览器 Origin/预检请求头,依次检查:
|
||||
|
||||
- OPTIONS 预检返回 204,无需登录。
|
||||
- 实际 GET 未登录仍返回 401,前端可读取这条错误响应。
|
||||
- 示例凭据通过后返回 200。
|
||||
- 404 也保留允许来源的 CORS 响应头。
|
||||
|
||||
示例 Token 仅用于展示,项目使用自身鉴权实现。CORS 不验证 Token,也不保证其他客户端不能调用 API。完整规则与配置见 [cors](../../cors/README.md)。
|
||||
10
examples/cors/config.yaml
Normal file
10
examples/cors/config.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
server:
|
||||
cors:
|
||||
enabled: true
|
||||
allowedOrigins:
|
||||
- "${WEB_ORIGIN:-https://web.example}"
|
||||
allowedMethods: [GET, POST]
|
||||
allowedHeaders: [Authorization, Content-Type]
|
||||
exposedHeaders: [X-Request-ID]
|
||||
allowCredentials: false
|
||||
maxAge: 10m
|
||||
76
examples/cors/main.go
Normal file
76
examples/cors/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
}
|
||||
|
||||
func (c Config) Validate() error { return config.AtField("server", c.Server.Validate()) }
|
||||
|
||||
func run(path string) error {
|
||||
cfg, err := config.LoadProject(path, Config{Server: config.Default()})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := omni.New(omni.WithConfig(cfg.Server))
|
||||
defer a.Close()
|
||||
a.GET("/private", func(c *omni.Context) error {
|
||||
if c.Header("Authorization") != "Bearer example-only" {
|
||||
return omni.Unauthorized("LOGIN_REQUIRED", "login required")
|
||||
}
|
||||
return c.OK("authenticated")
|
||||
})
|
||||
server := httptest.NewServer(a)
|
||||
defer server.Close()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for _, tc := range []struct {
|
||||
method, path, auth string
|
||||
status int
|
||||
}{{"OPTIONS", "/private", "", 204}, {"GET", "/private", "", 401}, {"GET", "/private", "Bearer example-only", 200}, {"GET", "/missing", "", 404}} {
|
||||
r, _ := http.NewRequest(tc.method, server.URL+tc.path, nil)
|
||||
r.Header.Set("Origin", cfg.Server.CORS.AllowedOrigins[0])
|
||||
if tc.method == "OPTIONS" {
|
||||
r.Header.Set("Access-Control-Request-Method", "GET")
|
||||
r.Header.Set("Access-Control-Request-Headers", "authorization")
|
||||
}
|
||||
if tc.auth != "" {
|
||||
r.Header.Set("Authorization", tc.auth)
|
||||
}
|
||||
res, err := client.Do(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, readErr := io.Copy(io.Discard, res.Body)
|
||||
closeErr := res.Body.Close()
|
||||
if err := errors.Join(readErr, closeErr); err != nil {
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") == "" {
|
||||
return fmt.Errorf("unexpected CORS response: %d", res.StatusCode)
|
||||
}
|
||||
fmt.Printf("%s %s -> %d, CORS allowed\n", tc.method, tc.path, res.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func main() {
|
||||
path := flag.String("config", "examples/cors/config.yaml", "selected configuration file")
|
||||
flag.Parse()
|
||||
if err := run(*path); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ module example.com/managed
|
||||
|
||||
go 1.22.0
|
||||
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.23.0-preview
|
||||
require git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
|
||||
24
examples/transactions/README.md
Normal file
24
examples/transactions/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 事务失败与结果不确定
|
||||
|
||||
在框架根目录运行:
|
||||
|
||||
```sh
|
||||
go run ./examples/transactions
|
||||
```
|
||||
|
||||
示例使用确定性的演示 SQL 驱动,没有真实数据库、上传文件或外部服务。业务调用仍走真实 `database/sql` 和 `database.InTx`;`demoDriver.go` 仅用来注入数据库操作成功后确认丢失的情况,项目继续使用自己的正常驱动。
|
||||
|
||||
```text
|
||||
success: committed, stored=true
|
||||
beginFailure: stage=begin outcome=notStarted stored=false cleanup=true
|
||||
workFailure: stage=work outcome=rolledBack stored=false cleanup=true
|
||||
rollbackFailure: stage=work outcome=unknown stored=false cleanup=false
|
||||
commitRejected: stage=commit outcome=unknown stored=false cleanup=false
|
||||
commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false
|
||||
```
|
||||
|
||||
最后两个场景收到一样的提交错误,但一个没有保存、另一个已经保存。调用方不能从提交错误推断数据库没记录,因此示例只在已知未执行回调或已回滚时考虑清理,不确定时保留。`cleanup` 只是演示业务决策,没有执行任何删除;实际业务仍需考虑文件是否只属于本次操作、其他事务或外部副作用。
|
||||
|
||||
`errors.As` 取得 TxError 的阶段/结果,`errors.Is` 仍找到原始演示错误。回滚失败有独立原因;TxError 不规定业务码、HTTP 状态或清理策略。
|
||||
|
||||
完整接口、自动回滚的观察边界和兼容注意见 [database](../../database/README.md#事务阶段与结果)。真实 MySQL 和故障回归见 [验收记录](../../reports/transactionAcceptance.md)。
|
||||
48
examples/transactions/demoDriver.go
Normal file
48
examples/transactions/demoDriver.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// This driver models an acknowledgement lost AFTER persistence. Applications
|
||||
// use their normal SQL driver; this is solely a runnable fault demonstration.
|
||||
type demoConnector struct {
|
||||
beginErr, commitErr, rollbackErr error
|
||||
persistBeforeError bool
|
||||
stored atomic.Bool
|
||||
}
|
||||
|
||||
func (d *demoConnector) Connect(context.Context) (driver.Conn, error) {
|
||||
return &demoConn{owner: d}, nil
|
||||
}
|
||||
func (d *demoConnector) Driver() driver.Driver { return d }
|
||||
func (d *demoConnector) Open(string) (driver.Conn, error) { return d.Connect(context.Background()) }
|
||||
|
||||
type demoConn struct{ owner *demoConnector }
|
||||
|
||||
func (c *demoConn) Prepare(string) (driver.Stmt, error) {
|
||||
return nil, errors.New("not implemented by demo")
|
||||
}
|
||||
func (c *demoConn) Close() error { return nil }
|
||||
func (c *demoConn) Begin() (driver.Tx, error) {
|
||||
if c.owner.beginErr != nil {
|
||||
return nil, c.owner.beginErr
|
||||
}
|
||||
return &demoTx{owner: c.owner}, nil
|
||||
}
|
||||
func (c *demoConn) ExecContext(context.Context, string, []driver.NamedValue) (driver.Result, error) {
|
||||
return driver.RowsAffected(1), nil
|
||||
}
|
||||
|
||||
type demoTx struct{ owner *demoConnector }
|
||||
|
||||
func (tx *demoTx) Commit() error {
|
||||
if tx.owner.commitErr == nil || tx.owner.persistBeforeError {
|
||||
tx.owner.stored.Store(true)
|
||||
}
|
||||
return tx.owner.commitErr
|
||||
}
|
||||
func (tx *demoTx) Rollback() error { return tx.owner.rollbackErr }
|
||||
55
examples/transactions/main.go
Normal file
55
examples/transactions/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
// Run with go run ./examples/transactions. Uses a deterministic demonstration
|
||||
// driver, no external database and no actual uploaded files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
failure := errors.New("demonstration failure")
|
||||
for _, name := range []string{"success", "beginFailure", "workFailure", "rollbackFailure", "commitRejected", "commitAcknowledgementLost"} {
|
||||
driver := &demoConnector{}
|
||||
var workErr error
|
||||
switch name {
|
||||
case "beginFailure":
|
||||
driver.beginErr = failure
|
||||
case "workFailure":
|
||||
workErr = failure
|
||||
case "rollbackFailure":
|
||||
workErr, driver.rollbackErr = failure, failure
|
||||
case "commitRejected":
|
||||
driver.commitErr = failure
|
||||
case "commitAcknowledgementLost":
|
||||
driver.commitErr, driver.persistBeforeError = failure, true
|
||||
}
|
||||
pool := &database.DB{DB: sql.OpenDB(driver)}
|
||||
err := pool.InTx(context.Background(), nil, func(tx *sql.Tx) error {
|
||||
if _, err := tx.ExecContext(context.Background(), "INSERT demonstration record"); err != nil {
|
||||
return err
|
||||
}
|
||||
return workErr
|
||||
})
|
||||
_ = pool.Close()
|
||||
if err == nil {
|
||||
fmt.Printf("%s: committed, stored=%t\n", name, driver.stored.Load())
|
||||
continue
|
||||
}
|
||||
var txErr *database.TxError
|
||||
if !errors.As(err, &txErr) || !errors.Is(err, failure) {
|
||||
panic("transaction phase or original cause lost")
|
||||
}
|
||||
// A project decides whether cleanup is appropriate for its own operation.
|
||||
// Unknown outcomes must not be treated as evidence that no record exists.
|
||||
cleanup := txErr.Outcome == database.TxOutcomeNotStarted || txErr.Outcome == database.TxOutcomeRolledBack
|
||||
if cleanup && driver.stored.Load() {
|
||||
panic("unsafe cleanup decision")
|
||||
}
|
||||
fmt.Printf("%s: stage=%s outcome=%s stored=%t cleanup=%t\n", name, txErr.Stage, txErr.Outcome, driver.stored.Load(), cleanup)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module example.com/userService
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
git.yuntuo.hk/k2safe/go-omni v0.23.0-preview
|
||||
git.yuntuo.hk/k2safe/go-omni v0.25.0-preview
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
)
|
||||
|
||||
|
||||
4
omni.go
4
omni.go
@@ -6,6 +6,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/app"
|
||||
"git.yuntuo.hk/k2safe/go-omni/auth"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
@@ -27,6 +28,7 @@ type APIError = apierrors.APIError
|
||||
type Config = config.Config
|
||||
type TLSConfig = config.TLSConfig
|
||||
type HTTPConfig = config.HTTPConfig
|
||||
type CORSConfig = cors.Config
|
||||
type EndpointsConfig = config.EndpointsConfig
|
||||
type EndpointConfig = config.EndpointConfig
|
||||
type HealthEndpointConfig = config.HealthEndpointConfig
|
||||
@@ -70,6 +72,8 @@ func WithTransport(name string) Option { return config.WithTransport(name) }
|
||||
|
||||
func WithHTTPConfig(settings HTTPConfig) Option { return config.WithHTTPConfig(settings) }
|
||||
|
||||
func WithCORS(settings CORSConfig) Option { return config.WithCORS(settings) }
|
||||
|
||||
func WithTLS(certFile, keyFile string) Option { return config.WithTLS(certFile, keyFile) }
|
||||
|
||||
func WithLogConfig(settings LogConfig) Option { return config.WithLogConfig(settings) }
|
||||
|
||||
76
reports/corsAcceptance.md
Normal file
76
reports/corsAcceptance.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# v0.24 CORS 验收(2026-09-26)
|
||||
|
||||
新增通用、可选的跨域策略。没有修改接入项目,没有连接业务数据库或对线上服务器进行压测。
|
||||
|
||||
## 回归
|
||||
|
||||
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含实际生成消费者和既有生命周期、错误目录、配置、上传下载等回归。
|
||||
- 最后补充 IPv4-mapped IPv6 来源序列化后,cors/config/app 的 race 复测通过。
|
||||
- Go 1.22.12 的 cors/config/app 测试通过;`go vet ./...`、`go build ./...` 通过。
|
||||
- 最后补充来源序列化后,同三个包在 Go 1.22.12 上复测通过。
|
||||
- managedService 重生成检查、race、根目录 build 通过;userService race 通过。没有提供外部数据库配置的测试按原规则跳过。
|
||||
- 实际生成的 CORS 消费者只修改配置,不修改 main、svc、handler;构建/race、重复生成、dry-run、check、业务文件字节保留通过。
|
||||
- `FuzzCORSHeaders` 运行 30 秒、4 worker,执行 2,877,406 次,通过。输入覆盖来源、方法和预检请求头,不等于穷尽所有协议组合。
|
||||
- `examples/cors` 使用本地临时监听端口验证 204、401、200、404,通过。
|
||||
|
||||
| 范围 | 已验证场景 |
|
||||
| --- | --- |
|
||||
| 默认与配置 | 默认关闭保持原 Handler;JSON/YAML/XML 一致;显式环境引用;配置错误定位 server.cors 具体字段及文件/环境名/可用位置,不输出值 |
|
||||
| 来源 | 精确协议/域名/端口、默认端口规范化、IPv6、恶意后缀、null/路径/CRLF 拒绝;通配来源禁止凭据;重复 Origin 拒绝 |
|
||||
| 预检 | 204 空响应,拒绝为 403;不进入业务鉴权;方法/头校验、大小与数量限制;普通 OPTIONS 仍进入项目 Handler |
|
||||
| 实际响应 | 业务鉴权保留;自定义数字响应、401/500/panic、404/405 保留跨域头和原响应;不允许来源仅不授予浏览器读取权限 |
|
||||
| 标准能力 | 原 Request/Context/Body/ResponseWriter 不替换,Flush 等能力保留;Vary 追加/去重,已有值和通配 Vary 保留 |
|
||||
| transport | nethttp/native/HTTP2 使用真实本地 TLS;预检、原始上传、流式下载、暴露文件名、404 与关闭;HTTP2 确认协议协商 |
|
||||
| 隔离 | 输入配置、Option、Config() 返回值的切片变更不改变已构建策略;32 个并发调用者各执行 100 次检查,race 通过 |
|
||||
| 生成项目 | 未实现 Logic 的 501、404/405、健康检查、OpenAPI 和预检采用同一配置;非法凭据/通配组合在启动前报错 |
|
||||
|
||||
## 浏览器
|
||||
|
||||
通过 Codex In-app Browser,在两个不同的临时 localhost 页面分别执行 10 项真实 fetch 检查,共 20 项通过。记录见 [corsBrowser.json](corsBrowser.json)。
|
||||
|
||||
允许来源能读取正常响应、401、404、下载文件名,Authorization 触发的预检通过;不允许的请求头/方法被浏览器拦截。未列入白名单的页面无法读取私有 API;无凭据通配 API 可读取,带 `credentials: include` 的通配请求被拦截。凭据模式测试使用临时演示请求,未设置或使用真实登录 Cookie。
|
||||
|
||||
复现:在框架根目录 `go run ./scripts/corsBrowser`,打开终端输出的两个页面地址并点击 Run checks。脚本只监听 127.0.0.1,接收退出信号或五分钟后关闭。它用于浏览器协议验证,不是部署服务。本次临时监听已停止。
|
||||
|
||||
## 性能
|
||||
|
||||
Go 1.27.1、darwin/arm64、Apple M2 Max,无 race;每场景三轮、benchtime=300ms,表中取中位数。[原始结果](corsBenchmark.txt)。
|
||||
|
||||
| 场景 | 时间/次 | 分配/次 |
|
||||
| --- | ---: | ---: |
|
||||
| 关闭 CORS | 3.357 ns | 0 B / 0 allocs |
|
||||
| 开启,无 Origin | 108.2 ns | 16 B / 1 alloc |
|
||||
| 精确来源允许,含两个暴露头 | 231.5 ns | 48 B / 3 allocs |
|
||||
| 来源不允许 | 130.4 ns | 16 B / 1 alloc |
|
||||
| 允许预检 | 589.7 ns | 176 B / 7 allocs |
|
||||
| 通配来源,无凭据 | 510.3 ns | 216 B / 5 allocs |
|
||||
|
||||
关闭时直接返回原 Handler,表中是基准循环与下游空 Handler 的耗时,不能理解为新增延迟。开启时使用预建请求、复用 Header map 的测试 Writer;不包含网络、TLS 或业务处理,不能换算成实际服务 QPS。精确白名单只查初始化时建立的表;通配模式还会验证传入来源是否合法,因此更慢。请求和响应体不进入本模块内存缓冲。
|
||||
|
||||
```sh
|
||||
go test ./cors -run '^$' -bench BenchmarkCORS -benchmem -benchtime=300ms -count=3
|
||||
go test ./cors -run '^$' -fuzz '^FuzzCORSHeaders$' -fuzztime=30s -parallel=4
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run '^TestGeneratedCORSConsumer$'
|
||||
```
|
||||
|
||||
## 边界
|
||||
|
||||
预检按应用策略判断,不探测每条路由。业务访问日志和指标不统计被 CORS 提前结束的预检,可在代理或 App 外层记录。网络解析/TLS/网络过载等进入 App 前的拒绝不由 CORS 改写。
|
||||
|
||||
默认关闭,同源反向代理可继续使用原部署方式。只支持 HTTP(S) 精确来源或单一无凭据通配来源,不提供子域通配/正则/null 来源。WebSocket Origin、Private Network Access 和对象存储自身的 CORS 属于其他接口,本轮不包含。
|
||||
|
||||
不批准来源不等于阻止服务端执行实际请求,项目的身份认证、权限和 CSRF 策略仍然必要。代理和应用只选择一处负责 CORS,下游不能覆盖许可头或 Vary。详细用法见 [cors 指南](../cors/README.md)。
|
||||
|
||||
## 远程版本验收
|
||||
|
||||
`v0.24.0-preview` 标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`。空 module 缓存安装远程 CLI,生成项目固定引用远程框架版本,无本地 replace;checksum 为 `h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=`。
|
||||
|
||||
验收耗时 66.78 秒,70 项检查通过。9 组实际生成消费者执行 race/build;新增 CORS 消费者只改配置即可使用,重复生成/check 保留业务文件。下载模块的 cors/app 全包 race 通过,含三种 transport 的真实 TLS 检查;此前 config/httpc/copyx/logx、初始化/监听失败清理、后台关闭顺序、错误目录继承、上传下载、workspace 等回归继续通过。
|
||||
|
||||
版本、检查项及源码哈希见 [corsRemote.json](corsRemote.json)。复现命令:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyManagedRelease.py --version v0.24.0-preview --report reports/corsRemote.json
|
||||
```
|
||||
|
||||
发布标签不随此验收记录提交移动。
|
||||
24
reports/corsBenchmark.txt
Normal file
24
reports/corsBenchmark.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
pkg: git.yuntuo.hk/k2safe/go-omni/cors
|
||||
cpu: Apple M2 Max
|
||||
BenchmarkCORS/disabled-12 98300086 3.347 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCORS/disabled-12 100000000 3.650 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCORS/disabled-12 105484652 3.357 ns/op 0 B/op 0 allocs/op
|
||||
BenchmarkCORS/noOrigin-12 3340696 111.6 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/noOrigin-12 3420523 108.2 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/noOrigin-12 3297222 108.2 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/allowed-12 1557544 232.6 ns/op 48 B/op 3 allocs/op
|
||||
BenchmarkCORS/allowed-12 1552413 230.4 ns/op 48 B/op 3 allocs/op
|
||||
BenchmarkCORS/allowed-12 1561892 231.5 ns/op 48 B/op 3 allocs/op
|
||||
BenchmarkCORS/denied-12 2723037 130.5 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/denied-12 2756702 129.4 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/denied-12 2777206 130.4 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkCORS/preflight-12 638340 588.4 ns/op 176 B/op 7 allocs/op
|
||||
BenchmarkCORS/preflight-12 623992 591.8 ns/op 176 B/op 7 allocs/op
|
||||
BenchmarkCORS/preflight-12 592320 589.7 ns/op 176 B/op 7 allocs/op
|
||||
BenchmarkCORS/wildcard-12 708910 510.3 ns/op 216 B/op 5 allocs/op
|
||||
BenchmarkCORS/wildcard-12 739617 506.8 ns/op 216 B/op 5 allocs/op
|
||||
BenchmarkCORS/wildcard-12 707361 510.6 ns/op 216 B/op 5 allocs/op
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/cors 8.971s
|
||||
137
reports/corsBrowser.json
Normal file
137
reports/corsBrowser.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"browser": "Codex In-app Browser",
|
||||
"passed": true,
|
||||
"allowed": {
|
||||
"allowedPage": true,
|
||||
"passed": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "simple GET",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "hello"
|
||||
},
|
||||
{
|
||||
"name": "401 readable",
|
||||
"pass": true,
|
||||
"status": 401,
|
||||
"body": "{\"code\":\"LOGIN_REQUIRED\",\"message\":\"login required\",\"requestId\":\"8e1c6e6f5b5188f2409cc4ea8ef00b31\"}\n"
|
||||
},
|
||||
{
|
||||
"name": "authorization preflight",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "authenticated"
|
||||
},
|
||||
{
|
||||
"name": "unknown header rejected",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "unknown method rejected",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "404 readable",
|
||||
"pass": true,
|
||||
"status": 404,
|
||||
"body": "{\"code\":\"NOT_FOUND\",\"message\":\"route not found\",\"requestId\":\"6a0acbdabaa5e41d862f921dc2f9fdf3\"}\n"
|
||||
},
|
||||
{
|
||||
"name": "credentials mode allowed",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "hello"
|
||||
},
|
||||
{
|
||||
"name": "wildcard without credentials",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "public"
|
||||
},
|
||||
{
|
||||
"name": "wildcard credentials blocked",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "exposed download header",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "file bytes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"denied": {
|
||||
"allowedPage": false,
|
||||
"passed": true,
|
||||
"checks": [
|
||||
{
|
||||
"name": "simple GET",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "401 readable",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "authorization preflight",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "unknown header rejected",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "unknown method rejected",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "404 readable",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "credentials mode allowed",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "wildcard without credentials",
|
||||
"pass": true,
|
||||
"status": 200,
|
||||
"body": "public"
|
||||
},
|
||||
{
|
||||
"name": "wildcard credentials blocked",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
},
|
||||
{
|
||||
"name": "exposed download header",
|
||||
"pass": true,
|
||||
"blocked": true,
|
||||
"error": "TypeError"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scope": "20 loopback checks; credentials mode exercised without setting or inspecting cookies"
|
||||
}
|
||||
205
reports/corsRemote.json
Normal file
205
reports/corsRemote.json
Normal file
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.24.0-preview",
|
||||
"frameworkSum": "h1:tHQSk+XawxJLWnN60so45GoA9An0m1xw5GFU8Ds1NoQ=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 66.78,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"freshModuleCache",
|
||||
"fixedVersionNoReplace",
|
||||
"contractChangePreview",
|
||||
"persistentLogicSignatureFailure",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"rootGoRun",
|
||||
"nethttpAndNativeSignals",
|
||||
"listenerReleased",
|
||||
"default404And405",
|
||||
"goOwnedErrorCatalog",
|
||||
"unwrappedDownloads",
|
||||
"rawCallback",
|
||||
"multipart400And413",
|
||||
"nethttpAndNativeTransfers",
|
||||
"sharedGoCatalogExport",
|
||||
"documentOnlyErrorSelection",
|
||||
"workspaceCheckNoWrites",
|
||||
"staleCatalogRejected",
|
||||
"twoIndependentServiceBuilds",
|
||||
"initializationAndListenFailureCleanup",
|
||||
"drainBeforeDependencyClose",
|
||||
"generatedConsumerRaceTests",
|
||||
"errorParamsAndInheritance",
|
||||
"manualHooksAndScalars",
|
||||
"dependencyFatalShutdown",
|
||||
"fatalProcessExitCode",
|
||||
"initializationShutdownNotification",
|
||||
"configuredBuiltinEndpoints",
|
||||
"projectHealthEnvelope",
|
||||
"endpointConflictCleanup",
|
||||
"responseArrayNullableNull",
|
||||
"primitiveAndDynamicArrays",
|
||||
"strictRequestPolicies",
|
||||
"declarativeMultipartBinding",
|
||||
"backgroundStopDrainResourceOrder",
|
||||
"backgroundInitAndListenFailureCleanup",
|
||||
"backgroundTimeoutRetainsResourcesAndExits",
|
||||
"remoteHTTPClientRaceTests",
|
||||
"remoteHTTPProxyAndTimeouts",
|
||||
"remoteHTTPHeaderIsolation",
|
||||
"remoteHTTPPoolAndDrainBudgets",
|
||||
"remoteHTTPExample",
|
||||
"remoteExplicitConfigurationRaceTests",
|
||||
"literalAndRequiredDefaultReferences",
|
||||
"remoteCheckConfigBeforeDependencies",
|
||||
"removedImplicitEnvironmentRules",
|
||||
"remotePreciseModuleDiagnostics",
|
||||
"remoteJSONSuccessStatuses",
|
||||
"remoteRejectedStatusBodyCleanup",
|
||||
"remoteCopyTypePreflight",
|
||||
"remoteEmptyCollectionTypeRejection",
|
||||
"remoteLoggerTimeZoneIsolation",
|
||||
"remoteCORSPolicyRace",
|
||||
"remoteCORSErrorResponses",
|
||||
"remoteCORSTransports",
|
||||
"generatedCORSConfigOnly",
|
||||
"flatFieldComposition",
|
||||
"copyxThreeEndpointConsumer",
|
||||
"explicitNullAndMoneyPolicies",
|
||||
"fieldAdditionWithoutCopyRuleChanges",
|
||||
"middlewareFactoryInjectionAndCleanup",
|
||||
"optionalRepositoryPreservesBusiness",
|
||||
"generatedUnifiedConfig",
|
||||
"jsonYamlXmlAndCustomDecoder",
|
||||
"configValidationBeforeDependencies",
|
||||
"configEnvProvenance",
|
||||
"configExtensionPreserved",
|
||||
"configLoaderDriftCheck"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
|
||||
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||
"cmd/go-omni/apigen/templates.go": "17b3c76390eb965a580f595ca1de0e98b584a13c729d5e2726baa8a94e24b977",
|
||||
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
|
||||
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
|
||||
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||
"cmd/go-omni/apigen/generate.go": "54bb2f746bec30068102a004db1f009f2bef214cb44053b11ae9d99d0dcd09fd",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "528bd1a9194389d469cd8ace2206366184626a84d626fc935914aa2d75088c71",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"scripts/verifyManagedRelease.py": "825b02631bd9186bb9f8f85b799ce5c84e2d19a64b6b80f35c47cb11343afd12",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
|
||||
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
|
||||
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
|
||||
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
|
||||
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
|
||||
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
|
||||
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
|
||||
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
|
||||
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
|
||||
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
|
||||
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
|
||||
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
|
||||
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
|
||||
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
|
||||
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
|
||||
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
|
||||
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
|
||||
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
|
||||
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
|
||||
"config/config.go": "70c8db3b7d5ee5e92b2086a5a18db671e5a3b34ccba19c56514422c11c461c38",
|
||||
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
|
||||
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
|
||||
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
|
||||
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
|
||||
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
|
||||
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
|
||||
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
|
||||
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
|
||||
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
|
||||
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
|
||||
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
|
||||
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
|
||||
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
|
||||
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
|
||||
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
|
||||
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
|
||||
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
|
||||
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
|
||||
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
|
||||
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
|
||||
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
|
||||
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
|
||||
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
|
||||
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
|
||||
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
|
||||
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
|
||||
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
|
||||
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
|
||||
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
|
||||
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
|
||||
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
|
||||
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
|
||||
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
|
||||
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
|
||||
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
|
||||
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
|
||||
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
|
||||
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
|
||||
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
|
||||
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
|
||||
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
|
||||
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
|
||||
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
|
||||
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
|
||||
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
|
||||
"database/database.go": "0376fc8ac461571fc028e144a00b1e324ec4f3bb1f6fa1bcf573b86dd32c7167",
|
||||
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
|
||||
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
|
||||
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440",
|
||||
"cors/cors.go": "a0feb191b5ecc43b0a0c889af5c1ec960fdc55bfa813aff49413008077e64f70",
|
||||
"cors/config.go": "973300087dee87fd35bb56a2c40b6ace75f85b08e6749cc509421aab9135e2e4",
|
||||
"cors/cors_test.go": "49b20eb72ba4c40b949a711fba0bdcd342d793a8a010771204e696c563b3e08c",
|
||||
"app/cors_test.go": "7dcfcbb9c605a16bbd5455177a1f527dbb18f673e6ebb2b93d7b874c64c5d56a",
|
||||
"cmd/go-omni/apigen/corsConsumer_test.go": "8b5ce07474f50d89ba1d7d155b433e7103785b350db13df8cacd527b7070e240",
|
||||
"cmd/go-omni/apigen/testdata/cors/consumer_test.go": "677c559b8c6fc77aa6d8a69c0c1296f1fbefaa7f2f64cf80609dbe4027e254ab",
|
||||
"examples/cors/main.go": "39d76c4e2a4929f18f2acbfe03ab10c50b7276e56345de4d12eb962b5f6cc5b7",
|
||||
"examples/cors/config.yaml": "a78f83433810cb385ac9a8debbeb6932ebef71d85df073afbfc1d928fb7bf89f",
|
||||
"scripts/corsBrowser/main.go": "2654778e9c32829f6dadefe572551f9feb891da110741a08bf9243c7284c3ba2"
|
||||
}
|
||||
}
|
||||
@@ -58,3 +58,14 @@ go run ./examples/developerTools
|
||||
- 成功状态选项只接受 2xx 子集,不改变 HTTP 重试策略;业务响应内容是否完整仍由项目协议校验。
|
||||
- 时区数据由运行环境提供;极简镜像可用 `go build -tags timetzdata .` 内置。logx 不强制引入约 450 KB 的时区数据库。
|
||||
- 本轮未修改接入项目业务代码,未连接业务数据库或使用远程服务器压测。仍需项目升级后的业务验收。
|
||||
|
||||
## 远程版本验收
|
||||
|
||||
`v0.23.0-preview` 标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,空 module 缓存安装、固定版本消费者和 checksum 核对通过,无本地 replace。验收耗时 57.06 秒,共 66 项检查。
|
||||
|
||||
- 下载模块里的 config/httpc/copyx/logx 全包 race 通过,含本轮新增回归。
|
||||
- 8 组实际生成消费者使用远程框架执行 race,启动/监听失败清理、后台收尾和业务文件保留通过。
|
||||
- 根目录运行/构建、nethttp/native 信号与端口释放、404/405、上传下载、错误目录和两服务 workspace 通过。
|
||||
- 生成检查与 `-check-config` 使用同一配置入口,无手写加载补丁。
|
||||
|
||||
完整版本、checksum、检查清单及源码哈希见 [developerToolsRemote.json](developerToolsRemote.json)。标签不随验收文档提交移动。
|
||||
|
||||
191
reports/developerToolsRemote.json
Normal file
191
reports/developerToolsRemote.json
Normal file
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.23.0-preview",
|
||||
"frameworkSum": "h1:rbn2/Fxz6Yg+8LNvD+fwWtM/pigmNg0Mb9fAalntUgQ=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 57.06,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"freshModuleCache",
|
||||
"fixedVersionNoReplace",
|
||||
"contractChangePreview",
|
||||
"persistentLogicSignatureFailure",
|
||||
"businessSourcePreserved",
|
||||
"fixedConsumerBuild",
|
||||
"rootGoRun",
|
||||
"nethttpAndNativeSignals",
|
||||
"listenerReleased",
|
||||
"default404And405",
|
||||
"goOwnedErrorCatalog",
|
||||
"unwrappedDownloads",
|
||||
"rawCallback",
|
||||
"multipart400And413",
|
||||
"nethttpAndNativeTransfers",
|
||||
"sharedGoCatalogExport",
|
||||
"documentOnlyErrorSelection",
|
||||
"workspaceCheckNoWrites",
|
||||
"staleCatalogRejected",
|
||||
"twoIndependentServiceBuilds",
|
||||
"initializationAndListenFailureCleanup",
|
||||
"drainBeforeDependencyClose",
|
||||
"generatedConsumerRaceTests",
|
||||
"errorParamsAndInheritance",
|
||||
"manualHooksAndScalars",
|
||||
"dependencyFatalShutdown",
|
||||
"fatalProcessExitCode",
|
||||
"initializationShutdownNotification",
|
||||
"configuredBuiltinEndpoints",
|
||||
"projectHealthEnvelope",
|
||||
"endpointConflictCleanup",
|
||||
"responseArrayNullableNull",
|
||||
"primitiveAndDynamicArrays",
|
||||
"strictRequestPolicies",
|
||||
"declarativeMultipartBinding",
|
||||
"backgroundStopDrainResourceOrder",
|
||||
"backgroundInitAndListenFailureCleanup",
|
||||
"backgroundTimeoutRetainsResourcesAndExits",
|
||||
"remoteHTTPClientRaceTests",
|
||||
"remoteHTTPProxyAndTimeouts",
|
||||
"remoteHTTPHeaderIsolation",
|
||||
"remoteHTTPPoolAndDrainBudgets",
|
||||
"remoteHTTPExample",
|
||||
"remoteExplicitConfigurationRaceTests",
|
||||
"literalAndRequiredDefaultReferences",
|
||||
"remoteCheckConfigBeforeDependencies",
|
||||
"removedImplicitEnvironmentRules",
|
||||
"remotePreciseModuleDiagnostics",
|
||||
"remoteJSONSuccessStatuses",
|
||||
"remoteRejectedStatusBodyCleanup",
|
||||
"remoteCopyTypePreflight",
|
||||
"remoteEmptyCollectionTypeRejection",
|
||||
"remoteLoggerTimeZoneIsolation",
|
||||
"flatFieldComposition",
|
||||
"copyxThreeEndpointConsumer",
|
||||
"explicitNullAndMoneyPolicies",
|
||||
"fieldAdditionWithoutCopyRuleChanges",
|
||||
"middlewareFactoryInjectionAndCleanup",
|
||||
"optionalRepositoryPreservesBusiness",
|
||||
"generatedUnifiedConfig",
|
||||
"jsonYamlXmlAndCustomDecoder",
|
||||
"configValidationBeforeDependencies",
|
||||
"configEnvProvenance",
|
||||
"configExtensionPreserved",
|
||||
"configLoaderDriftCheck"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||
"config/lifecycle_test.go": "fe31c371acc3b2df2f58ecfb7b6a0f29d444410f43c0d49bad6cbf72a1a87248",
|
||||
"task/task.go": "acb1a2cc0e26029331065545582c30e0c547e3b6869f7d8b16c7d25ab0077545",
|
||||
"cmd/go-omni/scaffold/templates/api-runtime.go.tmpl": "e8575c04b0923d924d96e4334731313076284fd068c4990715b7a07a2724f969",
|
||||
"cmd/go-omni/apigen/templates.go": "d048178fd125d2244c9deccc744923412b8e8c21a890c5fa57bd6b0f632fcde9",
|
||||
"config/project.go": "169e13921afa6a9336fcde3310675580799335ce55f076cbe60cdf3cfbd8cac2",
|
||||
"contract/bind.go": "7590c3100a9bf90236aba7fda0a021d065d9dacd8c432c8eed1066f2ed8d37c8",
|
||||
"contract/policy.go": "2c8e7efb07d59fea2c00ab39694d4df52a5bea6495ce23cac1c470584af74da1",
|
||||
"contract/multipart.go": "db0377599b1b8b39246d41129513e8187109e44c1892971244d581c22ccc8ecd",
|
||||
"contract/file.go": "20dd2de17e4854d452afc3c65f0293d504fb8fc25e91583ef45207fdab30948d",
|
||||
"cmd/go-omni/apigen/business_test.go": "3b484352282d945f5a58047682079e3d64d4b0bf6ec6bafc23151b1197bb3942",
|
||||
"cmd/go-omni/apigen/generate.go": "c04e1c82d054139f7e21d286b9dd5a1f537ca39e2eaf240135e1dc23b8d18652",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "2cf78b8f0eee8049ee612bb409a6eecc1d39eab9ab8f3b533ea21e3cac1611cb",
|
||||
"app/app.go": "e5255ffdd36cba1c155037afc42f1c921b1f91bc79db75798a7feb0e9d51b1c0",
|
||||
"app/transport.go": "88212d67d375e4c48c250a2e4c62fbff4e7873a67f91f2deb113a60634149f73",
|
||||
"scripts/verifyManagedRelease.py": "41ebe143c06905ca1ec9736cfddee14d1b03fa69f613a098a1996f669de6f3d7",
|
||||
"scripts/verifyManagedProject.py": "b0e4b1aea4f44d61470c35c41f971e0882ae36b0eb5df2be190400d62fc60a18",
|
||||
"cmd/go-omni/apigen/managed_test.go": "e0b2b5aa6c4d6383590b467d57d7b4e00505ca35d6b6cde2475e4ad574041e94",
|
||||
"cmd/go-omni/apigen/testdata/managed/options.go": "30d48e4b5aab476c887ac53cc89b48b2b7e250c8cd25e55fecb49bb2a3589247",
|
||||
"cmd/go-omni/apigen/testdata/managed/background.go": "fb357b5deefd5187f2df406be5189fc4d57da857bd55b474f4726bf065c77167",
|
||||
"cmd/go-omni/apigen/testdata/managed/client.go": "cdd7d394ddad4bcdc10224890a884aa513ed2ffd8275ecdad8de9963dd10986a",
|
||||
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
|
||||
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "79fbf8dd4508b8217af888a31aaf2f2a01c1fa3869b2e80420605af697c66319",
|
||||
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
|
||||
"cmd/go-omni/apigen/testdata/managed/operations.go": "44cea63e6023458c5818c9fdae223631cd1721f0841e671457398bfde8bc48f1",
|
||||
"cmd/go-omni/apigen/testdata/business/accessFactory.go": "2f982cf39b5e1a4be98596ce5d0a296af0fbf8c94616543a268e67ec376f31b4",
|
||||
"cmd/go-omni/apigen/testdata/business/client.go": "d4cbaeec18dfb3a0e2821217e8eb0515000e351a745a93ec811dabca44e58c8a",
|
||||
"cmd/go-omni/apigen/testdata/business/consumer_test.go": "e4159862a729ca2c5c9699840cc16a56350fc2914c43841f08946e9a6e9c8091",
|
||||
"cmd/go-omni/apigen/testdata/business/operations.go": "11ded7921a0753f22975c525e334e3d45366143625e41d24fe500504e785c64c",
|
||||
"cmd/go-omni/apigen/responseShape.go": "5b705130b66a3d4aaed842025b75abe54e6427acac8366366ca88ae9862429e1",
|
||||
"cmd/go-omni/apigen/documentRefs.go": "7b841a668154d872493104eda0f9049340c67d9323eb99f1621a1ef6ba343891",
|
||||
"cmd/go-omni/apigen/catalogDocument.go": "94df60328b2f105c35b73d9c6362ea14abadce6cb18e4d9921193d2d46854252",
|
||||
"cmd/go-omni/apigen/workspaceCatalog.go": "c00784220b379ed80deb07c060532b756cbc47434ecb8b1ca10bc12b12159328",
|
||||
"config/references.go": "ea709ddb32a9c5601662a875765d376b8490c4c766ecdb4365f462b5a2b10e16",
|
||||
"cmd/go-omni/apigen/arrayResponse_test.go": "9e3e4475f76683d3346d349cdfa94e9e75cf913ac6979bd7a4ed86628466d879",
|
||||
"cmd/go-omni/apigen/testdata/arrays/values.go": "d5e7f3adf12aabb301f46c296e8c894a7b2f6f70a6b6e7e02c74da7f1fd17bc5",
|
||||
"cmd/go-omni/apigen/testdata/arrays/consumer_test.go": "435932f2952ad897943e2cda83b04eccb2950579d8d88772661541e1e2a2333d",
|
||||
"cmd/go-omni/apigen/composition.go": "7ed3761b27d9c932d8ddc43e63c2aca798bdbe38f2b40571af8916c8401ea650",
|
||||
"cmd/go-omni/apigen/generatorConfig.go": "78db0c7808cf6ec997c5b64a4a5fbea11b74049709bc5900ccfbc5d8dabebbc8",
|
||||
"cmd/go-omni/apigen/copyConsumer_test.go": "e08ae4daed5dd25ab89d8872f653caf19499984dcaddc9835c13884c410fc0c3",
|
||||
"cmd/go-omni/apigen/generatorConfig_test.go": "a6d8c8ebea5f1accf0e37dd044e57b48e422821fe75086d8d2eb3abe3acb147c",
|
||||
"cmd/go-omni/apigen/middlewareFactory.go": "cc0220edb3eff25d7f66b2c37e206455f8730a2b909613713322bee7caf72303",
|
||||
"cmd/go-omni/apigen/testdata/composition/accessFactory.go": "e1e5057ff79f806b3218e93249e8191d097b5c4844977204a8c32848443cba33",
|
||||
"cmd/go-omni/apigen/testdata/composition/domain.go": "ab27ebd7ad5e3ed5505a2aa268b9b5ceedbb01477b59011bec63bb270114078b",
|
||||
"cmd/go-omni/apigen/testdata/composition/amount.go": "f2d1834502d71d9ecf72ebe3b1077e5ef41f44fd816abd4617ac19f9bb962fdd",
|
||||
"cmd/go-omni/apigen/testdata/composition/copy.go": "32d37d6f1f0e9aa2409aeb90b052801d1cdca70e504c2dd294cdae01422c9cb7",
|
||||
"cmd/go-omni/apigen/testdata/composition/client.go": "19102e191c0471cae9e7405623906d0d804865c40b80ff1d5b240a41ceab6a4e",
|
||||
"cmd/go-omni/apigen/testdata/composition/service.api": "01d230d5409756e2f3eb602a7fe065efa7d9599f7a4089da4bf8f07a2474444e",
|
||||
"cmd/go-omni/apigen/testdata/composition/consumer_test.go": "74aaf60318cd0de67495fc9142b3f67c3b6e232bb48b46b3e134de5aadd225ad",
|
||||
"cmd/go-omni/apigen/testdata/composition/dependencies.go": "edc153204b06da00c0e45c52db43a4a22f8ffe22c4a6d3f3ebec0137b861f84d",
|
||||
"cmd/go-omni/apigen/testdata/composition/converters.go": "ecdcfe31c75ca815dab8d1a82533b12cbbd40e12b3a5c444e815097f6c4df08e",
|
||||
"copyx/check_test.go": "4095de3e3ba946573edb0b2baad5b492eb559565745693e144ca008eb740e1a2",
|
||||
"copyx/check.go": "aa53985a2d8b73a18c51d112552bfecd06b60079cd119ebeed5ef4a5d3b0db22",
|
||||
"copyx/options.go": "edea2f3d2043bf813e56dedf0aea319aaa52301d3b589f41fef986e33a77dc32",
|
||||
"copyx/copy.go": "179a5a1be871813765074d945aa341945d2a4a9a42928e2b30e9839f9795aa9a",
|
||||
"copyx/graph.go": "ae6a2a11a8175033d327b580cd570c97fe9d2ddb814bb50884cc19a90766badd",
|
||||
"copyx/benchmark_test.go": "b1bf408957cadea47b47215ffb099d140d5d1a2eb061024a6d0c1d769f469a79",
|
||||
"copyx/copy_test.go": "89b65a6281591d7cd5823cc1c7d2c384c6bd446ef06aa1763ddc3acaa84dfb4a",
|
||||
"config/decoders.go": "6d31f67c82563c2d63e37ad1ce9b9fbad6232b2b785b9b58dd025be857f0fc28",
|
||||
"config/config.go": "e51ece1fa824d8b0fc7385e027b4a4408e459ec2c89444fab99e1e6a19e8c1cf",
|
||||
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
|
||||
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
|
||||
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||
"config/response_test.go": "b4ccb111f813f80dc180a7ddb11034a409b9409cbb1b57e654c04d678465d201",
|
||||
"config/projectAdvanced_test.go": "48b5493fe364e1022d63f1f2b556e2e1554b14af8f994074ec9b048fc6108fa4",
|
||||
"config/file_test.go": "019ad4356838a592826daaf3bdcc7706b74a0d7782abcbe9bd6aba161aaa6576",
|
||||
"config/moduleDiagnostics_test.go": "68b9bba485617e3cde57773b8c24cd9554636f6364151bbfcad48548408c83b4",
|
||||
"config/references_test.go": "e19f0a42bbe08d1a723aff63e2d77d22feba08da98ecf5d47aa675e7105da850",
|
||||
"config/errorParams_test.go": "fe61921a2e5fbdf32a623299fec42799287b0af1ab19b2f8fdd4bc97583e582d",
|
||||
"config/http.go": "6fbeac9c21f5dedad05471deb03a6b4465a0cb10f252d651da2f6f6cb3841998",
|
||||
"config/loader.go": "50a2347960cc98f337b7a179e3054af0bfd22db879b58bee4ffd09e3f693d051",
|
||||
"config/file.go": "2fbf4cd55d13944d7a7a208696f4a845a2089058d5f6e463c1e769e56bf77404",
|
||||
"config/diagnostic.go": "cf818cd90941a5c2d9a6279dc4c3b90d71495db22846625fdf769dadefcf077a",
|
||||
"config/bind.go": "0eabe1534eb671f13c938021183a1f3df829283d6fb587b849d22253dda278b2",
|
||||
"config/clone.go": "6146bf8a7d7549d975142a843db5c6f95c67e98b62cbd98e20c77842abd38040",
|
||||
"config/endpoints_test.go": "3368cd6af3e5902a1a4f7ea9f67e2da9e3d525f5c63f7e78aaeaae8fdfbf8394",
|
||||
"config/config_test.go": "112202bdd153e9e87a43629b842e02caaad54e9516e1f7cd18c301f74cb69cc7",
|
||||
"config/loader_test.go": "854de1586e64ac00acd7970be929af8a1b17d9a027bf92544f6221ccef38ceb9",
|
||||
"cmd/go-omni/apigen/configLoader_test.go": "75c47861be382558434883d25c8deb1a567bd305290db624e572d2dcf302afbf",
|
||||
"cmd/go-omni/apigen/testdata/configuration/consumer_test.go": "0cf2d8b6fabf8dc4652d33cadb5a462e6f3a527463d8965fcb54123ae6cb6cc4",
|
||||
"cmd/go-omni/scaffold/templates/api-load.go.tmpl": "c8089dfa50ea09de60a18f06463e83e8f0fa9fbb6795fd615e37822d7b2ab708",
|
||||
"cmd/go-omni/scaffold/templates/api-loader.go.tmpl": "847ac407827808022dcf493823b58671e1ae50349b173a5e4500580e07c64150",
|
||||
"httpc/config.go": "08241015b758648b0eb793e1856b88898ec57d8021c8e542db7d1dd6a2dcf2e6",
|
||||
"httpc/request_test.go": "ae410eaa2cac54bbc15515a524387dde7bd7aa61a8fcc8e820108cbcc85c7b98",
|
||||
"httpc/request.go": "43c1a4611331c8ac81eab98d5782fe8d4d271c14daa219e8a2134e029b65064d",
|
||||
"httpc/status_test.go": "4f71d3f4aede466c02f978c5b4a4d3ffc83edee5916ce8d0505d01878ddef2a5",
|
||||
"httpc/client.go": "a5a99b70bd1b1828a260bb41a6d9dc42d85f18a6b33ec822315374871d6af959",
|
||||
"httpc/benchmark_test.go": "3ac0ccfb227b00fcbca42f89686fb67e3e00f4ed23ac5dfa686663ae18843a07",
|
||||
"httpc/config_test.go": "84bc068fdc7964ae16c133374ecd6e07278253e33cea132ebcafc681378d7559",
|
||||
"httpc/client_test.go": "972ebd2efab618629e394484c288afe4e675fcfe0c50121d9fa9071680d6e763",
|
||||
"httpc/json.go": "e4a3caf19f24c3f58494311cd887dbb00acbdb4d9efc1f36988e19cb0f9abe7c",
|
||||
"examples/httpClient/main.go": "8572f68d1bdbe9e3ce36b6f53d6ac7ef98966e0ac596ba57bb0811314a832eb5",
|
||||
"examples/httpClient/main_test.go": "32f5513b2a2702214882c0163e0686548d8eb527977cde23829b54175d95010e",
|
||||
"logx/logx_test.go": "0f99eff445decdd81a55b3d5374c22d2e038d2a4748adb2c9367d6d03e481671",
|
||||
"logx/redaction.go": "e288c01cb0e30a79855b9e1b886a802c5614298aba5f39b2619eafb1f187f9ae",
|
||||
"logx/adapters_test.go": "f0c57ca90075c82f9552ec43e58ab0613b43dea89411d9db50ba5fcb5be3dddf",
|
||||
"logx/production_test.go": "5a6f957d409d657a1e712e72aab3e3275b2ba21dc81dd3be69523c90d47cc458",
|
||||
"logx/timeZone_test.go": "523a864a1d55e32a770b9816634d6445ad468cab2e7d8d86588b1c428ec04824",
|
||||
"logx/adapters.go": "5b558685e88cb37889238f73e9670d4db2b509fd39d512ddfca25de1b5f4aea8",
|
||||
"logx/logx.go": "18dd28f8378e403773e238d1c8ce4acd05838d2863475ab7a5e278f5ba7cb6fb",
|
||||
"logx/timeZone.go": "1b82737362f880f4a8461fd20637ef64eef5a5db475a49e0c9daa27a6ca7b636",
|
||||
"logx/rotation.go": "e278c5ffbdba36c39b15c8b31299abe402695edaf525eb5b03d4328cf35fce61",
|
||||
"internal/configdiag/error.go": "a25f9f398cd0e5ea1a0d049c8a7c264c85be67c469619b41220cf56afffdbda6",
|
||||
"database/mysql/mysql.go": "e5f95b97dd79eb64d493255d2fd2f5895538131021eb8a6068de8ff6f484702c",
|
||||
"database/connector.go": "5a973ea4bc53fee2b72f1925f5cf4e3d83a3c935a1455406f4a7abd46c47c007",
|
||||
"database/database.go": "0376fc8ac461571fc028e144a00b1e324ec4f3bb1f6fa1bcf573b86dd32c7167",
|
||||
"examples/developerTools/config.yaml": "1e672ba1b0b16a417840a09468f785776c51408ee973b55b1910cb736a8e29d9",
|
||||
"examples/developerTools/README.md": "3ba0d700dc3e3788a2a57367273b8d6b0f2acbf2f6cf38149edeef84ce898908",
|
||||
"examples/developerTools/main.go": "a799d3106b4a64258b7c1cbad9c776cab840526795416b07b114628e3bd4c440"
|
||||
}
|
||||
}
|
||||
63
reports/transactionAcceptance.md
Normal file
63
reports/transactionAcceptance.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# v0.25 事务错误验收(2026-09-26)
|
||||
|
||||
目标是让业务可靠地区分错误阶段和框架实际掌握的事务结果。保持 InTx 签名,保留原始/回滚错误链;不引入业务补偿、文件删除或事务重试。
|
||||
|
||||
## 回归
|
||||
|
||||
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./...`:49 个有测试的包通过,包含既有生成消费者、响应目录、生命周期、上传下载、配置等回归。
|
||||
- `GOTOOLCHAIN=go1.22.12 go test ./database ./errors ./app`:通过。
|
||||
- `go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./database -run '^TestInTxCancellation' -count=20`:通过,包含 20 × 128 次取消与回滚竞争检查。
|
||||
- `go run ./examples/transactions`:六种结果通过,包括“已保存但提交报错”时不清理文件的演示决策;示例没有真实文件删除。
|
||||
|
||||
| 场景 | 验证结果 |
|
||||
| --- | --- |
|
||||
| 开启失败 | callback 不执行,StageBegin/NotStarted,原始驱动或 context 原因可 Is/As;nil DB、nil callback 在开启前报错 |
|
||||
| 成功事务 | callback/Commit 各一次,返回 nil,不再调用显式 Rollback;context、隔离级别、只读选项传递保持 |
|
||||
| 回调失败 | 显式回滚 nil 才返回 Work/RolledBack;原始业务原因可 Is/As;回滚报错为 Unknown 且两条原因完整保留 |
|
||||
| 提交失败 | 同样的驱动错误分别模拟已保存/未保存,都返回 Commit/Unknown;超时、ErrBadConn 也不当成回滚;不重试 Commit/callback |
|
||||
| 取消 | 开启前取消、回调取消、自动回滚完成、取消与显式回滚竞争、提交过程中取消;ErrTxDone 从不表示回滚成功 |
|
||||
| 原因链 | 外层再次 `%w` 包装后 As/Is 可用;主要 typed cause 优先,回滚 typed cause 可单独找到;嵌套 TxError 不压平 |
|
||||
| 异常与隔离 | panic 原值传播,Goexit 正常展开;都尝试一次回滚,不提交;64 个并发调用的原因/结果互不串扰 |
|
||||
| HTTP 兼容 | 回调返回业务错误目录引用与参数,包装后仍输出项目 HTTP 200、数字业务码、提示与 data:null;回滚附加原因、提交内部原因不外泄 |
|
||||
|
||||
## 真实 MySQL
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyConnector.py --local --gorm --phases --report reports/transactionMySQL.json
|
||||
```
|
||||
|
||||
使用独立本地 MySQL 8.4 容器,1 CPU / 768 MiB / 128 pids,TLS 与临时随机凭据;不连接接入项目数据库或远程服务器。53 条通过记录(含子用例),耗时 13.2 秒。容器、数据卷、证书与临时消费者已经清理。完整证据见 [transactionMySQL.json](transactionMySQL.json)。
|
||||
|
||||
- 正常 Commit 后,另一个连接看到记录;回调失败且真实 Rollback 成功,返回 Work/RolledBack,另一个连接确认没有该记录。
|
||||
- 在真实驱动 Commit 成功返回后,由测试包装器注入“确认丢失”错误:另一个连接确认记录已经持久化,InTx 返回 Commit/Unknown,没有重试或误报回滚。
|
||||
- 在真实驱动 Rollback 成功后注入确认错误:库只能返回 Work/Unknown,并保留业务原因与回滚确认错误;不能利用测试已知的数据库状态推断生产结果。
|
||||
- 这是驱动返回边界的确定性故障注入,不等同于覆盖所有 TCP 断线时序。
|
||||
- 真实生成项目的配置/TLS/独立连接池/关闭、可选 GORM 事务与连接池归属、后台 drain 后关闭数据库均通过。
|
||||
- 本轮发现旧 MySQL 验收脚本仍依赖已移除的自动环境覆盖。现已改为测试配置文件显式引用变量,复测通过;没有恢复旧加载模式。
|
||||
|
||||
## 性能
|
||||
|
||||
Go 1.27.1、darwin/arm64、Apple M2 Max。使用相同演示驱动和 benchmark,比较 v0.24 的原 InTx 实现与当前实现,每场景三轮、300ms,取中位数。旧实现从提交 `8eddef9cc8e9902150f4f712a40bc761d4350415` 取出,临时 benchmark 文件测后删除。[原始结果](transactionBenchmark.txt)。
|
||||
|
||||
| 场景 | 原实现 ns/次 | 当前 ns/次 | 原分配 | 当前分配 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| 成功提交 | 617.3 | 587.0 | 392 B / 7 | 392 B / 7 |
|
||||
| 开启失败 | 212.9 | 146.0 | 96 B / 3 | 80 B / 2 |
|
||||
| 回调失败并成功回滚 | 556.5 | 540.9 | 280 B / 6 | 344 B / 7 |
|
||||
| 回调和回滚同时失败 | 681.1 | 732.6 | 416 B / 10 | 344 B / 7 |
|
||||
| 提交结果不确定 | 719.6 | 814.8 | 472 B / 9 | 456 B / 8 |
|
||||
|
||||
成功路径没有新增分配。原先直接返回的回调错误现在需要 TxError,增加 64 B / 1 次分配;其他失败路径移除了即时格式化/Join 的部分开销。部分失败路径耗时增加,以上是受 goroutine 调度影响的本机微基准,不含真实数据库网络、SQL 和磁盘耗时,不承诺固定提速比例或服务 QPS。
|
||||
|
||||
当前实现复现:
|
||||
|
||||
```sh
|
||||
go test ./database -run '^$' -bench '^BenchmarkInTx$' -benchmem -benchtime=300ms -count=3
|
||||
```
|
||||
|
||||
## 保证范围
|
||||
|
||||
Outcome 只属于当前受管理事务;回调不得自己提交/回滚、执行隐式提交 SQL、把事务交给其他 goroutine。其他连接、独立事务、非事务表、文件和第三方系统的副作用由业务负责。
|
||||
|
||||
标准库异步回滚不公开的驱动原因无法还原。只拿到 ErrTxDone 时保守返回 Unknown;直接观察到的回滚失败全部保留。panic/Goexit 保持原有传播,没有普通返回值可携带回滚原因。返回错误的文本/具体最外层类型有变化,调用方使用 errors.Is/As;GORM 自己的 Transaction 不属于本次接口。
|
||||
36
reports/transactionBenchmark.txt
Normal file
36
reports/transactionBenchmark.txt
Normal file
@@ -0,0 +1,36 @@
|
||||
goos: darwin
|
||||
goarch: arm64
|
||||
pkg: git.yuntuo.hk/k2safe/go-omni/database
|
||||
cpu: Apple M2 Max
|
||||
BenchmarkLegacyInTx/success-12 623776 617.3 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkLegacyInTx/success-12 715071 627.2 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkLegacyInTx/success-12 686116 605.4 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkLegacyInTx/beginFailure-12 1700702 213.6 ns/op 96 B/op 3 allocs/op
|
||||
BenchmarkLegacyInTx/beginFailure-12 1692045 211.5 ns/op 96 B/op 3 allocs/op
|
||||
BenchmarkLegacyInTx/beginFailure-12 1698073 212.9 ns/op 96 B/op 3 allocs/op
|
||||
BenchmarkLegacyInTx/workFailure-12 787486 544.8 ns/op 280 B/op 6 allocs/op
|
||||
BenchmarkLegacyInTx/workFailure-12 724378 557.3 ns/op 280 B/op 6 allocs/op
|
||||
BenchmarkLegacyInTx/workFailure-12 780825 556.5 ns/op 280 B/op 6 allocs/op
|
||||
BenchmarkLegacyInTx/rollbackFailure-12 598321 681.7 ns/op 416 B/op 10 allocs/op
|
||||
BenchmarkLegacyInTx/rollbackFailure-12 555447 665.4 ns/op 416 B/op 10 allocs/op
|
||||
BenchmarkLegacyInTx/rollbackFailure-12 516342 681.1 ns/op 416 B/op 10 allocs/op
|
||||
BenchmarkLegacyInTx/commitUnknown-12 485095 734.8 ns/op 472 B/op 9 allocs/op
|
||||
BenchmarkLegacyInTx/commitUnknown-12 549530 716.2 ns/op 472 B/op 9 allocs/op
|
||||
BenchmarkLegacyInTx/commitUnknown-12 498938 719.6 ns/op 472 B/op 9 allocs/op
|
||||
BenchmarkInTx/success-12 613156 597.3 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkInTx/success-12 592444 584.3 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkInTx/success-12 720130 587.0 ns/op 392 B/op 7 allocs/op
|
||||
BenchmarkInTx/beginFailure-12 2452880 147.0 ns/op 80 B/op 2 allocs/op
|
||||
BenchmarkInTx/beginFailure-12 2506814 146.0 ns/op 80 B/op 2 allocs/op
|
||||
BenchmarkInTx/beginFailure-12 2485419 145.2 ns/op 80 B/op 2 allocs/op
|
||||
BenchmarkInTx/workFailure-12 748887 538.1 ns/op 344 B/op 7 allocs/op
|
||||
BenchmarkInTx/workFailure-12 753242 540.9 ns/op 344 B/op 7 allocs/op
|
||||
BenchmarkInTx/workFailure-12 727633 592.8 ns/op 344 B/op 7 allocs/op
|
||||
BenchmarkInTx/rollbackFailure-12 538176 732.6 ns/op 345 B/op 7 allocs/op
|
||||
BenchmarkInTx/rollbackFailure-12 669040 740.1 ns/op 344 B/op 7 allocs/op
|
||||
BenchmarkInTx/rollbackFailure-12 629571 696.0 ns/op 344 B/op 7 allocs/op
|
||||
BenchmarkInTx/commitUnknown-12 650611 804.9 ns/op 456 B/op 8 allocs/op
|
||||
BenchmarkInTx/commitUnknown-12 434694 1015 ns/op 456 B/op 8 allocs/op
|
||||
BenchmarkInTx/commitUnknown-12 663369 814.8 ns/op 456 B/op 8 allocs/op
|
||||
PASS
|
||||
ok git.yuntuo.hk/k2safe/go-omni/database 14.818s
|
||||
90
reports/transactionMySQL.json
Normal file
90
reports/transactionMySQL.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.25.0-preview",
|
||||
"localSource": true,
|
||||
"gormAdapter": true,
|
||||
"phasedLifecycle": true,
|
||||
"frameworkSum": null,
|
||||
"freshModuleCache": false,
|
||||
"noLocalReplace": false,
|
||||
"mysqlImage": "sha256:1afd25fe334a1be64a3866495dd43a6bdba3c55dd1d6190ea12933163fe2843c",
|
||||
"mysqlLimits": {
|
||||
"cpus": 1,
|
||||
"memoryMiB": 768,
|
||||
"pids": 128
|
||||
},
|
||||
"durationSeconds": 13.2,
|
||||
"tests": [
|
||||
"TestConnectorIndependentPoolsAndContext",
|
||||
"TestConnectorRejectsInvalidInputWithoutTakingOwnership",
|
||||
"TestConnectorStartupFailuresReleaseResources/connect",
|
||||
"TestConnectorStartupFailuresReleaseResources/ping",
|
||||
"TestConnectorStartupFailuresReleaseResources",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/connect",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing/ping",
|
||||
"TestConnectorTimeoutBoundsConnectAndPing",
|
||||
"TestConnectorHealthBoundsPoolWaitAndRecovers",
|
||||
"TestConnectorIdleZeroAndLifetime/noIdle",
|
||||
"TestConnectorIdleZeroAndLifetime/expired",
|
||||
"TestConnectorIdleZeroAndLifetime",
|
||||
"TestConnectorTransactionsKeepCommitAndRollbackSemantics",
|
||||
"TestMySQLConnectorDriverLoggerIsolation",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/untrustedCA",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/wrongHostname",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/commitAcknowledgementLost",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation/rollbackAcknowledgementLost",
|
||||
"TestMySQLConnectorTLSAndPoolIsolation",
|
||||
"TestHTTPFileAndEnvironment/server.json",
|
||||
"TestHTTPFileAndEnvironment/server.yaml",
|
||||
"TestHTTPFileAndEnvironment",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_HEADER_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/READ_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/WRITE_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUTinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/IDLE_TIMEOUT-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTESinvalid",
|
||||
"TestHTTPInvalidAndUnsupportedSettings/MAX_HEADER_BYTES-1",
|
||||
"TestHTTPInvalidAndUnsupportedSettings",
|
||||
"TestHTTPConfiguredStandardServer",
|
||||
"TestHTTPInvalidStartupReleasesResources",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/writeBackpressure",
|
||||
"TestGORMMySQLTransactionsCancellationAndLogs",
|
||||
"TestStructuredMySQLAdapterTLS",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/nethttp",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowHeaders",
|
||||
"TestHTTPConfiguredNetworkLimits/native/slowBody",
|
||||
"TestHTTPConfiguredNetworkLimits/native/idle",
|
||||
"TestHTTPConfiguredNetworkLimits/native/writeBackpressure",
|
||||
"TestHTTPConfiguredNetworkLimits/native/headerLimit",
|
||||
"TestHTTPConfiguredNetworkLimits/native",
|
||||
"TestHTTPConfiguredNetworkLimits",
|
||||
"TestGeneratedOptionalGORM",
|
||||
"TestGeneratedMySQLConfigurationAndLifecycle",
|
||||
"TestGeneratedMySQLBackgroundDrain"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"database/connector_test.go": "0663dfc9794ba24130225fa3b6418a6d5f75d57d0f01686151f70c7ea3f592c0",
|
||||
"database/mysqlConnector_test.go": "db5a9f5a99fd46e8e84c4452663704172ddc26a57ad6af84d3165041ff5357e9",
|
||||
"app/httpConfig_test.go": "b060dc0b76fb8e5feebae7e3b12cb7bca845693fcc097e2a0496a5ba937c837a",
|
||||
"config/http_test.go": "6345fc06fb25f48f5cb26e464f525078408f9180da1c8135eda60969d9ca21d1",
|
||||
"database/mysql/integration_test.go": "05b0a73d50363eef760e8a3324a7e9a68c598f7519c6360ad530503178b56f43",
|
||||
"database/gormx/integration_test.go": "3a5ac66d5972efc9127c7f1cc473337f899a521b967d4ec1ce00a0dbe06188bb",
|
||||
"scripts/verifyConnector.py": "2b1b1851a53efde49a44642bc92296b4b4f40bf84e0666389ec763a35f63b48a",
|
||||
"scripts/testdata/mysqlGenerated_test.go": "419c83cf9d1c25e8791f92b697f8e74f55b0af76ac0bc41e1316ee87b6923711",
|
||||
"database/transaction.go": "037f14ed495e6d024e13e2033c2e469b138dc5c1e1d6f6a9a73e723e094e71e1",
|
||||
"scripts/testdata/phasedMySQL_test.go": "925ca9ce0036ce6d3833cc8be9e72f8a097c689b8a270051fa2b6471f2edb6bb",
|
||||
"lifecycle/runtime.go": "7d890150b931cdc6c5d03f3706ea09b1d220a958d91fbd6ddc6715a9ec13a5d2",
|
||||
"lifecycle/phases.go": "f76b9e046d725b5d7906e80a6f29dc247c5d453a64ac998c49d4fcffe0d84976",
|
||||
"scripts/testdata/gormGenerated_test.go": "6fc476ec8c089c63b032c8cc707a83c88e685ce8599a59ab63006bad05cbf251",
|
||||
"database/gormx/gorm.go": "512134d2c9aa7aa14b42071e837900ccf6b2fd8ed5550b686339c7b1605f14a2",
|
||||
"database/gormx/pool.go": "227fac41c2a2ef64f2e9fc2b8a7202ba6fc22c61fd231e015fe08f5b1d97001d",
|
||||
"database/gormx/logger.go": "e90a3d411e1b267d4abdf0ab795ef3d60ca4a443f7f9b1d51ccfe4cdae8aae4d"
|
||||
}
|
||||
}
|
||||
121
scripts/corsBrowser/main.go
Normal file
121
scripts/corsBrowser/main.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Run from the framework root; open the printed URLs and click Run checks.
|
||||
// All listeners are loopback-only and stop after five minutes or SIGINT/SIGTERM.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
func run() error {
|
||||
var listeners []net.Listener
|
||||
defer func() {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 4; i++ {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listeners = append(listeners, l)
|
||||
}
|
||||
url := func(i int) string { return "http://" + listeners[i].Addr().String() }
|
||||
app := omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{url(0)}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}, AllowCredentials: true}))
|
||||
defer app.Close()
|
||||
app.GET("/text", func(c *omni.Context) error { return c.Text(200, "hello") })
|
||||
app.GET("/private", func(c *omni.Context) error {
|
||||
if c.Header("Authorization") != "Bearer example-only" {
|
||||
return omni.Unauthorized("LOGIN_REQUIRED", "login required")
|
||||
}
|
||||
return c.Text(200, "authenticated")
|
||||
})
|
||||
app.GET("/download", func(c *omni.Context) error {
|
||||
c.SetResponseHeader("Content-Disposition", "attachment; filename=example.txt")
|
||||
return c.Text(200, "file bytes")
|
||||
})
|
||||
public := omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"*"}}))
|
||||
defer public.Close()
|
||||
public.GET("/text", func(c *omni.Context) error { return c.Text(200, "public") })
|
||||
page := func(allowed bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(map[string]any{"api": url(2), "publicApi": url(3), "allowed": allowed})
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
io.WriteString(w, strings.Replace(pageSource, "CONFIG_PLACEHOLDER", string(data), 1))
|
||||
})
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
||||
defer cancel()
|
||||
errors := make(chan error, 4)
|
||||
var servers []*http.Server
|
||||
for i, h := range []http.Handler{page(true), page(false), app, public} {
|
||||
s := &http.Server{Handler: h, ReadHeaderTimeout: 3 * time.Second, ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second}
|
||||
servers = append(servers, s)
|
||||
go func(l net.Listener) { errors <- s.Serve(l) }(listeners[i])
|
||||
}
|
||||
defer func() {
|
||||
for _, s := range servers {
|
||||
s.Close()
|
||||
}
|
||||
}()
|
||||
fmt.Printf("Allowed page: %s\nDenied page: %s\n", url(0), url(1))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-errors:
|
||||
if err != http.ErrServerClosed {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
const pageSource = `<!doctype html><meta charset="utf-8"><title>GoOmni CORS browser checks</title>
|
||||
<h1>GoOmni CORS browser checks</h1><p>Loopback-only, synthetic data; no accounts or credentials required.</p>
|
||||
<button id="run">Run checks</button><pre id="results">Not started</pre>
|
||||
<script>
|
||||
const cfg=CONFIG_PLACEHOLDER;
|
||||
document.querySelector('#run').onclick=async()=>{
|
||||
const results=[];const out=document.querySelector('#results');out.textContent='Running';
|
||||
async function check(name,path,options,expected,blocked=false,header=false){
|
||||
try{const r=await fetch(path,options);const body=await r.text();
|
||||
results.push({name,pass:!blocked&&r.status===expected&&(!header||r.headers.get('Content-Disposition')==='attachment; filename=example.txt'),status:r.status,body});
|
||||
}catch(e){results.push({name,pass:blocked&&e.name==='TypeError',blocked:true,error:e.name});}
|
||||
}
|
||||
await check('simple GET',cfg.api+'/text',{},200,!cfg.allowed);
|
||||
await check('401 readable',cfg.api+'/private',{},401,!cfg.allowed);
|
||||
await check('authorization preflight',cfg.api+'/private',{headers:{Authorization:'Bearer example-only'}},200,!cfg.allowed);
|
||||
await check('unknown header rejected',cfg.api+'/private',{headers:{'X-Not-Allowed':'example'}},0,true);
|
||||
await check('unknown method rejected',cfg.api+'/text',{method:'DELETE'},0,true);
|
||||
await check('404 readable',cfg.api+'/missing',{},404,!cfg.allowed);
|
||||
await check('credentials mode allowed',cfg.api+'/text',{credentials:'include'},200,!cfg.allowed);
|
||||
await check('wildcard without credentials',cfg.publicApi+'/text',{},200);
|
||||
await check('wildcard credentials blocked',cfg.publicApi+'/text',{credentials:'include'},0,true);
|
||||
await check('exposed download header',cfg.api+'/download',{},200,!cfg.allowed,cfg.allowed);
|
||||
out.textContent=JSON.stringify({allowedPage:cfg.allowed,passed:results.every(r=>r.pass),checks:results},null,2);
|
||||
};
|
||||
</script>`
|
||||
2
scripts/testdata/mysqlGenerated_test.go
vendored
2
scripts/testdata/mysqlGenerated_test.go
vendored
@@ -20,7 +20,7 @@ func TestGeneratedMySQLConfigurationAndLifecycle(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Database.User != "omni_test" || cfg.Database.TLS.ServerName != "localhost" || cfg.Database.Pool.MaxOpenConns != 2 {
|
||||
t.Fatal("structured environment overlay was not applied")
|
||||
t.Fatal("explicit database configuration references were not applied")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -72,7 +72,7 @@ def main():
|
||||
path.chmod(0o644) # Disposable fixtures, readable by the container's mysql uid.
|
||||
project = temp / "consumer"
|
||||
project.mkdir()
|
||||
version = args.version or "v0.15.0-preview"
|
||||
version = args.version or re.search(r'const currentVersion = "([^"]+)"', (ROOT / "cmd/go-omni/apigen/generate.go").read_text()).group(1)
|
||||
(project / "go.mod").write_text(f"module example.com/connectorAcceptance\n\ngo 1.22.0\n\nrequire {MODULE} {version}\n")
|
||||
if args.local:
|
||||
run(["go", "mod", "edit", f"-replace={MODULE}={ROOT}"], project, env)
|
||||
@@ -155,6 +155,8 @@ def main():
|
||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
|
||||
assert "TestMySQLConnectorTLSAndPoolIsolation" in passed
|
||||
assert "TestMySQLConnectorTLSAndPoolIsolation/commitAcknowledgementLost" in passed
|
||||
assert "TestMySQLConnectorTLSAndPoolIsolation/rollbackAcknowledgementLost" in passed
|
||||
assert "TestStructuredMySQLAdapterTLS" in passed
|
||||
if args.gorm:
|
||||
assert "TestGORMMySQLTransactionsCancellationAndLogs" in passed
|
||||
@@ -164,6 +166,18 @@ def main():
|
||||
GO_OMNI_DB_DATABASE="omni_test", GO_OMNI_DB_TLS_CA_FILE=str(certs / "ca.pem"),
|
||||
GO_OMNI_DB_TLS_SERVER_NAME="localhost", GO_OMNI_DB_POOL_MAX_OPEN_CONNS="2",
|
||||
GO_OMNI_DB_POOL_MAX_IDLE_CONNS="2")
|
||||
# Since v0.22, configuration reads only explicitly declared references.
|
||||
# JSON is valid YAML; keep secrets in the temporary process environment.
|
||||
(generated / "etc/config.yaml").write_text(json.dumps({
|
||||
"server": {"log": {"output": "discard"}},
|
||||
"database": {
|
||||
"host": "${GO_OMNI_DB_HOST}", "port": "${GO_OMNI_DB_PORT}",
|
||||
"user": "${GO_OMNI_DB_USER}", "password": "${GO_OMNI_DB_PASSWORD}",
|
||||
"database": "${GO_OMNI_DB_DATABASE}",
|
||||
"tls": {"caFile": "${GO_OMNI_DB_TLS_CA_FILE}", "serverName": "${GO_OMNI_DB_TLS_SERVER_NAME}"},
|
||||
"pool": {"maxOpenConns": "${GO_OMNI_DB_POOL_MAX_OPEN_CONNS}", "maxIdleConns": "${GO_OMNI_DB_POOL_MAX_IDLE_CONNS}"},
|
||||
},
|
||||
}, indent=2) + "\n")
|
||||
print("Testing generated MySQL service config, independent pools and cleanup", flush=True)
|
||||
check_output = run([cli, "api", "generate", "--check"], generated, env)
|
||||
if not args.local:
|
||||
@@ -182,7 +196,7 @@ def main():
|
||||
finally:
|
||||
if created:
|
||||
run(["docker", "rm", "--force", "--volumes", name], temp, env)
|
||||
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go"]
|
||||
report_sources = sources + ["scripts/verifyConnector.py", "scripts/testdata/mysqlGenerated_test.go", "database/transaction.go"]
|
||||
if args.phases:
|
||||
report_sources += ["scripts/testdata/phasedMySQL_test.go", "lifecycle/runtime.go", "lifecycle/phases.go"]
|
||||
if args.gorm:
|
||||
|
||||
@@ -124,7 +124,7 @@ def main():
|
||||
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
|
||||
|
||||
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
|
||||
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer"]
|
||||
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer"]
|
||||
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
|
||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
|
||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
@@ -161,13 +161,23 @@ def main():
|
||||
for package, expected in {
|
||||
"copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"},
|
||||
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
|
||||
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
|
||||
"app": {"TestAppCORSRoutingAndErrors", "TestAppCORSConfigIsolation", "TestCORSTransports"},
|
||||
"database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"},
|
||||
}.items():
|
||||
output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env)
|
||||
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
|
||||
passed = {event.get("Test") for event in events if event.get("Action") == "pass"}
|
||||
assert expected <= passed
|
||||
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
|
||||
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation"]
|
||||
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
|
||||
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
|
||||
transaction_output = run(["go", "run", MODULE + "/examples/transactions"], starter, env)
|
||||
assert "commitAcknowledgementLost: stage=commit outcome=unknown stored=true cleanup=false" in transaction_output
|
||||
assert "workFailure: stage=work outcome=rolledBack stored=false cleanup=true" in transaction_output
|
||||
checks += ["remoteTransactionStages", "remoteTransactionCauseChains", "remoteCommitOutcomeUnknown",
|
||||
"remoteCancellationOutcomeUnknown", "remoteTransactionPanicAndConcurrency",
|
||||
"remoteTransactionPublicErrors", "remoteTransactionExample"]
|
||||
|
||||
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
|
||||
|
||||
@@ -188,6 +198,9 @@ def main():
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "logx").glob("*.go")]
|
||||
sources += ["internal/configdiag/error.go", "database/mysql/mysql.go", "database/connector.go", "database/database.go", "contract/policy.go"]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/developerTools").iterdir() if p.is_file()]
|
||||
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "cors").glob("*.go")]
|
||||
sources += ["app/cors_test.go", "cmd/go-omni/apigen/corsConsumer_test.go", "cmd/go-omni/apigen/testdata/cors/consumer_test.go", "examples/cors/main.go", "examples/cors/config.yaml", "scripts/corsBrowser/main.go"]
|
||||
sources += ["database/transaction.go", "database/transaction_test.go", "database/transactionResponse_test.go", "database/mysqlConnector_test.go", "examples/transactions/main.go", "examples/transactions/demoDriver.go"]
|
||||
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
|
||||
"freshModuleCache": True, "noLocalReplace": True,
|
||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||
|
||||
Reference in New Issue
Block a user