feat: add backend_v1 migration
Some checks failed
build quyun / Build (push) Has been cancelled

This commit is contained in:
2025-12-19 14:46:58 +08:00
parent 218eb4689c
commit 24bd161df9
119 changed files with 12259 additions and 0 deletions

View File

@@ -0,0 +1,58 @@
package event
import (
"context"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"quyun/v2/app/commands"
"quyun/v2/app/events/subscribers"
"quyun/v2/providers/app"
"quyun/v2/providers/event"
"quyun/v2/providers/postgres"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.uber.org/dig"
)
func defaultProviders() container.Providers {
return commands.Default(container.Providers{
postgres.DefaultProvider(),
}...)
}
func Command() atom.Option {
return atom.Command(
atom.Name("event"),
atom.Short("start event processor"),
atom.RunE(Serve),
atom.Providers(
defaultProviders().
With(
subscribers.Provide,
),
),
)
}
type Service struct {
dig.In
App *app.Config
PubSub *event.PubSub
Initials []contracts.Initial `group:"initials"`
}
func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(ctx context.Context, svc Service) error {
log.SetFormatter(&log.JSONFormatter{})
if svc.App.IsDevMode() {
log.SetLevel(log.DebugLevel)
}
return svc.PubSub.Serve(ctx)
})
}

View File

@@ -0,0 +1,57 @@
package grpc
import (
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"quyun/v2/app/commands"
"quyun/v2/app/grpc/users"
"quyun/v2/providers/app"
"quyun/v2/providers/grpc"
"quyun/v2/providers/postgres"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.uber.org/dig"
)
func defaultProviders() container.Providers {
return commands.Default(container.Providers{
postgres.DefaultProvider(),
grpc.DefaultProvider(),
}...)
}
func Command() atom.Option {
return atom.Command(
atom.Name("grpc"),
atom.Short("run grpc server"),
atom.RunE(Serve),
atom.Providers(
defaultProviders().
With(
users.Provide,
),
),
)
}
type Service struct {
dig.In
App *app.Config
Grpc *grpc.Grpc
Initials []contracts.Initial `group:"initials"`
}
func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(svc Service) error {
log.SetFormatter(&log.JSONFormatter{})
if svc.App.IsDevMode() {
log.SetLevel(log.DebugLevel)
}
return svc.Grpc.Serve()
})
}

View File

@@ -0,0 +1,79 @@
package http
import (
"context"
"quyun/v2/app/commands"
"quyun/v2/app/errorx"
"quyun/v2/app/jobs"
_ "quyun/v2/docs"
"quyun/v2/providers/app"
"quyun/v2/providers/http"
"quyun/v2/providers/http/swagger"
"quyun/v2/providers/job"
"quyun/v2/providers/jwt"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"github.com/gofiber/fiber/v3/middleware/favicon"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.uber.org/dig"
)
func defaultProviders() container.Providers {
return commands.Default(container.Providers{
http.DefaultProvider(),
jwt.DefaultProvider(),
job.DefaultProvider(),
}...)
}
func Command() atom.Option {
return atom.Command(
atom.Name("serve"),
atom.Short("run http server"),
atom.RunE(Serve),
atom.Providers(
defaultProviders().
With(
jobs.Provide,
),
),
)
}
type Service struct {
dig.In
App *app.Config
Job *job.Job
Http *http.Service
Initials []contracts.Initial `group:"initials"`
Routes []contracts.HttpRoute `group:"routes"`
}
func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(ctx context.Context, svc Service) error {
log.SetFormatter(&log.JSONFormatter{})
if svc.App.Mode == app.AppModeDevelopment {
log.SetLevel(log.DebugLevel)
svc.Http.Engine.Get("/swagger/*", swagger.HandlerDefault)
}
svc.Http.Engine.Use(errorx.Middleware)
svc.Http.Engine.Use(favicon.New(favicon.Config{
Data: []byte{},
}))
group := svc.Http.Engine.Group("")
for _, route := range svc.Routes {
route.Register(group)
}
return svc.Http.Serve(ctx)
})
}

View File

@@ -0,0 +1,35 @@
package migrate
import (
"context"
"database/sql"
"github.com/pkg/errors"
"github.com/pressly/goose/v3"
"github.com/riverqueue/river/riverdriver/riverdatabasesql"
"github.com/riverqueue/river/rivermigrate"
)
func init() {
goose.AddMigrationNoTxContext(RiverQueueUp, RiverQueueDown)
}
func RiverQueueUp(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return errors.Wrap(err, "river migrate up failed")
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
}
func RiverQueueDown(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return errors.Wrap(err, "river migrate down failed")
}
_, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{TargetVersion: -1})
return err
}

View File

@@ -0,0 +1,86 @@
package migrate
import (
"context"
"database/sql"
"quyun/v2/app/commands"
"quyun/v2/database"
"github.com/pressly/goose/v3"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.uber.org/dig"
"github.com/riverqueue/river/riverdriver/riverdatabasesql"
"github.com/riverqueue/river/rivermigrate"
)
func defaultProviders() container.Providers {
return commands.Default(container.Providers{}...)
}
func Command() atom.Option {
return atom.Command(
atom.Name("migrate"),
atom.Short("run migrations"),
atom.RunE(Serve),
atom.Providers(defaultProviders()),
atom.Example("migrate [up|up-by-one|up-to|create|down|down-to|fix|redo|reset|status|version]"),
)
}
type Service struct {
dig.In
DB *sql.DB
}
// migrate
func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(ctx context.Context, svc Service) error {
if len(args) == 0 {
args = append(args, "up")
}
if args[0] == "create" {
return nil
}
action, args := args[0], args[1:]
log.Infof("migration action: %s args: %+v", action, args)
goose.SetBaseFS(database.MigrationFS)
goose.SetTableName("migrations")
goose.AddNamedMigrationNoTxContext("20251219062732_river_job.go", RiverUp, RiverDown)
return goose.RunContext(context.Background(), action, svc.DB, "migrations", args...)
})
}
func RiverUp(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
// Migrate up. An empty MigrateOpts will migrate all the way up, but
// best practice is to specify a specific target version.
_, err = migrator.Migrate(ctx, rivermigrate.DirectionUp, &rivermigrate.MigrateOpts{})
return err
}
func RiverDown(ctx context.Context, db *sql.DB) error {
migrator, err := rivermigrate.New(riverdatabasesql.New(db), nil)
if err != nil {
return err
}
// TargetVersion -1 removes River's schema completely.
_, err = migrator.Migrate(ctx, rivermigrate.DirectionDown, &rivermigrate.MigrateOpts{
TargetVersion: -1,
})
return err
}

View File

@@ -0,0 +1,24 @@
package queue
import (
"context"
"github.com/riverqueue/river"
"github.com/riverqueue/river/rivertype"
log "github.com/sirupsen/logrus"
)
type CustomErrorHandler struct{}
func (*CustomErrorHandler) HandleError(ctx context.Context, job *rivertype.JobRow, err error) *river.ErrorHandlerResult {
log.Infof("Job errored with: %s\n", err)
return nil
}
func (*CustomErrorHandler) HandlePanic(ctx context.Context, job *rivertype.JobRow, panicVal any, trace string) *river.ErrorHandlerResult {
log.Infof("Job panicked with: %v\n", panicVal)
log.Infof("Stack trace: %s\n", trace)
return &river.ErrorHandlerResult{
SetCancelled: true,
}
}

View File

@@ -0,0 +1,67 @@
package queue
import (
"context"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"quyun/v2/app/commands"
"quyun/v2/app/jobs"
"quyun/v2/providers/app"
"quyun/v2/providers/job"
"quyun/v2/providers/postgres"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"go.uber.org/dig"
)
func defaultProviders() container.Providers {
return commands.Default(container.Providers{
postgres.DefaultProvider(),
job.DefaultProvider(),
}...)
}
func Command() atom.Option {
return atom.Command(
atom.Name("queue"),
atom.Short("start queue processor"),
atom.RunE(Serve),
atom.Providers(
defaultProviders().
With(
jobs.Provide,
),
),
)
}
type Service struct {
dig.In
App *app.Config
Job *job.Job
Initials []contracts.Initial `group:"initials"`
CronJobs []contracts.CronJob `group:"cron_jobs"`
}
func Serve(cmd *cobra.Command, args []string) error {
return container.Container.Invoke(func(ctx context.Context, svc Service) error {
log.SetFormatter(&log.JSONFormatter{})
if svc.App.IsDevMode() {
log.SetLevel(log.DebugLevel)
}
if err := svc.Job.Start(ctx); err != nil {
return err
}
defer svc.Job.Close()
<-ctx.Done()
return nil
})
}

View File

@@ -0,0 +1,19 @@
package commands
import (
"quyun/v2/database"
"quyun/v2/providers/app"
"quyun/v2/providers/event"
"quyun/v2/providers/postgres"
"go.ipao.vip/atom/container"
)
func Default(providers ...container.ProviderContainer) container.Providers {
return append(container.Providers{
app.DefaultProvider(),
event.DefaultProvider(),
database.DefaultProvider(),
postgres.DefaultProvider(),
}, providers...)
}

View File

@@ -0,0 +1,30 @@
package testx
import (
"os"
"testing"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"github.com/rogeecn/fabfile"
. "github.com/smartystreets/goconvey/convey"
)
func Default(providers ...container.ProviderContainer) container.Providers {
return append(container.Providers{}, providers...)
}
func Serve(providers container.Providers, t *testing.T, invoke any) {
Convey("tests boot up", t, func() {
file := fabfile.MustFind("config.toml")
localEnv := os.Getenv("ENV_LOCAL")
if localEnv != "" {
file = fabfile.MustFind("config." + localEnv + ".toml")
}
So(atom.LoadProviders(file, providers), ShouldBeNil)
So(container.Container.Invoke(invoke), ShouldBeNil)
})
}

View File

View File

@@ -0,0 +1,65 @@
package errorx
import (
"fmt"
"runtime"
)
// AppError 应用错误结构
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
StatusCode int `json:"-"`
Data any `json:"data,omitempty"`
ID string `json:"id,omitempty"`
// 调试信息
originalErr error
file string
params []any
sql string
}
// Error 实现 error 接口
func (e *AppError) Error() string {
return fmt.Sprintf("[%d] %s", e.Code, e.Message)
}
// Unwrap 允许通过 errors.Unwrap 遍历到原始错误
func (e *AppError) Unwrap() error { return e.originalErr }
// WithData 添加数据
func (e *AppError) WithData(data any) *AppError {
e.Data = data
return e
}
// WithMsg 设置消息
func (e *AppError) WithMsg(msg string) *AppError {
e.Message = msg
return e
}
// WithSQL 记录SQL信息
func (e *AppError) WithSQL(sql string) *AppError {
e.sql = sql
return e
}
// WithParams 记录参数信息,并自动获取调用位置
func (e *AppError) WithParams(params ...any) *AppError {
e.params = params
if _, file, line, ok := runtime.Caller(1); ok {
e.file = fmt.Sprintf("%s:%d", file, line)
}
return e
}
// NewError 创建应用错误
func NewError(code ErrorCode, statusCode int, message string) *AppError {
return &AppError{
Code: code,
Message: message,
StatusCode: statusCode,
}
}

View File

@@ -0,0 +1,90 @@
package errorx
// ErrorCode 错误码类型
type ErrorCode int
const (
// 1000-1099: 数据相关错误
CodeRecordNotFound ErrorCode = 1001
CodeRecordDuplicated ErrorCode = 1002
CodeDataCorrupted ErrorCode = 1003
CodeDataTooLarge ErrorCode = 1004
CodeDataValidationFail ErrorCode = 1005
CodeConstraintViolated ErrorCode = 1006
CodeDataExpired ErrorCode = 1007
CodeDataLocked ErrorCode = 1008
// 1100-1199: 请求相关错误
CodeBadRequest ErrorCode = 1101
CodeMissingParameter ErrorCode = 1102
CodeInvalidParameter ErrorCode = 1103
CodeParameterTooLong ErrorCode = 1104
CodeParameterTooShort ErrorCode = 1105
CodeInvalidFormat ErrorCode = 1106
CodeUnsupportedMethod ErrorCode = 1107
CodeRequestTooLarge ErrorCode = 1108
CodeInvalidJSON ErrorCode = 1109
CodeInvalidXML ErrorCode = 1110
// 1200-1299: 认证授权错误
CodeUnauthorized ErrorCode = 1201
CodeForbidden ErrorCode = 1202
CodeTokenExpired ErrorCode = 1203
CodeTokenInvalid ErrorCode = 1204
CodeTokenMissing ErrorCode = 1205
CodePermissionDenied ErrorCode = 1206
CodeAccountDisabled ErrorCode = 1207
CodeAccountLocked ErrorCode = 1208
CodeInvalidCredentials ErrorCode = 1209
CodeSessionExpired ErrorCode = 1210
// 1300-1399: 业务逻辑错误
CodeBusinessLogic ErrorCode = 1301
CodeWorkflowError ErrorCode = 1302
CodeStatusConflict ErrorCode = 1303
CodeOperationFailed ErrorCode = 1304
CodeResourceConflict ErrorCode = 1305
CodePreconditionFailed ErrorCode = 1306
CodeQuotaExceeded ErrorCode = 1307
CodeResourceExhausted ErrorCode = 1308
// 1400-1499: 外部服务错误
CodeExternalService ErrorCode = 1401
CodeServiceUnavailable ErrorCode = 1402
CodeServiceTimeout ErrorCode = 1403
CodeThirdPartyError ErrorCode = 1404
CodeNetworkError ErrorCode = 1405
CodeDatabaseError ErrorCode = 1406
CodeCacheError ErrorCode = 1407
CodeMessageQueueError ErrorCode = 1408
// 1500-1599: 系统错误
CodeInternalError ErrorCode = 1501
CodeConfigurationError ErrorCode = 1502
CodeFileSystemError ErrorCode = 1503
CodeMemoryError ErrorCode = 1504
CodeConcurrencyError ErrorCode = 1505
CodeDeadlockError ErrorCode = 1506
// 1600-1699: 限流和频率控制
CodeRateLimitExceeded ErrorCode = 1601
CodeTooManyRequests ErrorCode = 1602
CodeConcurrentLimit ErrorCode = 1603
CodeAPIQuotaExceeded ErrorCode = 1604
// 1700-1799: 文件和上传错误
CodeFileNotFound ErrorCode = 1701
CodeFileTooBig ErrorCode = 1702
CodeInvalidFileType ErrorCode = 1703
CodeFileCorrupted ErrorCode = 1704
CodeUploadFailed ErrorCode = 1705
CodeDownloadFailed ErrorCode = 1706
CodeFilePermission ErrorCode = 1707
// 1800-1899: 加密和安全错误
CodeEncryptionError ErrorCode = 1801
CodeDecryptionError ErrorCode = 1802
CodeSignatureInvalid ErrorCode = 1803
CodeCertificateInvalid ErrorCode = 1804
CodeSecurityViolation ErrorCode = 1805
)

View File

@@ -0,0 +1,105 @@
package errorx
import (
"errors"
"net/http"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// ErrorHandler 错误处理器
type ErrorHandler struct{}
// NewErrorHandler 创建错误处理器
func NewErrorHandler() *ErrorHandler {
return &ErrorHandler{}
}
// Handle 处理错误并返回统一格式
func (h *ErrorHandler) Handle(err error) *AppError {
if appErr, ok := err.(*AppError); ok {
return appErr
}
// 处理 Fiber 错误
if fiberErr, ok := err.(*fiber.Error); ok {
return h.handleFiberError(fiberErr)
}
// 处理 GORM 错误
if appErr := h.handleGormError(err); appErr != nil {
return appErr
}
// 默认内部错误
return &AppError{
Code: ErrInternalError.Code,
Message: err.Error(),
StatusCode: http.StatusInternalServerError,
originalErr: err,
}
}
// handleFiberError 处理 Fiber 错误
func (h *ErrorHandler) handleFiberError(fiberErr *fiber.Error) *AppError {
var appErr *AppError
switch fiberErr.Code {
case http.StatusBadRequest:
appErr = ErrBadRequest
case http.StatusUnauthorized:
appErr = ErrUnauthorized
case http.StatusForbidden:
appErr = ErrForbidden
case http.StatusNotFound:
appErr = ErrRecordNotFound
case http.StatusMethodNotAllowed:
appErr = ErrUnsupportedMethod
case http.StatusRequestEntityTooLarge:
appErr = ErrRequestTooLarge
case http.StatusTooManyRequests:
appErr = ErrTooManyRequests
default:
appErr = ErrInternalError
}
return &AppError{
Code: appErr.Code,
Message: fiberErr.Message,
StatusCode: fiberErr.Code,
originalErr: fiberErr,
}
}
// handleGormError 处理 GORM 错误
func (h *ErrorHandler) handleGormError(err error) *AppError {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &AppError{
Code: ErrRecordNotFound.Code,
Message: ErrRecordNotFound.Message,
StatusCode: ErrRecordNotFound.StatusCode,
originalErr: err,
}
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return &AppError{
Code: ErrRecordDuplicated.Code,
Message: ErrRecordDuplicated.Message,
StatusCode: ErrRecordDuplicated.StatusCode,
originalErr: err,
}
}
if errors.Is(err, gorm.ErrInvalidTransaction) {
return &AppError{
Code: ErrConcurrencyError.Code,
Message: "事务无效",
StatusCode: ErrConcurrencyError.StatusCode,
originalErr: err,
}
}
return nil
}

View File

@@ -0,0 +1,38 @@
package errorx
import "github.com/gofiber/fiber/v3"
// 全局实例
var DefaultSender = NewResponseSender()
// Middleware 错误处理中间件
func Middleware(c fiber.Ctx) error {
err := c.Next()
if err != nil {
return DefaultSender.SendError(c, err)
}
return nil
}
// 便捷函数
func Wrap(err error) *AppError {
if err == nil {
return nil
}
if appErr, ok := err.(*AppError); ok {
return &AppError{
Code: appErr.Code,
Message: appErr.Message,
StatusCode: appErr.StatusCode,
Data: appErr.Data,
originalErr: appErr,
}
}
return DefaultSender.handler.Handle(err)
}
func SendError(ctx fiber.Ctx, err error) error {
return DefaultSender.SendError(ctx, err)
}

View File

@@ -0,0 +1,105 @@
package errorx
import "net/http"
// 预定义错误 - 数据相关
var (
ErrRecordNotFound = NewError(CodeRecordNotFound, http.StatusNotFound, "记录不存在")
ErrRecordDuplicated = NewError(CodeRecordDuplicated, http.StatusConflict, "记录重复")
ErrDataCorrupted = NewError(CodeDataCorrupted, http.StatusBadRequest, "数据损坏")
ErrDataTooLarge = NewError(CodeDataTooLarge, http.StatusRequestEntityTooLarge, "数据过大")
ErrDataValidationFail = NewError(CodeDataValidationFail, http.StatusBadRequest, "数据验证失败")
ErrConstraintViolated = NewError(CodeConstraintViolated, http.StatusConflict, "约束违规")
ErrDataExpired = NewError(CodeDataExpired, http.StatusGone, "数据已过期")
ErrDataLocked = NewError(CodeDataLocked, http.StatusLocked, "数据已锁定")
)
// 预定义错误 - 请求相关
var (
ErrBadRequest = NewError(CodeBadRequest, http.StatusBadRequest, "请求错误")
ErrMissingParameter = NewError(CodeMissingParameter, http.StatusBadRequest, "缺少必需参数")
ErrInvalidParameter = NewError(CodeInvalidParameter, http.StatusBadRequest, "参数无效")
ErrParameterTooLong = NewError(CodeParameterTooLong, http.StatusBadRequest, "参数过长")
ErrParameterTooShort = NewError(CodeParameterTooShort, http.StatusBadRequest, "参数过短")
ErrInvalidFormat = NewError(CodeInvalidFormat, http.StatusBadRequest, "格式无效")
ErrUnsupportedMethod = NewError(CodeUnsupportedMethod, http.StatusMethodNotAllowed, "不支持的请求方法")
ErrRequestTooLarge = NewError(CodeRequestTooLarge, http.StatusRequestEntityTooLarge, "请求体过大")
ErrInvalidJSON = NewError(CodeInvalidJSON, http.StatusBadRequest, "JSON格式错误")
ErrInvalidXML = NewError(CodeInvalidXML, http.StatusBadRequest, "XML格式错误")
)
// 预定义错误 - 认证授权
var (
ErrUnauthorized = NewError(CodeUnauthorized, http.StatusUnauthorized, "未授权")
ErrForbidden = NewError(CodeForbidden, http.StatusForbidden, "禁止访问")
ErrTokenExpired = NewError(CodeTokenExpired, http.StatusUnauthorized, "Token已过期")
ErrTokenInvalid = NewError(CodeTokenInvalid, http.StatusUnauthorized, "Token无效")
ErrTokenMissing = NewError(CodeTokenMissing, http.StatusUnauthorized, "Token缺失")
ErrPermissionDenied = NewError(CodePermissionDenied, http.StatusForbidden, "权限不足")
ErrAccountDisabled = NewError(CodeAccountDisabled, http.StatusForbidden, "账户已禁用")
ErrAccountLocked = NewError(CodeAccountLocked, http.StatusLocked, "账户已锁定")
ErrInvalidCredentials = NewError(CodeInvalidCredentials, http.StatusUnauthorized, "凭据无效")
ErrSessionExpired = NewError(CodeSessionExpired, http.StatusUnauthorized, "会话已过期")
)
// 预定义错误 - 业务逻辑
var (
ErrBusinessLogic = NewError(CodeBusinessLogic, http.StatusBadRequest, "业务逻辑错误")
ErrWorkflowError = NewError(CodeWorkflowError, http.StatusBadRequest, "工作流错误")
ErrStatusConflict = NewError(CodeStatusConflict, http.StatusConflict, "状态冲突")
ErrOperationFailed = NewError(CodeOperationFailed, http.StatusInternalServerError, "操作失败")
ErrResourceConflict = NewError(CodeResourceConflict, http.StatusConflict, "资源冲突")
ErrPreconditionFailed = NewError(CodePreconditionFailed, http.StatusPreconditionFailed, "前置条件失败")
ErrQuotaExceeded = NewError(CodeQuotaExceeded, http.StatusForbidden, "配额超限")
ErrResourceExhausted = NewError(CodeResourceExhausted, http.StatusTooManyRequests, "资源耗尽")
)
// 预定义错误 - 外部服务
var (
ErrExternalService = NewError(CodeExternalService, http.StatusBadGateway, "外部服务错误")
ErrServiceUnavailable = NewError(CodeServiceUnavailable, http.StatusServiceUnavailable, "服务不可用")
ErrServiceTimeout = NewError(CodeServiceTimeout, http.StatusRequestTimeout, "服务超时")
ErrThirdPartyError = NewError(CodeThirdPartyError, http.StatusBadGateway, "第三方服务错误")
ErrNetworkError = NewError(CodeNetworkError, http.StatusBadGateway, "网络错误")
ErrDatabaseError = NewError(CodeDatabaseError, http.StatusInternalServerError, "数据库错误")
ErrCacheError = NewError(CodeCacheError, http.StatusInternalServerError, "缓存错误")
ErrMessageQueueError = NewError(CodeMessageQueueError, http.StatusInternalServerError, "消息队列错误")
)
// 预定义错误 - 系统错误
var (
ErrInternalError = NewError(CodeInternalError, http.StatusInternalServerError, "内部错误")
ErrConfigurationError = NewError(CodeConfigurationError, http.StatusInternalServerError, "配置错误")
ErrFileSystemError = NewError(CodeFileSystemError, http.StatusInternalServerError, "文件系统错误")
ErrMemoryError = NewError(CodeMemoryError, http.StatusInternalServerError, "内存错误")
ErrConcurrencyError = NewError(CodeConcurrencyError, http.StatusInternalServerError, "并发错误")
ErrDeadlockError = NewError(CodeDeadlockError, http.StatusInternalServerError, "死锁错误")
)
// 预定义错误 - 限流
var (
ErrRateLimitExceeded = NewError(CodeRateLimitExceeded, http.StatusTooManyRequests, "请求频率超限")
ErrTooManyRequests = NewError(CodeTooManyRequests, http.StatusTooManyRequests, "请求过多")
ErrConcurrentLimit = NewError(CodeConcurrentLimit, http.StatusTooManyRequests, "并发数超限")
ErrAPIQuotaExceeded = NewError(CodeAPIQuotaExceeded, http.StatusTooManyRequests, "API配额超限")
)
// 预定义错误 - 文件处理
var (
ErrFileNotFound = NewError(CodeFileNotFound, http.StatusNotFound, "文件不存在")
ErrFileTooBig = NewError(CodeFileTooBig, http.StatusRequestEntityTooLarge, "文件过大")
ErrInvalidFileType = NewError(CodeInvalidFileType, http.StatusBadRequest, "文件类型无效")
ErrFileCorrupted = NewError(CodeFileCorrupted, http.StatusBadRequest, "文件损坏")
ErrUploadFailed = NewError(CodeUploadFailed, http.StatusInternalServerError, "上传失败")
ErrDownloadFailed = NewError(CodeDownloadFailed, http.StatusInternalServerError, "下载失败")
ErrFilePermission = NewError(CodeFilePermission, http.StatusForbidden, "文件权限不足")
)
// 预定义错误 - 安全相关
var (
ErrEncryptionError = NewError(CodeEncryptionError, http.StatusInternalServerError, "加密错误")
ErrDecryptionError = NewError(CodeDecryptionError, http.StatusInternalServerError, "解密错误")
ErrSignatureInvalid = NewError(CodeSignatureInvalid, http.StatusUnauthorized, "签名无效")
ErrCertificateInvalid = NewError(CodeCertificateInvalid, http.StatusUnauthorized, "证书无效")
ErrSecurityViolation = NewError(CodeSecurityViolation, http.StatusForbidden, "安全违规")
)

View File

@@ -0,0 +1,127 @@
package errorx
import (
"errors"
"fmt"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/binder"
"github.com/gofiber/utils/v2"
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ResponseSender 响应发送器
type ResponseSender struct {
handler *ErrorHandler
}
// NewResponseSender 创建响应发送器
func NewResponseSender() *ResponseSender {
return &ResponseSender{
handler: NewErrorHandler(),
}
}
// SendError 发送错误响应
func (s *ResponseSender) SendError(ctx fiber.Ctx, err error) error {
appErr := s.handler.Handle(err)
// 记录错误日志
s.logError(appErr)
// 根据 Content-Type 返回不同格式
return s.sendResponse(ctx, appErr)
}
// logError 记录错误日志
func (s *ResponseSender) logError(appErr *AppError) {
// 确保每个错误实例都有唯一ID便于日志关联
if appErr.ID == "" {
appErr.ID = uuid.NewString()
}
// 构造详细的错误级联链路(包含类型、状态、定位等)
chain := make([]map[string]any, 0, 4)
var e error = appErr
for e != nil {
entry := map[string]any{
"type": fmt.Sprintf("%T", e),
"error": e.Error(),
}
switch v := e.(type) {
case *AppError:
entry["code"] = v.Code
entry["statusCode"] = v.StatusCode
if v.file != "" {
entry["file"] = v.file
}
if len(v.params) > 0 {
entry["params"] = v.params
}
if v.sql != "" {
entry["sql"] = v.sql
}
if v.ID != "" {
entry["id"] = v.ID
}
case *fiber.Error:
entry["statusCode"] = v.Code
entry["message"] = v.Message
}
// GORM 常见错误归类标记
if errors.Is(e, gorm.ErrRecordNotFound) {
entry["gorm"] = "record_not_found"
} else if errors.Is(e, gorm.ErrDuplicatedKey) {
entry["gorm"] = "duplicated_key"
} else if errors.Is(e, gorm.ErrInvalidTransaction) {
entry["gorm"] = "invalid_transaction"
}
chain = append(chain, entry)
e = errors.Unwrap(e)
}
root := chain[len(chain)-1]["error"]
logEntry := log.WithFields(log.Fields{
"id": appErr.ID,
"code": appErr.Code,
"statusCode": appErr.StatusCode,
"file": appErr.file,
"sql": appErr.sql,
"params": appErr.params,
"error_chain": chain,
"root_error": root,
})
if appErr.originalErr != nil {
logEntry = logEntry.WithError(appErr.originalErr)
}
// 根据错误级别记录不同级别的日志
if appErr.StatusCode >= 500 {
logEntry.Error("系统错误: ", appErr.Message)
} else if appErr.StatusCode >= 400 {
logEntry.Warn("客户端错误: ", appErr.Message)
} else {
logEntry.Info("应用错误: ", appErr.Message)
}
}
// sendResponse 发送响应
func (s *ResponseSender) sendResponse(ctx fiber.Ctx, appErr *AppError) error {
contentType := utils.ToLower(utils.UnsafeString(ctx.Request().Header.ContentType()))
contentType = binder.FilterFlags(utils.ParseVendorSpecificContentType(contentType))
switch contentType {
case fiber.MIMETextXML, fiber.MIMEApplicationXML:
return ctx.Status(appErr.StatusCode).XML(appErr)
case fiber.MIMETextHTML, fiber.MIMETextPlain:
return ctx.Status(appErr.StatusCode).SendString(appErr.Message)
default:
return ctx.Status(appErr.StatusCode).JSON(appErr)
}
}

View File

@@ -0,0 +1,26 @@
package publishers
import (
"encoding/json"
"quyun/v2/app/events"
"quyun/v2/providers/event"
"go.ipao.vip/atom/contracts"
)
var _ contracts.EventPublisher = (*UserRegister)(nil)
type UserRegister struct {
event.DefaultChannel
ID int64 `json:"id"`
}
func (e *UserRegister) Marshal() ([]byte, error) {
return json.Marshal(e)
}
func (e *UserRegister) Topic() string {
return events.TopicUserRegister
}

View File

@@ -0,0 +1,27 @@
package subscribers
import (
"quyun/v2/providers/event"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
__event *event.PubSub,
) (contracts.Initial, error) {
obj := &UserRegister{}
if err := obj.Prepare(); err != nil {
return nil, err
}
__event.Handle("handler:UserRegister", obj)
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,45 @@
package subscribers
import (
"encoding/json"
"quyun/v2/app/events"
"quyun/v2/app/events/publishers"
"quyun/v2/providers/event"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/sirupsen/logrus"
"go.ipao.vip/atom/contracts"
)
var _ contracts.EventHandler = (*UserRegister)(nil)
// @provider(event)
type UserRegister struct {
event.DefaultChannel
event.DefaultPublishTo
log *logrus.Entry `inject:"false"`
}
func (e *UserRegister) Prepare() error {
e.log = logrus.WithField("module", "events.subscribers.user_register")
return nil
}
// Topic implements contracts.EventHandler.
func (e *UserRegister) Topic() string {
return events.TopicUserRegister
}
// Handler implements contracts.EventHandler.
func (e *UserRegister) Handler(msg *message.Message) ([]*message.Message, error) {
var payload publishers.UserRegister
err := json.Unmarshal(msg.Payload, &payload)
if err != nil {
return nil, err
}
e.log.Infof("received event %s", msg.Payload)
return nil, nil
}

View File

@@ -0,0 +1,24 @@
package subscribers
import (
"encoding/json"
"github.com/ThreeDotsLabs/watermill"
"github.com/ThreeDotsLabs/watermill/message"
)
func toMessage(event any) (*message.Message, error) {
b, err := json.Marshal(event)
if err != nil {
return nil, err
}
return message.NewMessage(watermill.NewUUID(), b), nil
}
func toMessageList(event any) ([]*message.Message, error) {
m, err := toMessage(event)
if err != nil {
return nil, err
}
return []*message.Message{m}, nil
}

View File

@@ -0,0 +1,6 @@
package events
const (
TopicProcessed = "event:processed"
TopicUserRegister = "event:user_register"
)

View File

@@ -0,0 +1,26 @@
package users
import (
"context"
userv1 "quyun/v2/pkg/proto/user/v1"
)
// @provider(grpc) userv1.RegisterUserServiceServer
type Users struct {
userv1.UnimplementedUserServiceServer
}
func (u *Users) ListUsers(ctx context.Context, in *userv1.ListUsersRequest) (*userv1.ListUsersResponse, error) {
// userv1.UserServiceServer
return &userv1.ListUsersResponse{}, nil
}
// GetUser implements userv1.UserServiceServer
func (u *Users) GetUser(ctx context.Context, in *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
return &userv1.GetUserResponse{
User: &userv1.User{
Id: in.Id,
},
}, nil
}

View File

@@ -0,0 +1,25 @@
package users
import (
userv1 "quyun/v2/pkg/proto/user/v1"
"quyun/v2/providers/grpc"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
__grpc *grpc.Grpc,
) (contracts.Initial, error) {
obj := &Users{}
userv1.RegisterUserServiceServer(__grpc.Server, obj)
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,76 @@
package v1
import (
"mime/multipart"
"quyun/v2/app/errorx"
"quyun/v2/app/requests"
"quyun/v2/app/services"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
)
// @provider
type demo struct{}
type FooUploadReq struct {
Folder string `json:"folder" form:"folder"` // 上传到指定文件夹
}
type FooQuery struct {
Search string `query:"search"` // 搜索关键词
}
type FooHeader struct {
ContentType string `header:"Content-Type"` // 内容类型
}
type Filter struct {
Name string `query:"name"` // 名称
Age int `query:"age"` // 年龄
}
type ResponseItem struct{}
// Foo
//
// @Summary Test
// @Description Test
// @Tags Test
// @Accept json
// @Produce json
//
// @Param id path int true "ID"
// @Param query query Filter true "Filter"
// @Param pager query requests.Pagination true "Pager"
// @Success 200 {object} requests.Pager{list=ResponseItem} "成功"
//
// @Router /v1/medias/:id [post]
// @Bind query query
// @Bind pager query
// @Bind header header
// @Bind id path
// @Bind req body
// @Bind file file
// @Bind claim local
func (d *demo) Foo(
ctx fiber.Ctx,
id int,
pager *requests.Pagination,
query *FooQuery,
header *FooHeader,
claim *jwt.Claims,
file *multipart.FileHeader,
req *FooUploadReq,
) error {
_, err := services.Test.Test(ctx)
if err != nil {
// 示例:在控制器层自定义错误消息/附加数据
appErr := errorx.Wrap(err).
WithMsg("获取测试失败").
WithData(fiber.Map{"route": "/v1/test"}).
WithParams("handler", "Test.Hello")
return appErr
}
return nil
}

View File

@@ -0,0 +1,37 @@
package v1
import (
"quyun/v2/app/middlewares"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func() (*demo, error) {
obj := &demo{}
return obj, nil
}); err != nil {
return err
}
if err := container.Container.Provide(func(
demo *demo,
middlewares *middlewares.Middlewares,
) (contracts.HttpRoute, error) {
obj := &Routes{
demo: demo,
middlewares: middlewares,
}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupRoutes); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,60 @@
// Code generated by atomctl. DO NOT EDIT.
// Package v1 provides HTTP route definitions and registration
// for the quyun/v2 application.
package v1
import (
"mime/multipart"
"quyun/v2/app/middlewares"
"quyun/v2/app/requests"
"quyun/v2/providers/jwt"
"github.com/gofiber/fiber/v3"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
_ "go.ipao.vip/atom/contracts"
. "go.ipao.vip/atom/fen"
)
// Routes implements the HttpRoute contract and provides route registration
// for all controllers in the v1 module.
//
// @provider contracts.HttpRoute atom.GroupRoutes
type Routes struct {
log *log.Entry `inject:"false"`
middlewares *middlewares.Middlewares
// Controller instances
demo *demo
}
// Prepare initializes the routes provider with logging configuration.
func (r *Routes) Prepare() error {
r.log = log.WithField("module", "routes.v1")
r.log.Info("Initializing routes module")
return nil
}
// Name returns the unique identifier for this routes provider.
func (r *Routes) Name() string {
return "v1"
}
// Register registers all HTTP routes with the provided fiber router.
// Each route is registered with its corresponding controller action and parameter bindings.
func (r *Routes) Register(router fiber.Router) {
// Register routes for controller: demo
r.log.Debugf("Registering route: Post /v1/medias/:id -> demo.Foo")
router.Post("/v1/medias/:id"[len(r.Path()):], Func7(
r.demo.Foo,
PathParam[int]("id"),
Query[requests.Pagination]("pager"),
Query[FooQuery]("query"),
Header[FooHeader]("header"),
Local[*jwt.Claims]("claim"),
File[multipart.FileHeader]("file"),
Body[FooUploadReq]("req"),
))
r.log.Info("Successfully registered all routes")
}

View File

@@ -0,0 +1,9 @@
package v1
func (r *Routes) Path() string {
return "/v1"
}
func (r *Routes) Middlewares() []any {
return []any{}
}

View File

@@ -0,0 +1,36 @@
package jobs
import (
"time"
. "github.com/riverqueue/river"
"github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
)
var _ contracts.CronJob = (*DemoCronJob)(nil)
// @provider(cronjob)
type DemoCronJob struct {
log *logrus.Entry `inject:"false"`
}
// Prepare implements contracts.CronJob.
func (DemoCronJob) Prepare() error {
return nil
}
// JobArgs implements contracts.CronJob.
func (DemoCronJob) Args() []contracts.CronJobArg {
return []contracts.CronJobArg{
{
Arg: DemoJob{
Strings: []string{"a", "b", "c", "d"},
},
PeriodicInterval: PeriodicInterval(time.Second * 10),
RunOnStart: false,
},
}
}

View File

@@ -0,0 +1,53 @@
package jobs
import (
"context"
"sort"
"time"
. "github.com/riverqueue/river"
log "github.com/sirupsen/logrus"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
_ "go.ipao.vip/atom/contracts"
)
var _ contracts.JobArgs = DemoJob{}
type DemoJob struct {
Strings []string `json:"strings"`
}
func (s DemoJob) InsertOpts() InsertOpts {
return InsertOpts{
Queue: QueueDefault,
Priority: PriorityDefault,
}
}
func (DemoJob) Kind() string { return "demo_job" }
func (a DemoJob) UniqueID() string { return a.Kind() }
var _ Worker[DemoJob] = (*DemoJobWorker)(nil)
// @provider(job)
type DemoJobWorker struct {
WorkerDefaults[DemoJob]
}
func (w *DemoJobWorker) NextRetry(job *Job[DemoJob]) time.Time {
return time.Now().Add(30 * time.Second)
}
func (w *DemoJobWorker) Work(ctx context.Context, job *Job[DemoJob]) error {
logger := log.WithField("job", job.Args.Kind())
logger.Infof("[START] %s args: %v", job.Args.Kind(), job.Args.Strings)
defer logger.Infof("[END] %s", job.Args.Kind())
// modify below
sort.Strings(job.Args.Strings)
logger.Infof("[%s] Sorted strings: %v\n", time.Now().Format(time.TimeOnly), job.Args.Strings)
return nil
}

View File

@@ -0,0 +1,53 @@
package jobs
import (
"context"
"testing"
"quyun/v2/app/commands/testx"
"quyun/v2/app/services"
. "github.com/riverqueue/river"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/suite"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type DemoJobSuiteInjectParams struct {
dig.In
Initials []contracts.Initial `group:"initials"` // nolint:structcheck
}
type DemoJobSuite struct {
suite.Suite
DemoJobSuiteInjectParams
}
func Test_DemoJob(t *testing.T) {
providers := testx.Default().With(Provide, services.Provide)
testx.Serve(providers, t, func(p DemoJobSuiteInjectParams) {
suite.Run(t, &DemoJobSuite{DemoJobSuiteInjectParams: p})
})
}
func (t *DemoJobSuite) Test_Work() {
Convey("test_work", t.T(), func() {
Convey("step 1", func() {
job := &Job[DemoJob]{
Args: DemoJob{
Strings: []string{"a", "b", "c"},
},
}
worker := &DemoJobWorker{}
err := worker.Work(context.Background(), job)
So(err, ShouldBeNil)
})
})
}

View File

@@ -0,0 +1,41 @@
package jobs
import (
"quyun/v2/providers/job"
"github.com/riverqueue/river"
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
__job *job.Job,
) (contracts.Initial, error) {
obj := &DemoCronJob{}
if err := obj.Prepare(); err != nil {
return nil, err
}
container.Later(func() error { return __job.AddPeriodicJobs(obj) })
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
if err := container.Container.Provide(func(
__job *job.Job,
) (contracts.Initial, error) {
obj := &DemoJobWorker{}
if err := river.AddWorkerSafely(__job.Workers, obj); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,9 @@
package middlewares
import (
"github.com/gofiber/fiber/v3"
)
func (f *Middlewares) DebugMode(c fiber.Ctx) error {
return c.Next()
}

View File

@@ -0,0 +1,15 @@
package middlewares
import (
log "github.com/sirupsen/logrus"
)
// @provider
type Middlewares struct {
log *log.Entry `inject:"false"`
}
func (f *Middlewares) Prepare() error {
f.log = log.WithField("module", "middleware")
return nil
}

View File

@@ -0,0 +1,20 @@
package middlewares
import (
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/opt"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func() (*Middlewares, error) {
obj := &Middlewares{}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,30 @@
package requests
import "github.com/samber/lo"
type Pager struct {
Pagination `json:",inline"`
Total int64 `json:"total"`
Items any `json:"items"`
}
type Pagination struct {
Page int64 `json:"page" form:"page" query:"page"`
Limit int64 `json:"limit" form:"limit" query:"limit"`
}
func (filter *Pagination) Offset() int64 {
return (filter.Page - 1) * filter.Limit
}
func (filter *Pagination) Format() *Pagination {
if filter.Page <= 0 {
filter.Page = 1
}
if !lo.Contains([]int64{10, 20, 50, 100}, filter.Limit) {
filter.Limit = 10
}
return filter
}

View File

@@ -0,0 +1,41 @@
package requests
import (
"strings"
"github.com/samber/lo"
)
type SortQueryFilter struct {
Asc *string `json:"asc" form:"asc"`
Desc *string `json:"desc" form:"desc"`
}
func (s *SortQueryFilter) AscFields() []string {
if s.Asc == nil {
return nil
}
return strings.Split(*s.Asc, ",")
}
func (s *SortQueryFilter) DescFields() []string {
if s.Desc == nil {
return nil
}
return strings.Split(*s.Desc, ",")
}
func (s *SortQueryFilter) DescID() *SortQueryFilter {
if s.Desc == nil {
s.Desc = lo.ToPtr("id")
}
items := s.DescFields()
if lo.Contains(items, "id") {
return s
}
items = append(items, "id")
s.Desc = lo.ToPtr(strings.Join(items, ","))
return s
}

View File

@@ -0,0 +1,36 @@
package services
import (
"go.ipao.vip/atom"
"go.ipao.vip/atom/container"
"go.ipao.vip/atom/contracts"
"go.ipao.vip/atom/opt"
"gorm.io/gorm"
)
func Provide(opts ...opt.Option) error {
if err := container.Container.Provide(func(
db *gorm.DB,
test *test,
) (contracts.Initial, error) {
obj := &services{
db: db,
test: test,
}
if err := obj.Prepare(); err != nil {
return nil, err
}
return obj, nil
}, atom.GroupInitial); err != nil {
return err
}
if err := container.Container.Provide(func() (*test, error) {
obj := &test{}
return obj, nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,28 @@
package services
import (
"gorm.io/gorm"
)
var _db *gorm.DB
// exported CamelCase Services
var (
Test *test
)
// @provider(model)
type services struct {
db *gorm.DB
// define Services
test *test
}
func (svc *services) Prepare() error {
_db = svc.db
// set exported Services here
Test = svc.test
return nil
}

View File

@@ -0,0 +1,10 @@
package services
import "context"
// @provider
type test struct{}
func (t *test) Test(ctx context.Context) (string, error) {
return "Test", nil
}

View File

@@ -0,0 +1,41 @@
package services
import (
"testing"
"time"
"quyun/v2/app/commands/testx"
. "github.com/smartystreets/goconvey/convey"
"github.com/stretchr/testify/suite"
_ "go.ipao.vip/atom"
"go.ipao.vip/atom/contracts"
"go.uber.org/dig"
)
type TestSuiteInjectParams struct {
dig.In
Initials []contracts.Initial `group:"initials"` // nolint:structcheck
}
type TestSuite struct {
suite.Suite
TestSuiteInjectParams
}
func Test_Test(t *testing.T) {
providers := testx.Default().With(Provide)
testx.Serve(providers, t, func(p TestSuiteInjectParams) {
suite.Run(t, &TestSuite{TestSuiteInjectParams: p})
})
}
func (t *TestSuite) Test_Test() {
Convey("test_work", t.T(), func() {
t.T().Log("start test at", time.Now())
})
}