feat: complete API-driven projects with infrastructure and business acceptance

This commit is contained in:
2026-09-23 17:33:50 +08:00
parent 04a1f7999d
commit 2c74dc0859
137 changed files with 11926 additions and 105 deletions

View File

@@ -0,0 +1,15 @@
# 接口定义示例
`user.api` 是入口,导入 `shared.api`。包含 JSON/path/query/header 混合请求、分页与重复 query 参数、嵌套对象、optional 指针、user 与 admin/user 分组以及 Auth/Audit 命名中间件。
在 go-omni 根目录执行:
```sh
go run ./cmd/go-omni api validate --api examples/contracts/user.api
go run ./cmd/go-omni api generate --api examples/contracts/user.api \
--dir ../userService --module example.com/userService --framework-dir .
```
项目中补充 `internal/service` 的业务实现和 `internal/middleware` 的 Auth/Audit hook,再启动服务。初始占位实现返回 501。
需要立即能返回成功结果的最小项目,用 `go-omni api new` 创建问候示例。完整语法、目录、更新策略见 [API.md](../../API.md)。

View File

@@ -0,0 +1,11 @@
syntax = "v1"
type Address {
City string `json:"city" binding:"required,max=50"`
}
type UserResp {
UserID int64 `json:"userId"`
Name string `json:"name"`
Active bool `json:"active"`
}

View File

@@ -0,0 +1,58 @@
syntax = "v1"
info (
title: "User API"
version: "1.0.0"
)
import "shared.api"
type (
UpdateUserReq {
UserID int64 `path:"userId,range=[1:1000000]"`
Notify bool `form:"notify,default=false"`
Client string `header:"X-Client,optional"`
Name string `json:"name" binding:"required,max=50"`
Active bool `json:"active"`
Age *int `json:"age,optional,range=[0:150]"`
Address *Address `json:"address,optional"`
}
ListUsersReq {
Page int `form:"page,default=1,range=[1:1000]"`
PageSize int `form:"pageSize,default=20,range=[1:100]"`
Tags []string `form:"tag,optional" binding:"max=10"`
}
ListUsersResp {
Items []UserResp `json:"items"`
Page int `json:"page"`
Total int64 `json:"total"`
}
DeleteUserReq {
UserID int64 `path:"userId,range=[1:1000000]"`
}
)
@server (
group: user
prefix: /api/v1
)
service user-api {
@doc "Update a user"
@handler updateUser
put /users/:userId (UpdateUserReq) returns (UserResp)
@doc "List users"
@handler listUsers
get /users (ListUsersReq) returns (ListUsersResp)
}
@server (
group: admin/user
prefix: /admin/v1
middleware: Auth,Audit
)
service user-api {
@doc "Delete a user; business-owned authentication and auditing required"
@handler deleteUser
delete /users/:userId (DeleteUserReq)
}

6
examples/userService/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/server
/var/
.env
.env.*
!.env.example
/.goOmni/api.lock

View File

@@ -0,0 +1,19 @@
{
"profile": {
"database": "mysql",
"storage": "local",
"logOutput": "file",
"logFormat": "json"
},
"version": 1,
"module": "example.com/userService",
"entry": "api/user.api",
"generated": {
"docs/openapi.json": "0675e4eb1278b36828aa31b12587b0d33d2f384112f67a43179eb72b52c3a0ea",
"internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"
}
}

View File

@@ -0,0 +1,79 @@
# API 驱动的用户管理示例
这个独立 Go module 从 `api/user.api` 生成,随后在开发者拥有的文件中实现业务。
使用 MySQL 持久化、事务审计、环境变量 Token 鉴权、文件日志和 local 存储;业务层只接收标准 context 和基础模块接口。
## 启动
先准备空的业务数据库,再设置环境变量(替换占位值,密钥不写进配置或 Git):
```sh
export GO_OMNI_DB_DSN='user:password@tcp(127.0.0.1:3306)/user_service?parseTime=true'
export USER_SERVICE_API_TOKEN='<至少32字节的随机值>'
export GO_OMNI_STORAGE_SIGNING_KEY='<另一个至少32字节的随机值>'
export GO_OMNI_STORAGE_PUBLIC_URL='http://127.0.0.1:8080/storage/object'
go mod tidy
# 在本目录运行与框架版本一致的 CLI;迁移必须显式执行。
go-omni migrate --dir migrations --apply
go run ./cmd/server
```
源码示例的 go.mod 使用 `replace ... => ../..`。复制到独立仓库时,删除这条 replace,
保留已发布的固定依赖版本,再执行 go mod tidy。远程版本验证脚本会自动检查没有本地替换。
`GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。
配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转,
`GO_OMNI_LOG_OUTPUT=stderr` 可以切换为标准错误输出。MySQL 默认最多 10 条连接。
应用检查 users 和 user_audit 表,缺失时拒绝启动,不自动改表。
## 接口
除健康检查、OpenAPI 和持有效签名的对象下载外,业务接口需要 `Authorization: Bearer <Token>`。
这个 Token 代表示例的管理员;可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。
框架不会推断租户模型或自动给接口放行。
| 接口 | 行为 |
| --- | --- |
| POST /api/v1/users | 创建,返回 201;邮箱规范化并唯一 |
| GET /api/v1/users/:id | 查询,缺失返回 404 |
| GET /api/v1/users?page=1&pageSize=20 | 分页,pageSize 最大 100,空结果返回 [] |
| PUT /api/v1/users/:id | 完整更新;邮箱冲突返回 409 |
| DELETE /api/v1/users/:id | 删除,返回 204 |
| POST /api/v1/users/:id/export | 私有文本导出,返回 5 分钟下载链接 |
| GET /health/ready | 数据库不可用时返回 503,恢复后重新就绪 |
| GET /openapi.json | 与 docs/openapi.json 一致 |
参数不合法返回 400,认证失败 401,邮箱重复 409,内部故障只返回通用 500。
OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上表。
创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。
事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。
数据库异常日志不记录 SQL、DSN 或用户输入。
文件导出演示 `deps.Uploads` 和 `deps.Downloads`。对象使用随机 key,签名 URL 不进入日志。
签名过期只限制下载,不删除文件;实际项目应给 exports/ 配置保留期限或清理任务。
只签发下载链接,不对外开放任意对象上传或签名接口。
## 开发与验证
```sh
go-omni api generate --api api/user.api --dir .
go-omni api generate --api api/user.api --dir . --check
go test ./...
```
Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移、鉴权、配置和 main 保留。
修改 .api 的类型/方法签名后,需要同步修改自己的业务实现并运行测试。
在框架仓库运行:
```sh
python3 scripts/verifyAPIWorkflow.py
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \
--report performance/apiWorkflowRemote.json
```
验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。
覆盖 nethttp/native 的 CRUD、校验、鉴权、事务错误/异常/取消回滚、数据库停机恢复、
重启持久化、签名文件下载、文件日志与优雅退出。不会连接业务服务器。

View File

@@ -0,0 +1,60 @@
syntax = "v1"
info (
title: "User service reference"
version: "1.0.0"
)
type (
UserResp {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
CreateUserReq {
Name string `json:"name" binding:"required,max=100"`
Email string `json:"email" binding:"required,email,max=254"`
}
UserIDReq {
ID int64 `path:"id,range=[1:9007199254740991]"`
}
UpdateUserReq {
ID int64 `path:"id,range=[1:9007199254740991]"`
Name string `json:"name" binding:"required,max=100"`
Email string `json:"email" binding:"required,email,max=254"`
}
ListUsersReq {
Page int `form:"page,default=1,range=[1:1000000]"`
PageSize int `form:"pageSize,default=20,range=[1:100]"`
}
UserPageResp {
Items []UserResp `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
ExportResp {
DownloadURL string `json:"downloadUrl"`
}
)
@server (
group: user
prefix: /api/v1
middleware: Auth
)
service user-api {
@handler createUser
@status 201
post /users (CreateUserReq) returns (UserResp)
@handler getUser
get /users/:id (UserIDReq) returns (UserResp)
@handler listUsers
get /users (ListUsersReq) returns (UserPageResp)
@handler updateUser
put /users/:id (UpdateUserReq) returns (UserResp)
@handler deleteUser
delete /users/:id (UserIDReq)
@doc "Export one user as a private text object with a short-lived download link"
@handler exportUser
post /users/:id/export (UserIDReq) returns (ExportResp)
}

View File

@@ -0,0 +1,58 @@
package main
import (
"context"
"errors"
"example.com/userService/internal/handler"
"example.com/userService/internal/servicecontext"
"example.com/userService/internal/settings"
"flag"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"log"
"os"
"os/signal"
"syscall"
)
func main() {
path := flag.String("config", "etc/config.yaml", "configuration file; empty uses defaults and environment")
flag.Parse()
if err := run(*path); err != nil {
log.Fatal(err)
}
}
func run(path string) (runErr error) {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
cfg, err := settings.Load(path)
if err != nil {
return err
}
logger, err := logx.New(cfg.Server.Log)
if err != nil {
return err
}
defer func() { runErr = errors.Join(runErr, logger.Close()) }()
logx.SetDefault(logger)
deps, err := servicecontext.New(ctx, logger, cfg)
if err != nil {
return err
}
defer func() { runErr = errors.Join(runErr, deps.Close()) }()
app := omni.New(omni.WithConfig(cfg.Server))
app.OnShutdown(logger.Close)
app.OnShutdown(deps.Close)
for _, method := range []string{"GET", "HEAD", "PUT"} {
app.Handle(method, "/storage/object", func(c *httpx.Context) error { deps.FileHTTP.ServeHTTP(c.Raw(), c.Request()); return nil })
}
if err = handler.RegisterRoutes(app, deps); err != nil {
return err
}
app.GET("/openapi.json", handler.OpenAPI)
app.GET("/health/live", deps.Health.LivenessHandler())
app.GET("/health/ready", deps.Health.ReadinessHandler())
logger.InfoContext(ctx, "service.starting", "address", cfg.Server.Address)
return app.RunContext(ctx, cfg.Server.Address)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
# Non-secret settings only. Environment variables override these values.
server:
address: '127.0.0.1:8080'
enableLogger: true
slowRequestThreshold: 500ms
shutdownTimeout: 10s
log:
service: "example.com/userService"
level: info
format: json
file: './var/log/server.log'
rotation:
enabled: true
maxSizeMb: 100
maxBackups: 10
maxAgeDays: 7
compress: true
redactKeys: []
storage:
provider: local
maxBytes: 33554432
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
linkTtl: 5m
requestTimeout: 30s
root: './var/files'
publicUrl: 'http://127.0.0.1:8080/storage/object'
database:
driver: mysql
maxOpenConns: 10
maxIdleConns: 5
connMaxLifetime: 30m
connMaxIdleTime: 5m
pingTimeout: 5s
# GO_OMNI_DB_DSN supplies the secret connection string.

View File

@@ -0,0 +1,26 @@
module example.com/userService
go 1.22.0
require (
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview
github.com/go-sql-driver/mysql v1.9.3
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace git.yuntuo.hk/k2safe/go-omni => ../..

View File

@@ -0,0 +1,38 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,27 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
h0 "example.com/userService/internal/handler/user"
s0 "example.com/userService/internal/service/user"
"example.com/userService/internal/servicecontext"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {
if router == nil || deps == nil {
return fmt.Errorf("router and dependencies required")
}
if deps.Middlewares["Auth"] == nil {
return fmt.Errorf("missing middleware Auth")
}
h0 := h0.New(s0.New(deps))
router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"])
router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"])
router.Handle("DELETE", "/api/v1/users/:id", h0.DeleteUser, deps.Middlewares["Auth"])
router.Handle("POST", "/api/v1/users/:id/export", h0.ExportUser, deps.Middlewares["Auth"])
return nil
}

View File

@@ -0,0 +1,120 @@
// Code generated by go-omni api. DO NOT EDIT.
package user
import (
"context"
"example.com/userService/internal/types"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
type Service interface {
CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error)
GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error)
ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error)
UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error)
DeleteUser(ctx context.Context, req *types.UserIDReq) error
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error)
}
type Handler struct{ service Service }
func New(s Service) *Handler { return &Handler{service: s} }
var createUserRequest = contract.MustNew[types.CreateUserReq]()
func (h *Handler) CreateUser(c *httpx.Context) error {
var req types.CreateUserReq
if err := createUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.service.CreateUser(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("CreateUser returned a nil response")
}
return c.Created(resp)
}
var getUserRequest = contract.MustNew[types.UserIDReq]()
func (h *Handler) GetUser(c *httpx.Context) error {
var req types.UserIDReq
if err := getUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.service.GetUser(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("GetUser returned a nil response")
}
return c.OK(resp)
}
var listUsersRequest = contract.MustNew[types.ListUsersReq]()
func (h *Handler) ListUsers(c *httpx.Context) error {
var req types.ListUsersReq
if err := listUsersRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.service.ListUsers(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("ListUsers returned a nil response")
}
return c.OK(resp)
}
var updateUserRequest = contract.MustNew[types.UpdateUserReq]()
func (h *Handler) UpdateUser(c *httpx.Context) error {
var req types.UpdateUserReq
if err := updateUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.service.UpdateUser(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("UpdateUser returned a nil response")
}
return c.OK(resp)
}
var deleteUserRequest = contract.MustNew[types.UserIDReq]()
func (h *Handler) DeleteUser(c *httpx.Context) error {
var req types.UserIDReq
if err := deleteUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
if err := h.service.DeleteUser(c.Context(), &req); err != nil {
return err
}
return c.NoContent()
}
var exportUserRequest = contract.MustNew[types.UserIDReq]()
func (h *Handler) ExportUser(c *httpx.Context) error {
var req types.UserIDReq
if err := exportUserRequest.Bind(c.Request(), httpx.RouteParams(c.Context()), &req); err != nil {
return err
}
resp, err := h.service.ExportUser(c.Context(), &req)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("ExportUser returned a nil response")
}
return c.OK(resp)
}

View File

@@ -0,0 +1,40 @@
package middleware
import (
"crypto/sha256"
"crypto/subtle"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"os"
"strings"
)
// Auth is the fail-closed registry default. Startup replaces it with configured
// authentication. This administrative example uses one environment-only key;
// production applications can inject JWT/OIDC through the same middleware slot.
func Auth(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
}
}
func NewAuthFromEnv() (httpx.Middleware, error) {
token := os.Getenv("USER_SERVICE_API_TOKEN")
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
}
expected := sha256.Sum256([]byte(token))
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
parts := strings.Fields(c.Header("Authorization"))
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
}
actual := sha256.Sum256([]byte(parts[1]))
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
}
return next(c)
}
}, nil
}

View File

@@ -0,0 +1,35 @@
package middleware
import (
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"net/http/httptest"
"testing"
)
func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
t.Setenv("USER_SERVICE_API_TOKEN", "")
if _, err := NewAuthFromEnv(); err == nil {
t.Fatal("missing credential accepted")
}
token := "test-key-012345678901234567890123456789"
t.Setenv("USER_SERVICE_API_TOKEN", token)
middleware, err := NewAuthFromEnv()
if err != nil {
t.Fatal(err)
}
for _, header := range []string{"", "Basic " + token, "Bearer wrong", "Bearer " + token} {
called := false
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set("Authorization", header)
c := httpx.NewContext(r, httptest.NewRecorder(), nil)
err = middleware(func(*httpx.Context) error { called = true; return nil })(c)
if header == "Bearer "+token {
if err != nil || !called {
t.Fatal("valid token rejected")
}
} else if err == nil || called || apierrors.From(err).Status != 401 {
t.Fatal("unauthenticated request reached service")
}
}
}

View File

@@ -0,0 +1,10 @@
// Code generated by go-omni api. DO NOT EDIT.
package middleware
import "git.yuntuo.hk/k2safe/go-omni/httpx"
func Defaults() map[string]httpx.Middleware {
return map[string]httpx.Middleware{
"Auth": Auth,
}
}

View File

@@ -0,0 +1,11 @@
package model
import "errors"
// Domain errors are translated into HTTP responses only by the handler layer.
var (
ErrUserNotFound = errors.New("user not found")
ErrEmailTaken = errors.New("email already in use")
ErrInvalidUser = errors.New("invalid user")
ErrInvalidPage = errors.New("invalid pagination")
)

View File

@@ -0,0 +1,25 @@
package model
import "time"
// User is the business entity; it has no dependency on HTTP or a database driver.
type User struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// UserInput is shared by creation and full replacement (PUT).
type UserInput struct {
Name string
Email string
}
type UserPage struct {
Items []User `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}

View File

@@ -0,0 +1,143 @@
package user
import (
"context"
"database/sql"
"errors"
"example.com/userService/internal/model"
"github.com/go-sql-driver/mysql"
)
// SQLExecutor is implemented by both *sql.DB and *sql.Tx. A repository created
// with a transaction keeps all of its queries on that same transaction.
type SQLExecutor interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
QueryRowContext(context.Context, string, ...any) *sql.Row
}
// MySQLUsers is the persistent implementation of UserRepository.
// The schema is documented in schema.sql; migrations stay outside the framework.
type MySQLUsers struct {
db SQLExecutor
}
func NewMySQLUsers(db SQLExecutor) *MySQLUsers {
return &MySQLUsers{db: db}
}
// CheckSchema validates that the application's migration has been applied.
// Runtime startup never creates or alters tables.
func (r *MySQLUsers) CheckSchema(ctx context.Context) error {
rows, err := r.db.QueryContext(ctx, `SELECT id, name, email, created_at, updated_at FROM users LIMIT 0`)
if err != nil {
return err
}
return rows.Close()
}
func (r *MySQLUsers) Create(ctx context.Context, input model.UserInput) (model.User, error) {
result, err := r.db.ExecContext(ctx, `
INSERT INTO users (name, email, created_at, updated_at)
VALUES (?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`, input.Name, input.Email)
if err != nil {
return model.User{}, mapMySQLError(err)
}
id, err := result.LastInsertId()
if err != nil {
return model.User{}, err
}
return r.Get(ctx, id)
}
func (r *MySQLUsers) Get(ctx context.Context, id int64) (model.User, error) {
var user model.User
err := r.db.QueryRowContext(ctx, `
SELECT id, name, email, created_at, updated_at
FROM users WHERE id = ?`, id).Scan(
&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return model.User{}, model.ErrUserNotFound
}
if err != nil {
return model.User{}, err
}
return user, nil
}
func (r *MySQLUsers) List(ctx context.Context, page, pageSize int) (model.UserPage, error) {
if page < 1 || pageSize < 1 || pageSize > 100 {
return model.UserPage{}, model.ErrInvalidPage
}
var total int
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&total); err != nil {
return model.UserPage{}, err
}
result := model.UserPage{Items: []model.User{}, Total: total, Page: page, PageSize: pageSize}
if total == 0 || page-1 > (total-1)/pageSize {
return result, nil
}
offset := (page - 1) * pageSize
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, email, created_at, updated_at
FROM users ORDER BY id LIMIT ? OFFSET ?`, pageSize, offset)
if err != nil {
return model.UserPage{}, err
}
defer rows.Close()
for rows.Next() {
var user model.User
if err := rows.Scan(&user.ID, &user.Name, &user.Email, &user.CreatedAt, &user.UpdatedAt); err != nil {
return model.UserPage{}, err
}
result.Items = append(result.Items, user)
}
if err := rows.Err(); err != nil {
return model.UserPage{}, err
}
return result, nil
}
func (r *MySQLUsers) Update(ctx context.Context, id int64, input model.UserInput) (model.User, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE users SET name = ?, email = ?, updated_at = UTC_TIMESTAMP(6)
WHERE id = ?`, input.Name, input.Email, id)
if err != nil {
return model.User{}, mapMySQLError(err)
}
changed, err := result.RowsAffected()
if err != nil {
return model.User{}, err
}
if changed == 0 {
// MySQL may report zero affected rows when the replacement is identical;
// distinguish that from an ID that does not exist.
return r.Get(ctx, id)
}
return r.Get(ctx, id)
}
func (r *MySQLUsers) Delete(ctx context.Context, id int64) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
if err != nil {
return err
}
deleted, err := result.RowsAffected()
if err != nil {
return err
}
if deleted == 0 {
return model.ErrUserNotFound
}
return nil
}
func mapMySQLError(err error) error {
var mysqlErr *mysql.MySQLError
if errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 {
return model.ErrEmailTaken
}
return err
}

View File

@@ -0,0 +1,16 @@
package user
import (
"context"
"example.com/userService/internal/model"
)
// UserRepository describes the storage operations the business needs.
// Implementations must atomically enforce unique, normalized email addresses.
type UserRepository interface {
Create(context.Context, model.UserInput) (model.User, error)
Get(context.Context, int64) (model.User, error)
List(context.Context, int, int) (model.UserPage, error)
Update(context.Context, int64, model.UserInput) (model.User, error)
Delete(context.Context, int64) error
}

View File

@@ -0,0 +1,41 @@
package user
import (
"context"
"database/sql"
"errors"
"git.yuntuo.hk/k2safe/go-omni/database"
"time"
)
type UnitOfWork interface {
UserRepository
WithTx(context.Context, func(UserRepository, func(string, int64, string) error) error) error
}
type Store struct {
*MySQLUsers
db *database.DB
}
func NewStore(db *database.DB) *Store { return &Store{MySQLUsers: NewMySQLUsers(db.DB), db: db} }
func (s *Store) WithTx(ctx context.Context, work func(UserRepository, func(string, int64, string) error) error) error {
return s.db.InTx(ctx, nil, func(tx *sql.Tx) error {
audit := func(operation string, id int64, requestID string) error {
_, err := tx.ExecContext(ctx, "INSERT INTO user_audit (user_id,operation,request_id,created_at) VALUES (?,?,?,?)", id, operation, requestID, time.Now().UTC())
return err
}
return work(NewMySQLUsers(tx), audit)
})
}
// CheckSchema validates columns; it never mutates database schema.
func (s *Store) CheckSchema(ctx context.Context) error {
if err := s.MySQLUsers.CheckSchema(ctx); err != nil {
return err
}
rows, err := s.db.QueryContext(ctx, "SELECT id,user_id,operation,request_id,created_at FROM user_audit LIMIT 0")
if err != nil {
return err
}
return errors.Join(rows.Err(), rows.Close())
}

View File

@@ -0,0 +1,84 @@
package user
import (
"context"
"errors"
"os"
"testing"
"example.com/userService/internal/model"
"git.yuntuo.hk/k2safe/go-omni/database"
)
// This suite runs only against the disposable database created by verifyAPIWorkflow.
func TestTransactions(t *testing.T) {
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
t.Skip("isolated MySQL required")
}
cfg, err := database.ConfigFromEnv("")
if err != nil {
t.Fatal(err)
}
db, err := database.Open(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
store := NewStore(db)
for _, kind := range []string{"error", "panic", "cancel", "commit"} {
t.Run(kind, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
email := kind + "@transaction.example"
var recovered any
func() {
defer func() { recovered = recover() }()
err = store.WithTx(ctx, func(r UserRepository, audit func(string, int64, string) error) error {
u, e := r.Create(ctx, model.UserInput{Name: "Transaction", Email: email})
if e != nil {
return e
}
if e = audit("created", u.ID, "transactionCheck"); e != nil {
return e
}
switch kind {
case "error":
return errors.New("rollback sentinel")
case "panic":
panic("rollback sentinel")
case "cancel":
cancel()
}
return nil
})
}()
if kind == "panic" && recovered == nil {
t.Fatal("panic lost")
}
if kind == "commit" && err != nil {
t.Fatal(err)
}
if kind != "commit" && kind != "panic" && err == nil {
t.Fatal("failed transaction committed")
}
var count int
if e := db.QueryRow("SELECT COUNT(*) FROM users WHERE email=?", email).Scan(&count); e != nil {
t.Fatal(e)
}
want := 0
if kind == "commit" {
want = 1
}
if count != want {
t.Fatalf("rows=%d want=%d", count, want)
}
})
}
var audits int
if err = db.QueryRow("SELECT COUNT(*) FROM user_audit WHERE request_id='transactionCheck'").Scan(&audits); err != nil {
t.Fatal(err)
}
if audits != 1 {
t.Fatalf("audit transaction mismatch: %d", audits)
}
}

View File

@@ -0,0 +1,16 @@
package user
import (
"context"
"example.com/userService/internal/model"
repo "example.com/userService/internal/repository/user"
"example.com/userService/internal/types"
)
func (s *Service) CreateUser(ctx context.Context, req *types.CreateUserReq) (*types.UserResp, error) {
input, err := normalize(req.Name, req.Email)
if err != nil {
return nil, s.failure(ctx, err)
}
return s.mutate(ctx, "created", func(r repo.UserRepository) (model.User, error) { return r.Create(ctx, input) })
}

View File

@@ -0,0 +1,16 @@
package user
import (
"context"
"example.com/userService/internal/model"
repo "example.com/userService/internal/repository/user"
"example.com/userService/internal/types"
)
func (s *Service) DeleteUser(ctx context.Context, req *types.UserIDReq) error {
_, err := s.mutate(ctx, "deleted", func(r repo.UserRepository) (model.User, error) {
err := r.Delete(ctx, req.ID)
return model.User{ID: req.ID}, err
})
return err
}

View File

@@ -0,0 +1,40 @@
package user
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"example.com/userService/internal/types"
"fmt"
"strings"
"time"
)
func (s *Service) ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error) {
u, err := s.GetUser(ctx, req)
if err != nil {
return nil, err
}
payload, err := json.Marshal(u)
if err != nil {
return nil, s.failure(ctx, err)
}
var nonce [16]byte
if _, err = rand.Read(nonce[:]); err != nil {
return nil, s.failure(ctx, err)
}
key := fmt.Sprintf("exports/users/%d/%s.txt", u.ID, hex.EncodeToString(nonce[:]))
if _, err = s.deps.Uploads.Put(ctx, key, strings.NewReader(string(payload)), int64(len(payload)), "text/plain"); err != nil {
return nil, s.failure(ctx, err)
}
url, err := s.deps.Downloads.PresignGet(ctx, key, 5*time.Minute)
if err != nil {
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
defer cancel()
_ = s.deps.Files.Delete(cleanup, key)
return nil, s.failure(ctx, err)
}
s.deps.Logger.InfoContext(ctx, "user.exported", "userId", u.ID)
return &types.ExportResp{DownloadURL: url.String()}, nil
}

View File

@@ -0,0 +1,14 @@
package user
import (
"context"
"example.com/userService/internal/types"
)
func (s *Service) GetUser(ctx context.Context, req *types.UserIDReq) (*types.UserResp, error) {
u, err := s.deps.Users.Get(ctx, req.ID)
if err != nil {
return nil, s.failure(ctx, err)
}
return response(u), nil
}

View File

@@ -0,0 +1,18 @@
package user
import (
"context"
"example.com/userService/internal/types"
)
func (s *Service) ListUsers(ctx context.Context, req *types.ListUsersReq) (*types.UserPageResp, error) {
page, err := s.deps.Users.List(ctx, req.Page, req.PageSize)
if err != nil {
return nil, s.failure(ctx, err)
}
result := &types.UserPageResp{Items: []types.UserResp{}, Total: page.Total, Page: page.Page, PageSize: page.PageSize}
for _, item := range page.Items {
result.Items = append(result.Items, *response(item))
}
return result, nil
}

View File

@@ -0,0 +1,65 @@
package user
import (
"context"
"errors"
"example.com/userService/internal/model"
repo "example.com/userService/internal/repository/user"
"example.com/userService/internal/servicecontext"
"example.com/userService/internal/types"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"net/mail"
"strings"
"time"
"unicode/utf8"
)
type Service struct{ deps *servicecontext.Context }
func New(deps *servicecontext.Context) *Service { return &Service{deps: deps} }
func normalize(name, email string) (model.UserInput, error) {
input := model.UserInput{Name: strings.TrimSpace(name), Email: strings.ToLower(strings.TrimSpace(email))}
address, err := mail.ParseAddress(input.Email)
if input.Name == "" || utf8.RuneCountInString(input.Name) > 100 || len(input.Email) > 254 || err != nil || address.Address != input.Email {
return model.UserInput{}, model.ErrInvalidUser
}
return input, nil
}
func response(u model.User) *types.UserResp {
return &types.UserResp{ID: u.ID, Name: u.Name, Email: u.Email, CreatedAt: u.CreatedAt.UTC().Format(time.RFC3339Nano), UpdatedAt: u.UpdatedAt.UTC().Format(time.RFC3339Nano)}
}
func (s *Service) failure(ctx context.Context, err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, model.ErrUserNotFound):
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
case errors.Is(err, model.ErrEmailTaken):
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
case errors.Is(err, model.ErrInvalidUser):
return apierrors.BadRequest("INVALID_USER", "invalid user")
case errors.Is(err, model.ErrInvalidPage):
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
default:
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
return apierrors.Internal(err)
}
}
func (s *Service) mutate(ctx context.Context, operation string, fn func(repo.UserRepository) (model.User, error)) (*types.UserResp, error) {
var u model.User
err := s.deps.Users.WithTx(ctx, func(r repo.UserRepository, audit func(string, int64, string) error) error {
var err error
u, err = fn(r)
if err != nil {
return err
}
return audit(operation, u.ID, logx.RequestID(ctx))
})
if err != nil {
return nil, s.failure(ctx, err)
}
s.deps.Logger.InfoContext(ctx, "user."+operation, "userId", u.ID)
return response(u), nil
}

View File

@@ -0,0 +1,16 @@
package user
import (
"context"
"example.com/userService/internal/model"
repo "example.com/userService/internal/repository/user"
"example.com/userService/internal/types"
)
func (s *Service) UpdateUser(ctx context.Context, req *types.UpdateUserReq) (*types.UserResp, error) {
input, err := normalize(req.Name, req.Email)
if err != nil {
return nil, s.failure(ctx, err)
}
return s.mutate(ctx, "updated", func(r repo.UserRepository) (model.User, error) { return r.Update(ctx, req.ID, input) })
}

View File

@@ -0,0 +1,122 @@
package servicecontext
import (
"context"
"errors"
"example.com/userService/internal/middleware"
userrepo "example.com/userService/internal/repository/user"
"example.com/userService/internal/settings"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/health"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/storage"
"github.com/go-sql-driver/mysql"
"net/http"
"sync"
"time"
)
// Context owns infrastructure, while Logger is borrowed from main.
// Add business repositories here; HTTP contracts do not define database tables.
type Context struct {
Users userrepo.UnitOfWork
Logger *logx.Logger
Health *health.Checker
Middlewares map[string]httpx.Middleware
DB *database.DB
Files storage.Store
Uploads *storage.Uploader
Downloads storage.Presigner
UploadLinks storage.UploadPresigner
FileHTTP http.Handler
storageClose func() error
closeMu sync.Mutex
closed bool
closeOnce sync.Once
closeErr error
}
// Pass the settings loaded by main. The optional form supports lightweight
// tests without infrastructure; selected adapters still require valid settings.
func New(ctx context.Context, logger *logx.Logger, configs ...settings.Config) (result *Context, err error) {
if ctx == nil {
ctx = context.Background()
}
if err = ctx.Err(); err != nil {
return nil, err
}
if len(configs) > 1 {
return nil, errors.New("expected at most one configuration")
}
cfg := settings.Default()
if len(configs) == 1 {
cfg = configs[0]
}
_ = cfg
checker, err := health.New()
if err != nil {
return nil, err
}
c := &Context{Logger: logger, Health: checker, Middlewares: middleware.Defaults()}
defer func() {
if err != nil {
err = errors.Join(err, c.Close())
}
}()
if err = cfg.Validate(); err != nil {
return nil, err
}
dsn, parseErr := mysql.ParseDSN(cfg.Database.DSN)
if parseErr != nil || dsn.DBName == "" {
return nil, errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")
}
dsn.ParseTime = true
dsn.Loc = time.UTC
cfg.Database.DSN = dsn.FormatDSN()
c.DB, err = database.Open(ctx, cfg.Database)
if err != nil {
return nil, err
}
if err = checker.Add(health.Probe{Name: "database", Timeout: cfg.Database.PingTimeout, Check: c.DB.Health}); err != nil {
return nil, err
}
if err = c.InitStorage(ctx, cfg.Storage); err != nil {
return nil, err
}
c.Users = userrepo.NewStore(c.DB)
checkCtx, cancel := context.WithTimeout(ctx, cfg.Database.PingTimeout)
defer cancel()
if err = userrepo.NewStore(c.DB).CheckSchema(checkCtx); err != nil {
return nil, errors.New("user schema is missing or incompatible; apply migrations first")
}
authHook, authErr := middleware.NewAuthFromEnv()
if authErr != nil {
return nil, authErr
}
c.Middlewares["Auth"] = authHook
return c, nil
}
// Close is idempotent, including startup failure and graceful shutdown.
func (c *Context) Close() error {
if c == nil {
return nil
}
c.closeMu.Lock()
defer c.closeMu.Unlock()
c.closed = true
c.closeOnce.Do(func() {
if c.storageClose != nil {
c.closeErr = errors.Join(c.closeErr, c.storageClose())
}
if c.DB != nil {
c.closeErr = errors.Join(c.closeErr, c.DB.Close())
}
})
return c.closeErr
}

View File

@@ -0,0 +1,81 @@
package servicecontext
import (
"context"
"io"
"os"
"path/filepath"
"testing"
"time"
"example.com/userService/internal/settings"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestStartupFailureAndIdempotentCleanup(t *testing.T) {
if os.Getenv("USER_SERVICE_TEST_DATABASE") != "1" {
t.Skip("isolated MySQL required")
}
t.Setenv("USER_SERVICE_API_TOKEN", "test-012345678901234567890123456789")
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY", "test-012345678901234567890123456789")
cfg, err := settings.Load("../../etc/config.yaml")
if err != nil {
t.Fatal(err)
}
observer, err := database.Open(context.Background(), cfg.Database)
if err != nil {
t.Fatal(err)
}
defer observer.Close()
count := func() int {
var n int
if err := observer.QueryRow("SELECT COUNT(*) FROM information_schema.processlist WHERE USER=SUBSTRING_INDEX(CURRENT_USER(),'@',1) AND DB=DATABASE()").Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
baseline := count()
cfg.Server.Log.File = ""
cfg.Server.Log.Writer = io.Discard
cfg.Server.Log.Rotation = logx.RotationConfig{}
logger, err := logx.New(cfg.Server.Log)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
root := t.TempDir()
file := filepath.Join(root, "notADirectory")
if err = os.WriteFile(file, []byte("x"), 0600); err != nil {
t.Fatal(err)
}
cfg.Storage.Root = file
if c, err := New(context.Background(), logger, cfg); err == nil {
c.Close()
t.Fatal("invalid storage root accepted")
}
deadline := time.Now().Add(3 * time.Second)
for count() != baseline {
if time.Now().After(deadline) {
t.Fatal("failed startup leaked MySQL connection")
}
time.Sleep(10 * time.Millisecond)
}
cfg.Storage.Root = filepath.Join(root, "files")
c, err := New(context.Background(), logger, cfg)
if err != nil {
t.Fatal(err)
}
if err = c.Close(); err != nil {
t.Fatal(err)
}
if err = c.Close(); err != nil {
t.Fatal(err)
}
if err = c.DB.Ping(); err == nil {
t.Fatal("closed application retained database")
}
if err = c.InitStorage(context.Background(), cfg.Storage); err == nil {
t.Fatal("closed dependencies reopened storage")
}
}

View File

@@ -0,0 +1,49 @@
package servicecontext
import (
"context"
"errors"
"git.yuntuo.hk/k2safe/go-omni/storage/local"
"example.com/userService/internal/settings"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
// InitStorage is called once, before serving requests. The composition root
// owns provider clients; business services receive only capability interfaces.
func (c *Context) InitStorage(ctx context.Context, cfg settings.StorageConfig) error {
if c == nil || ctx == nil {
return errors.New("storage initialization requires context")
}
c.closeMu.Lock()
defer c.closeMu.Unlock()
if c.closed {
return errors.New("service dependencies are closed")
}
if err := ctx.Err(); err != nil {
return err
}
if c.Files != nil {
return errors.New("storage already initialized")
}
if err := cfg.Validate(); err != nil {
return err
}
files, err := local.New(cfg.Root, cfg.MaxBytes)
if err != nil {
return err
}
signer, err := storage.NewUploadSigner(files, cfg.PublicURL, []byte(cfg.SigningKey), cfg.Policy())
if err != nil {
return err
}
uploader, err := storage.NewUploader(files, cfg.Policy())
if err != nil {
return err
}
c.Files, c.Uploads, c.Downloads, c.UploadLinks, c.FileHTTP = files, uploader, signer, signer, signer
return nil
}

View File

@@ -0,0 +1,193 @@
// Package settings owns this project's configuration; it adds no business identity model.
package settings
import (
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/database"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/storage"
"net/url"
"os"
"strconv"
"strings"
"time"
)
type Config struct {
Server config.Config `json:"server"`
Database database.Config `json:"database"`
Storage StorageConfig `json:"storage"`
}
func Default() Config {
server := config.Default()
server.Address = "127.0.0.1:8080"
server.EnableLogger = true
server.SlowRequestThreshold = 500 * time.Millisecond
server.Log.Service = "example.com/userService"
server.Log.Format = "json"
server.Log.File = "./var/log/server.log"
server.Log.Rotation = logx.RotationConfig{Enabled: true, MaxSizeMB: 100, MaxBackups: 10, MaxAgeDays: 7, Compress: true}
return Config{Server: server, Database: database.DefaultConfig(), Storage: StorageConfig{
Provider: "local", Root: "./var/files", PublicURL: "http://127.0.0.1:8080/storage/object",
Secure: true, MaxBytes: 32 << 20, ContentTypes: []string{"text/plain", "image/png", "image/jpeg", "application/pdf"},
LinkTTL: 5 * time.Minute, RequestTimeout: 30 * time.Second,
}}
}
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
// Pass an empty path to use only defaults and environment.
func Load(path string) (Config, error) {
cfg := Default()
var err error
if path != "" {
cfg, err = config.DecodeFile(path, cfg)
if err != nil {
return Config{}, err
}
}
cfg.Server, err = config.ApplyEnv(cfg.Server, "")
if err != nil {
return Config{}, err
}
// A stream explicitly selected by environment overrides a file profile.
if output := strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT")); output != "" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE")) == "" {
cfg.Server.Log.File = ""
cfg.Server.Log.Rotation = logx.RotationConfig{}
}
if err = cfg.Storage.applyEnv(); err != nil {
return Config{}, err
}
cfg.Database, err = database.ApplyEnv(cfg.Database, "")
if err != nil {
return Config{}, err
}
if err = cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) Validate() error {
if err := c.Database.Validate(); err != nil {
return err
}
if c.Database.Driver != "mysql" || c.Database.MaxOpenConns < 1 {
return errors.New("database requires driver mysql and a positive maxOpenConns")
}
if err := c.Server.Validate(); err != nil {
return err
}
if c.Server.Log.Format != "json" && c.Server.Log.Format != "text" {
return errors.New("log.format must be json or text")
}
switch strings.ToLower(c.Server.Log.Level) {
case "debug", "info", "warn", "warning", "error":
default:
return errors.New("invalid log.level")
}
return c.Storage.Validate()
}
type StorageConfig struct {
Provider string `json:"provider"`
Root string `json:"root"`
PublicURL string `json:"publicUrl"`
Endpoint string `json:"endpoint"`
Region string `json:"region"`
Bucket string `json:"bucket"`
AccountID string `json:"accountId"`
Secure bool `json:"secure"`
MaxBytes int64 `json:"maxBytes"`
ContentTypes []string `json:"contentTypes"`
LinkTTL time.Duration `json:"linkTtl"`
RequestTimeout time.Duration `json:"requestTimeout"`
// Secrets are environment-only and cannot be populated by config files.
FilesToken string `json:"-"`
SigningKey string `json:"-"`
AccessKey string `json:"-"`
SecretKey string `json:"-"`
SessionToken string `json:"-"`
}
func (s StorageConfig) Policy() storage.UploadPolicy {
return storage.UploadPolicy{MaxBytes: s.MaxBytes, ContentTypes: s.ContentTypes}
}
func (s *StorageConfig) applyEnv() error {
values := map[string]*string{
"GO_OMNI_STORAGE_ROOT": &s.Root, "GO_OMNI_STORAGE_PUBLIC_URL": &s.PublicURL,
"GO_OMNI_STORAGE_ENDPOINT": &s.Endpoint, "GO_OMNI_STORAGE_REGION": &s.Region,
"GO_OMNI_STORAGE_BUCKET": &s.Bucket, "GO_OMNI_STORAGE_ACCOUNT_ID": &s.AccountID,
"GO_OMNI_STORAGE_SIGNING_KEY": &s.SigningKey, "GO_OMNI_FILES_TOKEN": &s.FilesToken,
"GO_OMNI_STORAGE_ACCESS_KEY": &s.AccessKey, "GO_OMNI_STORAGE_SECRET_KEY": &s.SecretKey,
"GO_OMNI_STORAGE_SESSION_TOKEN": &s.SessionToken,
}
for name, target := range values {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
*target = value
}
}
for name, target := range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL": &s.LinkTTL, "GO_OMNI_STORAGE_REQUEST_TIMEOUT": &s.RequestTimeout} {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
v, err := time.ParseDuration(value)
if err != nil {
return fmt.Errorf("%s must be a duration", name)
}
*target = v
}
}
if value := os.Getenv("GO_OMNI_STORAGE_MAX_BYTES"); value != "" {
v, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")
}
s.MaxBytes = v
}
if value := os.Getenv("GO_OMNI_STORAGE_SECURE"); value != "" {
v, err := strconv.ParseBool(value)
if err != nil {
return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")
}
s.Secure = v
}
if value := strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES")); value != "" {
s.ContentTypes = strings.Split(value, ",")
for i := range s.ContentTypes {
s.ContentTypes[i] = strings.TrimSpace(s.ContentTypes[i])
}
}
return nil
}
func (s StorageConfig) Validate() error {
if s.Provider != "local" {
return errors.New("storage.provider must match the adapter selected at generation")
}
if err := s.Policy().Validate(); err != nil {
return err
}
if s.LinkTTL < time.Second || s.LinkTTL > time.Hour {
return errors.New("storage.linkTtl must be 1s..1h")
}
if s.RequestTimeout <= 0 || s.RequestTimeout > 5*time.Minute {
return errors.New("storage.requestTimeout must be >0 and <=5m")
}
if s.Root == "" {
return errors.New("storage.root is required")
}
u, err := url.Parse(s.PublicURL)
if err != nil || u.Host == "" || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "/storage/object" {
return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")
}
if len(s.SigningKey) < 32 {
return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")
}
return nil
}

View File

@@ -0,0 +1,35 @@
// Code generated by go-omni api. DO NOT EDIT.
package types
type UserResp struct {
ID int64 `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type CreateUserReq struct {
Name string `json:"name" binding:"required,max=100"`
Email string `json:"email" binding:"required,email,max=254"`
}
type UserIDReq struct {
ID int64 `path:"id,range=[1:9007199254740991]"`
}
type UpdateUserReq struct {
ID int64 `path:"id,range=[1:9007199254740991]"`
Name string `json:"name" binding:"required,max=100"`
Email string `json:"email" binding:"required,email,max=254"`
}
type ListUsersReq struct {
Page int `form:"page,default=1,range=[1:1000000]"`
PageSize int `form:"pageSize,default=20,range=[1:100]"`
}
type UserPageResp struct {
Items []UserResp `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type ExportResp struct {
DownloadURL string `json:"downloadUrl"`
}

View File

@@ -0,0 +1,2 @@
DROP TABLE user_audit;
DROP TABLE users;

View File

@@ -0,0 +1,18 @@
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
email VARCHAR(254) NOT NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_users_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
CREATE TABLE user_audit (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
operation VARCHAR(16) NOT NULL,
request_id VARCHAR(128) NOT NULL,
created_at DATETIME(6) NOT NULL,
INDEX ix_user_audit_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;