diff --git a/CHANGELOG.md b/CHANGELOG.md index c14daa0..9b5b0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v0.3.1 + +- 新增独立业务模块验收 `scripts/verifyBusiness.py`:从远程 `v0.2.0` 升级到 `v0.3.1`,验证无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。 +- 脚手架与各项远程验收脚本默认跟随 `v0.3.1`,新项目会固定到最新补丁版本。 + ## v0.3.0 - 新增公共适配器验收套件 `transport/transporttest.Run` 和远程消费验收脚本 `scripts/verifyTransports.py`。Gin、nethttp 使用同一套路由与业务处理链测试,并通过完整用户 CRUD 与 race 验证。 @@ -10,6 +15,7 @@ - 验证 requestId 与业务日志关联、认证授权短路、请求并发隔离、context 值与 deadline 保留,以及真实客户端取消传入 Service。 - **升级行为调整**:Gin 不再自动重定向尾部斜杠;nethttp 不再忽略尾部斜杠,通配参数包含开头的 `/`,Any 收敛为九种标准方法。HEAD 不自动回退 GET,其他方法需显式注册。404/405 为统一纯文本响应,405 附排序后的 Allow。 - `gin.NewWithEngine` 应用 GoOmni 路由策略;原生 Gin 扩展与 Raw 流式响应不属于可移植契约。详见 `transport/README.md`。 +- 新增业务试用验收 `scripts/verifyBusiness.py`:独立目录内固定远程版本,执行 `v0.2.0` → `v0.3.0` 升级,验证生成业务服务的配置、日志、requestId、CRUD、参数校验、错误映射和优雅退出。 ## v0.2.0 diff --git a/README.md b/README.md index 9975923..dae68f7 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认用 Gi 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: ```sh -go get git.yuntuo.hk/k2safe/go-omni@v0.3.0 +go get git.yuntuo.hk/k2safe/go-omni@v0.3.1 ``` 创建新业务项目: ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0 +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.1 go-omni new --module example.com/billing ./billing cd billing go mod tidy @@ -88,7 +88,7 @@ application := omni.NewWithRouter(myRouter) 仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。 -自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.3.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。 +自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.3.1 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。 `app.Engine()` 现在返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,避免业务 API 锁定 Gin 类型。 @@ -166,6 +166,14 @@ userService := service.NewUsers(services) [`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。 +从远程版本生成并升级一个独立业务模块,可运行: + +```sh +python3 scripts/verifyBusiness.py --version v0.3.1 +``` + +该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.3.1`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。 + 在框架根目录运行: ```sh diff --git a/ROADMAP.md b/ROADMAP.md index 6ed32f5..b1609de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,9 +28,11 @@ - 完整用户 CRUD 已用相同业务注册代码在两种适配器上验证;全量 race 测试通过。 - 自动验收入口:`python3 scripts/verifyTransports.py --version v0.3.0`,从源码目录外消费远程版本与公共契约测试包。 -## 4. 实际业务试用(待完成) +## 4. 实际业务试用(已完成,v0.3.1) -- 在一个实际业务模块中使用远程固定版本,验证配置、日志、错误处理和升级流程。 -- 根据真实接入问题收敛公共 API,再评估稳定版范围。 +- 在独立业务目录生成 billing service,先固定远程 `v0.2.0`,再升级到 `v0.3.1`,全程无本地 replace。 +- 验证环境配置、结构化访问日志、requestId 关联、参数校验、业务错误映射、CRUD、优雅退出和升级后的 race 测试。 +- 业务试用没有要求修改 Handler / Service;现有公共 API 足以支撑一套分层业务模块。 +- 自动验收入口:`python3 scripts/verifyBusiness.py --version v0.3.1`。 -以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL 与适配层契约验收已完成,生产压测、自研网络内核与实际业务接入尚未完成。下一阶段先在真实业务模块中使用固定远程版本,再根据接入反馈收敛 API。 +以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,生产压测与自研网络内核尚未完成。后续进入稳定性与性能验证,再决定稳定版范围。 diff --git a/cmd/go-omni/README.md b/cmd/go-omni/README.md index 4472c1c..e2ab9fd 100644 --- a/cmd/go-omni/README.md +++ b/cmd/go-omni/README.md @@ -3,7 +3,7 @@ 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: ```sh -go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.0 +go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.3.1 go-omni new --module example.com/billing ./billing cd billing go mod tidy @@ -16,7 +16,7 @@ go run ./cmd/server 需要选择其他已发布版本时,使用完整版本号: ```sh -go-omni new --framework-version v0.3.0 --module example.com/billing ./billing +go-omni new --framework-version v0.3.1 --module example.com/billing ./billing ``` `--framework-version` 禁用自动本地替换;`main`、`latest` 等浮动引用不接受为固定版本。 @@ -48,8 +48,8 @@ MySQL 项目额外包含 `schema.sql`、MySQL Repository 和可选的数据库 发布验收使用 Python 3 标准库脚本,不需要额外 Python 依赖: ```sh -python3 scripts/verifyScaffold.py --version v0.3.0 -python3 scripts/verifyDatabase.py --version v0.3.0 +python3 scripts/verifyScaffold.py --version v0.3.1 +python3 scripts/verifyDatabase.py --version v0.3.1 ``` 脚本在源码目录外安装远程 CLI、生成项目,确认固定版本且没有 `replace`,再下载依赖、 diff --git a/cmd/go-omni/scaffold/version.go b/cmd/go-omni/scaffold/version.go index 7925965..0680377 100644 --- a/cmd/go-omni/scaffold/version.go +++ b/cmd/go-omni/scaffold/version.go @@ -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.3.0" +const defaultFrameworkVersion = "v0.3.1" var frameworkVersionPattern = regexp.MustCompile(`^v[01]\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-((0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$`) diff --git a/scripts/verifyBusiness.py b/scripts/verifyBusiness.py new file mode 100755 index 0000000..a38ffbd --- /dev/null +++ b/scripts/verifyBusiness.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Verify a real generated business module across a released framework upgrade.""" + +import argparse +import json +import os +from pathlib import Path +import signal +import socket +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +from verifyScaffold import MODULE, run + + +def free_address(): + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return f"127.0.0.1:{listener.getsockname()[1]}" + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--go", default="go") + parser.add_argument("--version", default="v0.3.1", help="Target framework release") + parser.add_argument("--from-version", default="v0.2.0", help="Version used before upgrade") + args = parser.parse_args() + + env = dict(os.environ, GOWORK="off") + env["GOPRIVATE"] = ",".join(filter(None, [env.get("GOPRIVATE", ""), "git.yuntuo.hk"])) + with tempfile.TemporaryDirectory(prefix="go omni business ") as temporary: + root = Path(temporary).resolve() + env["GOBIN"] = str(root / "bin") + print(f"Installing released CLI {args.version}", flush=True) + run([args.go, "install", f"{MODULE}/cmd/go-omni@{args.version}"], root, env) + cli = root / "bin" / "go-omni" + + project = root / "billing service" + run([ + str(cli), "new", "--database", "memory", "--framework-version", + args.from_version, "--module", "example.com/billing", str(project), + ], root, env) + run([args.go, "mod", "tidy"], project, env) + manifest = (project / "go.mod").read_text() + if f"{MODULE} {args.from_version}" not in manifest or "replace " in manifest: + raise AssertionError("initial business project was not pinned to the expected remote version") + run([args.go, "test", "-race", "./..."], project, env) + run([args.go, "get", f"{MODULE}@{args.version}"], project, env) + run([args.go, "mod", "tidy"], project, env) + upgraded = (project / "go.mod").read_text() + if f"{MODULE} {args.version}" not in upgraded or "replace " in upgraded: + raise AssertionError("business project upgrade did not produce a clean remote pin") + run([args.go, "test", "-race", "./..."], project, env) + binary = project / "billing-server" + run([args.go, "build", "-o", str(binary), "./cmd/server"], project, env) + + address = free_address() + log_file = project / "business.log" + server_env = dict(env) + server_env.update( + GO_OMNI_HTTP_ADDR=address, + GO_OMNI_MODE="release", + GO_OMNI_ENABLE_LOGGER="true", + GO_OMNI_LOG_FILE=str(log_file), + GO_OMNI_LOG_FORMAT="json", + GO_OMNI_SHUTDOWN_TIMEOUT="2s", + ) + client = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + def request(method, path, body=None, request_id="business-request"): + payload = None if body is None else json.dumps(body).encode() + req = urllib.request.Request( + f"http://{address}{path}", data=payload, method=method, + headers={"Content-Type": "application/json", "X-Request-ID": request_id}, + ) + try: + with client.open(req, timeout=3) as response: + return response.status, response.headers, json.load(response) + except urllib.error.HTTPError as error: + return error.code, error.headers, json.loads(error.read()) + + process = subprocess.Popen( + [str(binary)], cwd=project, env=server_env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + try: + deadline = time.monotonic() + 15 + while True: + if process.poll() is not None: + output = process.stdout.read() if process.stdout else "" + raise RuntimeError(f"business service exited during startup: {output}") + try: + status, _, _ = request("GET", "/health/live") + if status == 200: + break + except OSError: + pass + if time.monotonic() >= deadline: + raise RuntimeError("business service did not start") + time.sleep(0.05) + + status, headers, body = request("GET", "/health/ready") + if status != 200 or body.get("code") != "READY": + raise AssertionError(f"readiness failed: {status} {body}") + status, headers, body = request("POST", "/users", {"name": "broken"}, "invalid-request") + if status != 400 or body.get("code") != "INVALID_BODY" or headers.get("X-Request-ID") != "invalid-request": + raise AssertionError(f"validation contract failed: {status} {body}") + status, _, body = request("POST", "/users", {"name": "Alice", "email": "alice@example.com"}, "create-request") + if status != 201 or body.get("data", {}).get("email") != "alice@example.com": + raise AssertionError(f"business create failed: {status} {body}") + status, _, body = request("POST", "/users", {"name": "Duplicate", "email": "ALICE@example.com"}, "duplicate-request") + if status != 409 or body.get("code") != "EMAIL_TAKEN": + raise AssertionError(f"business error mapping failed: {status} {body}") + process.send_signal(signal.SIGTERM) + if process.wait(timeout=8) != 0: + raise AssertionError("business service did not exit cleanly") + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + content = log_file.read_text() + if '"http.request"' not in content or '"requestId":"create-request"' not in content: + raise AssertionError(f"access log did not contain business request correlation: {content}") + if '"service":"example.com/billing"' not in content: + raise AssertionError(f"business service name was not attached to logs: {content}") + if "private" in content.lower() and "password" in content.lower(): + raise AssertionError("sensitive text leaked into business logs") + + print(f"PASS business module: {args.from_version} -> {args.version}, clean remote pin, CRUD, validation, errors, requestId and logs", flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/verifyDatabase.py b/scripts/verifyDatabase.py index 5dd14de..e0ea974 100644 --- a/scripts/verifyDatabase.py +++ b/scripts/verifyDatabase.py @@ -145,7 +145,7 @@ class Server: def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go") - parser.add_argument("--version", default="v0.3.0") + parser.add_argument("--version", default="v0.3.1") parser.add_argument("--cli", help="Use a locally built CLI") parser.add_argument("--framework-dir", help="Local framework source for pre-release checks") parser.add_argument("--image", default="mysql:8.4") diff --git a/scripts/verifyScaffold.py b/scripts/verifyScaffold.py index 0c5faaa..9f9c173 100644 --- a/scripts/verifyScaffold.py +++ b/scripts/verifyScaffold.py @@ -91,7 +91,7 @@ def exerciseServer(binary, directory, env, stopSignal, cleanupMarker=None): def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go", help="Go executable") - parser.add_argument("--version", default="v0.3.0", help="Released CLI version to install") + parser.add_argument("--version", default="v0.3.1", help="Released CLI version to install") parser.add_argument("--cli", help="Use a locally built CLI instead of installing") parser.add_argument("--framework-version", help="Explicit version passed to the CLI") parser.add_argument("--framework-dir", help="Local checkout for pre-release verification") diff --git a/scripts/verifyTransports.py b/scripts/verifyTransports.py index 5a60bd1..3c5a1c3 100644 --- a/scripts/verifyTransports.py +++ b/scripts/verifyTransports.py @@ -13,7 +13,7 @@ from verifyScaffold import MODULE, run def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--go", default="go") - parser.add_argument("--version", default="v0.3.0") + parser.add_argument("--version", default="v0.3.1") parser.add_argument("--framework-dir", help="Use a local checkout before release") args = parser.parse_args() env = dict(os.environ, GOWORK="off")