first commit

main
熊二 2 months ago
commit 8216396227
  1. 51
      .air.toml
  2. 134
      .env
  3. 1
      .gitattributes
  4. 10
      .gitignore
  5. 51
      README.md
  6. 49
      app/app.go
  7. 99
      app/http/http.go
  8. 148
      app/http/middleware/auth.go
  9. 239
      app/http/middleware/cors.go
  10. 37
      app/http/middleware/error_handler.go
  11. 49
      app/http/routes/init.go
  12. 32
      app/http/routes/print.go
  13. 7
      app/http/routes/system.go
  14. 7
      app/http/routes/tenant.go
  15. 7
      app/listeners/listeners.go
  16. 38
      app/models/account.go
  17. 40
      app/models/address.go
  18. 22
      app/models/certification.go
  19. 52
      app/models/customer.go
  20. 12
      app/models/customer_tack.go
  21. 31
      app/models/department.go
  22. 38
      app/models/distribution_plan.go
  23. 50
      app/models/district.go
  24. 5296
      app/models/district.json
  25. 32
      app/models/employee.go
  26. 34
      app/models/file.go
  27. 52
      app/models/inbound.go
  28. 24
      app/models/inbound_item.go
  29. 26
      app/models/linkman.go
  30. 30
      app/models/material.go
  31. 28
      app/models/model_trace.go
  32. 55
      app/models/outbound.go
  33. 24
      app/models/outbound_item.go
  34. 40
      app/models/payment_plan.go
  35. 54
      app/models/product.go
  36. 26
      app/models/product_category.go
  37. 36
      app/models/purchase_audit.go
  38. 36
      app/models/purchase_execution.go
  39. 57
      app/models/purchase_order.go
  40. 32
      app/models/purchase_product.go
  41. 53
      app/models/purchase_requisition.go
  42. 49
      app/models/remittance.go
  43. 44
      app/models/supplier.go
  44. 35
      app/models/supply_price.go
  45. 52
      app/models/tag.go
  46. 21
      app/models/tenant.go
  47. 21
      app/models/utils.go
  48. 90
      app/models/warehouse.go
  49. 34
      app/models/warehouse_location.go
  50. 13
      database/README.md
  51. 39
      database/migrator.go
  52. 21
      database/system/202409291240_01_initialize_database.go
  53. 32
      database/system/202409291240_02_create_super_account.go
  54. 10
      database/system/migrations.go
  55. 49
      database/tenant/202409291240_01_initialize_database.go
  56. 18
      database/tenant/202409291240_02_initialize_districts.go
  57. 10
      database/tenant/migrations.go
  58. 49
      go.mod
  59. 269
      go.sum
  60. 48
      main.go
  61. 87
      util/backoff/backoff.go
  62. 57
      util/backoff/backoff_test.go
  63. 100
      util/cache/cache.go
  64. 140
      util/db/clause.go
  65. 130
      util/db/db.go
  66. 91
      util/db/dts/any.go
  67. 84
      util/db/dts/color.go
  68. 42
      util/db/dts/date.go
  69. 104
      util/db/dts/map.go
  70. 64
      util/db/dts/null_date.go
  71. 62
      util/db/dts/null_string.go
  72. 53
      util/db/dts/null_time.go
  73. 74
      util/db/dts/null_uint.go
  74. 72
      util/db/dts/slice.go
  75. 70
      util/db/dts/url.go
  76. 120
      util/db/init.go
  77. 84
      util/db/logger.go
  78. 162
      util/db/migrator.go
  79. 57
      util/db/mysql/driver.go
  80. 93
      util/db/mysql/lock.go
  81. 135
      util/db/mysql/migrate.go
  82. 60
      util/db/mysql/schema.go
  83. 89
      util/db/mysql_lock.go
  84. 99
      util/db/mysql_schema.go
  85. 41
      util/db/pgsql/driver.go
  86. 63
      util/db/pgsql/lock.go
  87. 119
      util/db/pgsql/migrate.go
  88. 65
      util/db/pgsql/schema.go
  89. 68
      util/db/pgsql_lock.go
  90. 107
      util/db/pgsql_schema.go
  91. 46
      util/evio/default.go
  92. 102
      util/evio/evio.go
  93. 257
      util/evio/simple.go
  94. 54
      util/evio/utils.go
  95. 52
      util/init.go
  96. 54
      util/jwt/auth.go
  97. 51
      util/jwt/finder.go
  98. 36
      util/jwt/generate.go
  99. 36
      util/jwt/jwt.go
  100. 33
      util/jwt/verify.go
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,51 @@
root = "."
testdata_dir = "testdata"
tmp_dir = ".air"
[build]
args_bin = []
bin = "./.air/main"
cmd = "go build -o ./.air/main ."
delay = 1000
exclude_dir = ["assets", ".air", "vendor", "testdata"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

134
.env

@ -0,0 +1,134 @@
################
# 服务器配置
################
# 设置运行环境
# 可以设置的值有 dev、prod
APP_ENV=dev
# 设置运行时区
# TIME_ZONE=Asia/Shanghai
# 服务器监听的地址
# 默认值为 0.0.0.0
#
# SERVER_ADDR=0.0.0.0
# 服务器监听的端口
# 默认值为 1234
#
# SERVER_PORT=1234
# 允许的报头大小上限
# 默认值为 Golang 标准库定义的值 `http.DefaultMaxHeaderBytes`
#
# HTTP_MAX_HEADER_BYTES=
# 写超时,如果提供流服务,切勿设置该值。
#
# HTTP_WRITE_TIMEOUT=
# 读超时,暂时不建议设置
# HTTP_READ_TIMEOUT=
# 闲置超时,暂时不建议设置
# HTTP_IDLE_TIMEOUT=
##################
# 数据库配置
##################
DB_DRIVER=postgres
# 数据库表前缀
DB_PREFIX=ims_
# 数据库存储引擎
# DB_STORE_ENGINE=InnoDB
DB_USER=postgres
DB_AUTH=password
DB_HOST=localhost
DB_PORT=5432
DB_NAME=postgres
DB_PUBLIC_SCHEMA=public
# DB_CHARSET=utf8mb4 # 数据库字符类型,仅支持 mysql
# DB_STRING_SIZE=256 # 数据库字符串类型长度,仅支持 mysql
# DB_SSLMODE=disable # 数据库SSL模式,仅支持 postgres
# DB_SINGULAR_TABLE=false
# DB_IDENTIFIER_MAX_LENGTH=0
# DB_QUERY_FIELDS=true
# DB_DISABLE_FOREIGN_KEY_CONSTRAINT=false
# DB_IGNORE_RELATIONSHIPS=false
# DB_MAX_IDLE_CONNS=
# DB_MAX_OPEN_CONNS=
# DB_CONN_MAX_LIFETIME=
################
# 跨域设置
################
CORS_EXPOSE_HEADERS=X-Token-Expired
################
# 授权认证
################
# 在线获取公私钥网站 https://travistidwell.com/jsencrypt/demo/
# 或者使用 scripts/rs256/rs256.go 生成
# 授权令牌私钥
JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIIEogIBAAKCAQEAq04SRRhAjKD9DjQANg4cPIyTUGU732ExlVeDEaee7RhNsHNH
4FfwYJqU60LFEN5WVoYIJ4pFgZD2GgIlLRAWZm/MtUcKRTG2U0wnsy5bgoLwaWWK
iKOUljyXi8P+wzucUYtTCm+/cI9Iz1BKNm8gJ62Af4UCURN1jecw/hfb6TsdSb+E
56Al31Jce9XXXxPu4WOUnnKtK5SNsOF6jzVMo4C/cdqEyX4Yss2QIn9L9bcNsXZR
U4QHe2fstPj0FnvkYCz2KcWMnImwMfL8mFY7aXilxeqeTfqVz2vXVTTI4sKjvEbC
Ag+kBdgT7NOmQpqrTqeBRuNvoEPgtvAwGVSOtwIDAQABAoIBAFKWdSByrMwf4WCz
mVZ2Pw7CB1O/OrpbNXh2lG4yjeBo0yu6qHB0dSNd42X47uFPD/ju7YeCljf9F2k6
l4m+M853IA5VjZIGgYxvLsSoGN43Goj1t3BgzQYReE6d03l0h8yYixSBA57UtZmd
b+oGcU4vy7+u9Ir6ArbDO0+FWTR7zQ3IvWJx0vwPjVLgeLY5hj8B7yEtE01aVvAI
6JyNgleuLcPDVAmbGvc9qYND92qSwQjUz1ljpZVPE8OP71K4KVnstBEONHd36Svp
jcHBw5ZePAbWvp+qLPco6O/wi/aDqHMXrhvyw+WJiawAZ2gUmOVray6qwAn6D307
KRnmS+ECgYEAwoTy1ywDv7xwM84paCldwe9q39kFjlrBrDdfFM8ZKg96FtMiD8Oc
VOLsiR6yqt1tpPc66r0GicSIokZErf9UKbC7gnSFBaNJahSVHgLKB3hxvPYzYf+e
s79NuRIL3b+cLRFbLmQOHcWkqWS9jmP3sQXj7I9v+l/Pyfk0PgJltuUCgYEA4XLK
MJuQjtv76bhaoxM1zI5YIkGua8SPTiffNkqkU8xRBqfZ32HdtYhxsiCyQkcJHMvQ
tVMFv14ZIeZL5JUJXHzFYCaqbZVtgwud5X4vPhWidDoXtK/GPq1y7qV6K0hb6ibR
xOogxKLPcLWSa2qk6hA1GviLnQMNskdKI2AV2WsCgYAmWcHrDGF81vEkNZxSTf2P
M93VXltLEremdZBIcQBcG4sNnvjTNNTIo6jQ6/171IY+hQPXrgbw+I+bttDpoTJ9
ubtuX/yt/OCHiqFPtmsQh/2crMm6o4QtmiT/vQAb6yAmfoqEnfNuiT4Kq7L3tNvr
yeaDCFCig2tbPcfABgb5xQKBgBPvVYeUyeSH+S+CeKlA0poC4DIvQqAO4mkTx34C
faNXRrbi8rX47mTV6s/IalrH8ztTnmHaNIDmbix8M6vDre86rS1CXgGQm/1hcpXP
YZgIy7OhS2VTwaDbL3WAtlvLay06P4Q8+6bHirY5p/fNl0WHJ1r6LUY19ekUuq6E
GYp/AoGAGzR94Ao1GwLHmBuFzjm4j8kGaK8eRyhV/YBtYKYGYpW7H52hDhXxghN3
sq5Br0L4PtWsS6Qbk4H15P5FBo9iLLOOsvDpyNwe7cgU6/GHCapvjGJWu3lPVGa+
7WVOetKnnSHYoboZBFT9dHNgsEt4MSXAQ3AClD2tXDzKCAEzLAY=
-----END PRIVATE KEY-----"
# 授权令牌公钥
JWT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----
MIIBCgKCAQEAq04SRRhAjKD9DjQANg4cPIyTUGU732ExlVeDEaee7RhNsHNH4Ffw
YJqU60LFEN5WVoYIJ4pFgZD2GgIlLRAWZm/MtUcKRTG2U0wnsy5bgoLwaWWKiKOU
ljyXi8P+wzucUYtTCm+/cI9Iz1BKNm8gJ62Af4UCURN1jecw/hfb6TsdSb+E56Al
31Jce9XXXxPu4WOUnnKtK5SNsOF6jzVMo4C/cdqEyX4Yss2QIn9L9bcNsXZRU4QH
e2fstPj0FnvkYCz2KcWMnImwMfL8mFY7aXilxeqeTfqVz2vXVTTI4sKjvEbCAg+k
BdgT7NOmQpqrTqeBRuNvoEPgtvAwGVSOtwIDAQAB
-----END PUBLIC KEY-----"
# 令牌时长
JWT_TTL=168h
# 令牌签发者
JWT_ISSUER=slim
# 令牌主体
JWT_SUBJECT=slim
# 令牌受众,多个值可以使用逗号分开,比如:app,pc,wap,routine
# 若值为 '*' 表示所有。
JWT_AUDIENCE=*

1
.gitattributes vendored

@ -0,0 +1 @@
* text=auto eol=lf

10
.gitignore vendored

@ -0,0 +1,10 @@
/.idea
/.vscode
/.air
/storage
.DS_Store
*.iml
*.log
*.db
/*.zip
.env.*

@ -0,0 +1,51 @@
## 目录结构说明
```text
├─app 应用目录
│ ├─events 事件目录
│ │
│ ├─http 网络服务
│ │ ├─controllers 控制器目录
│ │ ├─middleware 中间件目录
│ │ ├─requests 请求参数
│ │ ├─routes 路由目录
│ │ └─http.go 网络服务入口
│ │
│ ├─jobs 任务目录
│ ├─models 模型目录
│ ├─routes 路由目录
│ └─app.go 应用入口文件
├─cmd 程序命令
│ ├─web.go 网络服务名称
│ ├─cmd.go 命令入口
│ └─ ...
├─database 配置目录
│ ├─system 系统迁移
│ └─tenant 租户迁移
├─storage 运行时目录
│ ├─cache 缓存文件目录
│ ├─logs 日志目录
│ ├─uploads 文件上传目录
├─util 工具集
│ ├─backoff 函数失败重试
│ ├─cache 缓存组件
│ ├─district 内置三级地区初始化
│ ├─db 数据库操作
│ ├─jwt JWT 封装
│ ├─rdb Redis 操作
│ ├─rsp 接口返回封装
│ └─init.go 工具集初始化
├─.air.toml air 工具配置
├─.env 默认配置(不建议修改)
├─.env.dev 开发配置
├─.example.env 环境变量示例文件
├─LICENSE.txt 授权说明文件
├─README.md README 文件
├─main.go 命令行入口文件
├─ ... 其它文件
```

@ -0,0 +1,49 @@
package app
import (
"context"
"ims/app/http"
"ims/app/listeners"
"ims/database"
"ims/util"
"ims/util/evio"
"ims/util/log"
"sync/atomic"
)
var closed uint32
func Start(ctx context.Context) error {
var err error
check := func(fn func(ctx context.Context) error) {
if err == nil {
err = fn(ctx)
}
}
check(log.Init) // 初始化日志
check(listeners.Init) // 初始化注册事件
check(util.Init) // 初始化工具包
check(database.Init) // 初始化数据库
check(callEvent("app.init")) // 触发初始化事件
check(evio.Start) // 启动事件总线
check(http.Start) // 启动HTTP服务器
check(callEvent("app.boot")) // 触发启动事件
return err
}
func callEvent(topic string) func(ctx context.Context) error {
return func(ctx context.Context) error {
evio.Call(topic, nil) // 触发事件
return nil
}
}
func Stop() error {
if !atomic.CompareAndSwapUint32(&closed, 0, 1) {
return nil
}
err := http.Stop() // 停止HTTP服务器
evio.Call("app.exit", err) // 触发退出事件
evio.Stop() // 停止事件总线
return err
}

@ -0,0 +1,99 @@
package http
import (
"context"
"errors"
"fmt"
"ims/app/http/middleware"
"ims/app/http/routes"
"ims/util/log"
"net/http"
"zestack.dev/env"
"zestack.dev/slim"
)
var (
started chan struct{}
exit chan chan error
)
func init() {
started = make(chan struct{})
exit = make(chan chan error)
}
func Start(ctx context.Context) error {
select {
case <-started:
return errors.New("app: server already started")
default:
close(started)
}
app := createKernel()
srv := createServer(app)
routes.Init(app)
go func() {
if srvErr := app.StartServer(srv); srvErr != nil {
log.Error("encountered an error while serving listener: ", srvErr)
}
}()
go func() {
// 监听停止命令,停止网络服务
errChan := <-exit
// 停止 slim 应用
errChan <- app.Close()
}()
return nil
}
func createKernel() *slim.Slim {
app := slim.New()
app.Debug = !env.IsEnv("prod")
app.Logger = log.Default()
app.ErrorHandler = middleware.ErrorHandler
app.ResetRouterCreator(func(s *slim.Slim) slim.Router {
return slim.NewRouter(slim.RouterConfig{
RoutingTrailingSlash: true,
})
})
app.Use(slim.Logging())
app.Use(slim.Recovery())
app.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: env.List("CORS_ALLOW_ORIGINS"),
AllowMethods: env.List("CORS_ALLOW_METHODS"),
AllowHeaders: env.List("CORS_ALLOW_HEADERS"),
AllowCredentials: env.Bool("CORS_ALLOW_CREDENTIALS"),
ExposeHeaders: env.List("CORS_EXPOSE_HEADERS"),
MaxAge: env.Int("CORS_MAX_AGE"),
}))
return app
}
func createServer(app *slim.Slim) *http.Server {
// TODO 支持 TLSConfig 配置
return &http.Server{
Addr: fmt.Sprintf("%s:%d", env.String("SERVER_ADDR"), env.Int("SERVER_PORT", 1234)),
Handler: app,
ReadTimeout: env.Duration("HTTP_READ_TIMEOUT", 0),
ReadHeaderTimeout: env.Duration("HTTP_READ_HEADER_TIMEOUT", 0),
WriteTimeout: env.Duration("HTTP_WRITE_TIMEOUT", 0),
IdleTimeout: env.Duration("HTTP_IDLE_TIMEOUT", 0),
MaxHeaderBytes: env.Int("HTTP_MAX_HEADER_BYTES", http.DefaultMaxHeaderBytes),
}
}
func Stop() error {
select {
case <-started:
errCh := make(chan error)
exit <- errCh
return <-errCh
default:
return nil
}
}

@ -0,0 +1,148 @@
package middleware
import (
"errors"
"fmt"
"ims/app/models"
"ims/util/db"
"ims/util/jwt"
"ims/util/rdb"
"github.com/jinzhu/inflection"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
"zestack.dev/log"
"zestack.dev/slim"
)
var sfg singleflight.Group
type JWTExtra struct {
AccountID uint `redis:"account"`
EmployeeID uint `redis:"employee"`
TenantID uint `redis:"tenant"`
Account *models.Account
Employee *models.Employee
Tenant *models.Tenant
}
func Auth(withTenant, anonymously bool) slim.MiddlewareFunc {
return jwt.Auth(jwt.AuthConfig{
Skipper: func(c slim.Context) bool {
if c.RouteMatchType() == slim.RouteMatchUnknown {
panic("unknown route")
}
switch c.RouteInfo().Name() {
case "login":
return true
default:
return false
}
},
Anonymously: anonymously,
Claims: func(c slim.Context, token string, _ *jwt.Claims) error {
var extra JWTExtra
if err := extra.load(c, token); err != nil {
return err
}
// 针对租户,如果加载失败,说明没有权限
if withTenant && (extra.Tenant == nil || extra.Employee == nil) {
return jwt.ErrForbidden
}
// // 如果租户数据没有初始化,那就只能访问有限的几个页面,
// // 比如基本设置、初始化等。
// if withTenant && !tenant.IsReady() {
// return c.Redirect(http.StatusMovedPermanently, "/tenant/init")
// }
return nil
},
})
}
func (e *JWTExtra) load(c slim.Context, token string) error {
// 加载额外信息
if err := rdb.Redis().HGetAll(c, "jwt:"+token).Scan(e); err != nil {
return err
}
// 加载用户信息
if err := e.loadAccount(c); err != nil {
return err
}
// 加载员工信息
if err := e.loadEmployee(c); err != nil {
return err
}
// 加载租户信息
if err := e.loadTenant(c); err != nil {
return err
}
return nil
}
func (e *JWTExtra) loadAccount(c slim.Context) error {
var account models.Account
err := e.loadModel(c, "account", e.AccountID, &account)
if err != nil {
return err
}
e.Account = &account
return nil
}
func (e *JWTExtra) loadEmployee(c slim.Context) error {
if e.EmployeeID == 0 {
return nil
}
var employee models.Employee
err := e.loadModel(c, "employee", e.EmployeeID, &employee)
if err != nil {
return err
}
e.Employee = &employee
return nil
}
func (e *JWTExtra) loadTenant(c slim.Context) error {
if e.TenantID == 0 {
return nil
}
var tenant models.Tenant
err := e.loadModel(c, "tenant", e.TenantID, &tenant)
if err != nil {
return err
}
e.Tenant = &tenant
return nil
}
func (e *JWTExtra) loadModel(c slim.Context, label string, id uint, val any) error {
client := rdb.Redis()
key := fmt.Sprintf("%s:%d", inflection.Plural(label), id)
_, err, _ := sfg.Do(key, func() (any, error) {
// 加载 Redis 中的数据
err := client.HGetAll(c, key).Scan(val)
if err == nil {
return nil, nil
}
if !errors.Is(err, redis.Nil) {
return nil, err
}
// 加载数据库中的数据
err = db.Engine().First(val, id).Error
if err == nil && val != nil {
err = client.HSet(c, key, val).Err()
if err != nil {
log.Error("failed to cache "+label, "error", err, "id", id)
}
}
return nil, err
})
if err != nil {
return err
}
c.Set("jwt:"+label, val)
return nil
}

@ -0,0 +1,239 @@
package middleware
import (
"net/http"
"regexp"
"strconv"
"strings"
"zestack.dev/slim"
)
// CORSConfig defines the config for CORS middleware.
type CORSConfig struct {
// AllowOrigin defines a list of origins that may access the resource.
// Optional. Default value []string{"*"}.
AllowOrigins []string
// AllowOriginFunc is a custom function to validate the origin. It takes the
// origin as an argument and returns true if allowed or false otherwise. If
// an error is returned, it is returned by the handler. If this option is
// set, AllowOrigins is ignored.
// Optional.
AllowOriginFunc func(origin string) (bool, error)
// AllowMethods defines a list methods allowed when accessing the resource.
// This is used in response to a preflight request.
// Optional. Default value DefaultCORSConfig.AllowMethods.
AllowMethods []string
// AllowHeaders defines a list of request headers that can be used when
// making the actual request. This is in response to a preflight request.
// Optional. Default value []string{}.
AllowHeaders []string
// AllowCredentials indicates whether or not the response to the request
// can be exposed when the credential flag is true. When used as part of
// a response to a preflight request, this indicates whether or not the
// actual request can be made using credentials.
// Optional. Default value is false.
AllowCredentials bool
// ExposeHeaders defines the whitelist headers that clients are allowed to
// access.
// Optional. Default value []string{}.
ExposeHeaders []string
// MaxAge indicates how long (in seconds) the results of a preflight request
// can be cached.
// Optional. Default value 0.
MaxAge int
}
func CORS() slim.MiddlewareFunc {
return CORSWithConfig(CORSConfig{})
}
func CORSWithConfig(config CORSConfig) slim.MiddlewareFunc {
if len(config.AllowOrigins) == 0 {
config.AllowOrigins = []string{"*"}
}
if len(config.AllowMethods) == 0 {
config.AllowMethods = []string{
http.MethodGet,
http.MethodHead,
http.MethodPut,
http.MethodPatch,
http.MethodPost,
http.MethodDelete,
}
}
var allowOriginPatterns []string
for _, origin := range config.AllowOrigins {
pattern := regexp.QuoteMeta(origin)
pattern = strings.Replace(pattern, "\\*", ".*", -1)
pattern = strings.Replace(pattern, "\\?", ".", -1)
pattern = "^" + pattern + "$"
allowOriginPatterns = append(allowOriginPatterns, pattern)
}
allowMethods := strings.Join(config.AllowMethods, ",")
allowHeaders := strings.Join(config.AllowHeaders, ",")
exposeHeaders := strings.Join(config.ExposeHeaders, ",")
maxAge := strconv.Itoa(config.MaxAge)
return func(c slim.Context, next slim.HandlerFunc) error {
req := c.Request()
res := c.Response()
origin := req.Header.Get(slim.HeaderOrigin)
allowOrigin := ""
preflight := req.Method == http.MethodOptions
res.Header().Add(slim.HeaderVary, slim.HeaderOrigin)
// No Origin provided
if origin == "" {
if !preflight {
return next(c)
}
return c.NoContent(http.StatusNoContent)
}
if config.AllowOriginFunc != nil {
allowed, err := config.AllowOriginFunc(origin)
if err != nil {
return err
}
if allowed {
allowOrigin = origin
}
} else {
// Check allowed origins
for _, o := range config.AllowOrigins {
if o == "*" && config.AllowCredentials {
allowOrigin = origin
break
}
if o == "*" || o == origin {
allowOrigin = o
break
}
if matchSubdomain(origin, o) {
allowOrigin = origin
break
}
}
// Check allowed origin patterns
for _, re := range allowOriginPatterns {
if allowOrigin == "" {
didx := strings.Index(origin, "://")
if didx == -1 {
continue
}
domAuth := origin[didx+3:]
// to avoid regex cost by invalid long domain
if len(domAuth) > 253 {
break
}
if match, _ := regexp.MatchString(re, origin); match {
allowOrigin = origin
break
}
}
}
}
// Origin isn't allowed
if allowOrigin == "" {
if !preflight {
return next(c)
}
return c.NoContent(http.StatusNoContent)
}
// Simple request
if !preflight {
res.Header().Set(slim.HeaderAccessControlAllowOrigin, allowOrigin)
if config.AllowCredentials {
res.Header().Set(slim.HeaderAccessControlAllowCredentials, "true")
}
if exposeHeaders != "" {
res.Header().Set(slim.HeaderAccessControlExposeHeaders, exposeHeaders)
}
return next(c)
}
// Preflight request
res.Header().Add(slim.HeaderVary, slim.HeaderAccessControlRequestMethod)
res.Header().Add(slim.HeaderVary, slim.HeaderAccessControlRequestHeaders)
res.Header().Set(slim.HeaderAccessControlAllowOrigin, allowOrigin)
res.Header().Set(slim.HeaderAccessControlAllowMethods, allowMethods)
if config.AllowCredentials {
res.Header().Set(slim.HeaderAccessControlAllowCredentials, "true")
}
if allowHeaders != "" {
res.Header().Set(slim.HeaderAccessControlAllowHeaders, allowHeaders)
} else {
h := req.Header.Get(slim.HeaderAccessControlRequestHeaders)
if h != "" {
res.Header().Set(slim.HeaderAccessControlAllowHeaders, h)
}
}
if config.MaxAge > 0 {
res.Header().Set(slim.HeaderAccessControlMaxAge, maxAge)
}
return c.NoContent(http.StatusNoContent)
}
}
func matchScheme(domain, pattern string) bool {
didx := strings.Index(domain, ":")
pidx := strings.Index(pattern, ":")
return didx != -1 && pidx != -1 && domain[:didx] == pattern[:pidx]
}
// matchSubdomain compares authority with wildcard
func matchSubdomain(domain, pattern string) bool {
if !matchScheme(domain, pattern) {
return false
}
didx := strings.Index(domain, "://")
pidx := strings.Index(pattern, "://")
if didx == -1 || pidx == -1 {
return false
}
domAuth := domain[didx+3:]
// to avoid long loop by invalid long domain
if len(domAuth) > 253 {
return false
}
patAuth := pattern[pidx+3:]
domComp := strings.Split(domAuth, ".")
patComp := strings.Split(patAuth, ".")
for i := len(domComp)/2 - 1; i >= 0; i-- {
opp := len(domComp) - 1 - i
domComp[i], domComp[opp] = domComp[opp], domComp[i]
}
for i := len(patComp)/2 - 1; i >= 0; i-- {
opp := len(patComp) - 1 - i
patComp[i], patComp[opp] = patComp[opp], patComp[i]
}
for i, v := range domComp {
if len(patComp) <= i {
return false
}
p := patComp[i]
if p == "*" {
return true
}
if p != v {
return false
}
}
return false
}

@ -0,0 +1,37 @@
package middleware
import (
"errors"
"ims/util/jwt"
"ims/util/rsp"
"gorm.io/gorm"
"zestack.dev/slim"
)
func ErrorHandler(c slim.Context, err error) {
if c.Written() {
return
}
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
err = rsp.ErrRecordNotFound
case errors.Is(err, gorm.ErrDuplicatedKey):
err = rsp.ErrInternal.WithText(rsp.ErrInternal.Text() + ",数据已经存在")
case errors.Is(err, jwt.ErrInvalidToken),
errors.Is(err, jwt.ErrUnauthorized),
errors.Is(err, jwt.ErrTokenNotFound):
err = rsp.ErrUnauthorized
case errors.Is(err, jwt.ErrTokenExpired):
err = rsp.ErrUnauthorized.WithText("授权令牌已过期")
case errors.Is(err, slim.ErrNotFound):
err = rsp.ErrNotFound
case errors.Is(err, slim.ErrMethodNotAllowed):
err = rsp.ErrMethodNotAllowed
case errors.Is(err, slim.ErrUnsupportedMediaType):
err = rsp.ErrUnsupportedMediaType.WithInternal(err)
}
_ = rsp.Fail(c, err)
}

@ -0,0 +1,49 @@
package routes
import (
"net/http/pprof"
"zestack.dev/slim"
)
// Init 初始化路由
func Init(app *slim.Slim) {
router := app.Router()
// 注册系统路由
router.Group(func(r slim.RouteCollector) {
registerSystemRoutes(r)
})
// 注册租户路由
router.Group(func(r slim.RouteCollector) {
registerTenantRoutes(r)
})
// 调试模式下
// 1.注册 pprof 路由
// 2.打印注册的路由
if app.Debug {
usePprof(router)
printRoutes(router)
}
}
// 注册性能分析路由
func usePprof(router slim.Router) {
router.GET("/debug/pprof/", slim.WrapHandlerFunc(pprof.Index))
router.GET("/debug/pprof/cmdline", slim.WrapHandlerFunc(pprof.Cmdline))
router.GET("/debug/pprof/profile", slim.WrapHandlerFunc(pprof.Profile))
router.GET("/debug/pprof/symbol", slim.WrapHandlerFunc(pprof.Symbol))
router.GET("/debug/pprof/trace", slim.WrapHandlerFunc(pprof.Trace))
router.GET("/debug/pprof/allocs", prof("allocs"))
router.GET("/debug/pprof/block", prof("block"))
router.GET("/debug/pprof/goroutine", prof("goroutine"))
router.GET("/debug/pprof/heap", prof("heap"))
router.GET("/debug/pprof/mutex", prof("mutex"))
router.GET("/debug/pprof/threadcreate", prof("threadcreate"))
}
func prof(name string) slim.HandlerFunc {
return slim.WrapHandlerFunc(pprof.Handler(name).ServeHTTP)
}

@ -0,0 +1,32 @@
package routes
import (
"os"
"reflect"
"runtime"
"strings"
"github.com/olekukonko/tablewriter"
"zestack.dev/slim"
)
func printRoutes(router slim.Router) {
table := tablewriter.NewWriter(os.Stdout)
table.SetHeader([]string{"Method", "Pattern", "Name", "Handler"})
table.SetHeaderAlignment(3)
for _, route := range router.Routes() {
table.Append([]string{
strings.Join(route.Methods(), ","),
route.Pattern(),
route.Name(),
nameOfFunction(route.Handler()),
})
}
table.Render()
}
func nameOfFunction(f any) string {
return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name()
}

@ -0,0 +1,7 @@
package routes
import "zestack.dev/slim"
func registerSystemRoutes(r slim.RouteCollector) {
//
}

@ -0,0 +1,7 @@
package routes
import "zestack.dev/slim"
func registerTenantRoutes(r slim.RouteCollector) {
//
}

@ -0,0 +1,7 @@
package listeners
import "context"
func Init(_ context.Context) error {
return nil
}

@ -0,0 +1,38 @@
package models
import (
"errors"
"ims/util/db/dts"
"time"
"gorm.io/gorm"
"zestack.dev/misc"
)
// Account 平台账户
type Account struct {
ID uint `json:"id" gorm:"primaryKey;comment:账户ID"`
Nickname string `json:"nickname" gorm:"comment:昵称"`
AvatarUrl string `json:"avatar_url" gorm:"comment:头像地址"`
Username string `json:"username" gorm:"unique;comment:用户名"`
PhoneNumber string `json:"phone_number" gorm:"uniqueIndex;comment:手机号码"`
WechatOpenid dts.NullString `json:"wechat_openid" gorm:"uniqueIndex;comment:微信openid"`
Email dts.NullString `json:"email" gorm:"uniqueIndex;comment:电子邮箱"`
RawPassword string `json:"-" gorm:"-"` // 原始密码
Password string `json:"-" gorm:"comment:登录密码"`
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"comment:删除时间"`
}
func (u *Account) BeforeCreate(_ *gorm.DB) error {
if u.RawPassword != "" {
hash, err := misc.PasswordHash(u.RawPassword)
if err != nil {
return err
}
u.Password = hash
return nil
}
return errors.New("缺少密码")
}

@ -0,0 +1,40 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
const (
AddressForCustomer = "customer" // 消费者
AddressForSupplier = "supplier" // 供应商
AddressForOutbound = "outbound" // 出库单
)
// Address 地址信息
type Address struct {
ID uint `json:"id" gorm:"primaryKey"` // 地址编号
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
ProvinceID int `json:"province_id"` // 省份编号
CityID int `json:"city_id"` // 城市编号
CountyID int `json:"county_id"` // 区县编号
Detail string `json:"detail"` // 详细地址
Province *District `json:"district,omitempty" gorm:"foreignKey:ProvinceID"` // 省份
City *District `json:"city,omitempty" gorm:"foreignKey:CityID"` // 城市
County *District `json:"county,omitempty" gorm:"foreignKey:CountyID"` // 区县
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,22 @@
package models
import (
"time"
"gorm.io/gorm"
)
const (
CertForSupplier = "supplier" // 供应商资质
)
// Certification 资质
type Certification struct {
ID uint `json:"id" gorm:"primarykey"` // 标签编号
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
Attachments []File `json:"attachments" gorm:"polymorphic:Owner;polymorphicValue:certification"` // 证书附件
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次更新时间
DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间
}

@ -0,0 +1,52 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// CustomerSource 客户来源
type CustomerSource int8
const (
FromRecommend CustomerSource = iota + 1 // 转介绍
FromPC // 网站注册
FromMiniProgram // 小程序注册
FromSalesman // 销售自拓
)
// Customer 客户
type Customer struct {
ID uint `json:"id" gorm:"primarykey"` // 客户ID
Code string `json:"code" gorm:"uniqueIndex"` // 客户编码
Name string `json:"name" gorm:"uniqueIndex"` // 客户名称
Source CustomerSource `json:"source"` // 客户来源
TypeID uint `json:"type_id"` // 客户类型
TagID dts.NullUint `json:"tag_id"` // 客户标签
PriceStrategyID dts.NullUint `json:"price_strategy_id"` // 价格策略
SettlementID dts.NullUint `json:"settlement_id"` // 结算方式
CreditLimit int `json:"credit_limit"` // 信用额度,单位分
PrincipalID dts.NullUint `json:"principal_id"` // 销售负责人编号
Type *Tag `json:"type,omitempty" gorm:"foreignKey:TypeID"` // 客户类型
Tag *Tag `json:"Tag,omitempty" gorm:"foreignKey:TagID"` // 客户标签
PriceStrategy *Tag `json:"price_strategy" gorm:"foreignKey:PriceStrategyID"` // 价格策略
Settlement *Tag `json:"settlement" gorm:"foreignKey:SettlementID"` // 结算方式
Principal *Employee `json:"principal" gorm:"foreignKey:PrincipalID"` // 销售负责人
Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 客户地址
Remittance *Remittance `json:"remittance,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 汇款信息
Linkmen []Linkman `json:"linkmen,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 联系人列表
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,12 @@
package models
// CustomerTrack 客户跟进记录
type CustomerTrack struct {
ID uint `json:"id" gorm:"primary_key"`
CustomerId uint `json:"customer_id"` // 客户编号
// Content string // 跟进内容记录
// 跟进时间
// 跟进方式 - 上门拜访、电话拜访、微信沟通、其他
// 跟进人
// 跟进内容 - 初次沟通、需求沟通、方案确认、报价、合同签署、销售回访
}

@ -0,0 +1,31 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// Department 部门
type Department struct {
ID uint `json:"id" gorm:"primaryKey"` // 部门编号
PID dts.NullUint `json:"pid" gorm:"column:pid;default:null;index:,unique,composite:uni_name_with_pid_and_company"` // 上级部门编号
Name string `json:"name" gorm:"index:,unique,composite:uni_name_with_pid_and_company"` // 部门名称
ManagerId dts.NullUint `json:"manager_id"` // 部门经理编号
Manager *Employee `json:"manager,omitempty" gorm:"foreignKey:ManagerId"` // 部门经理
Children []Department `json:"children,omitempty" gorm:"foreignKey:PID"` // 下级部门
Employees []Employee `json:"employees,omitempty" gorm:"many2many:department_employees;"` // 部门员工
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,38 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
const (
DistributionForPurchase = "purchase" // 采购
DistributionForSale = "sale" // 销售
)
// DistributionPlan 配送计划
//
// 支持采购和销售
type DistributionPlan struct {
ID uint `json:"id" gorm:"primaryKey"` // 审核ID
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
BatchID uint `json:"batch_id"` // 配送批次
Date dts.Date `json:"date"` // 计划配送日期
Remark string `json:"remark"` // 计划配送内容备注
Batch *Tag `json:"batch,omitempty" gorm:"foreignKey:BatchID"`
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,50 @@
package models
import (
_ "embed"
"encoding/json"
"ims/util/db/dts"
"gorm.io/gorm"
)
//go:embed district.json
var districtBytes []byte
// District 行政区域
// 数据来源 https://github.com/uiwjs/province-city-china
type District struct {
ID uint `json:"id" gorm:"primaryKey;size:6;comment:'地区ID'"`
PID dts.NullUint `json:"pid" gorm:"size:6;column:pid;default:null;comment:'上级地区ID'"`
Name string `json:"name" gorm:"size:200;comment:'地区名字'"`
Level uint `json:"level" gorm:"size:1;comment:'地区等级,1省、2市、3县'"`
Children []District `json:"children,omitempty" gorm:"foreignKey:PID"`
}
func InstallDistricts(tx *gorm.DB) error {
var districts []District
err := json.Unmarshal(districtBytes, &districts)
if err != nil {
return err
}
districts = ensureDistricts(districts, 1, 0)
err = tx.Exec("TRUNCATE TABLE " + tx.NamingStrategy.TableName("district")).Error
if err != nil {
return err
}
return tx.Create(&districts).Error
}
func ensureDistricts(districts []District, level, pid uint) []District {
if districts == nil || len(districts) > 0 {
return nil
}
for i, d := range districts {
d.PID = dts.NullUint{Uint: pid, Valid: pid > 0}
d.Level = level
d.Children = ensureDistricts(d.Children, level+1, d.ID)
districts[i] = d
}
return districts
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,32 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// Employee 员工
// 必须通过微信来初始化
type Employee struct {
ID uint `json:"id" gorm:"primarykey"` // 员工编号
AccountID dts.NullUint `json:"account_id"` // 账号编号
Name string `json:"name"` // 员工姓名
PhoneNumber string `json:"phone_number" gorm:"uniqueIndex"` // 联系电话
WechatOpenid dts.NullString `json:"wechat_openid" gorm:"uniqueIndex"` // 微信openid
Email dts.NullString `json:"email" gorm:"uniqueIndex"` // 电子邮箱
Departments []Department `json:"departments,omitempty" gorm:"many2many:department_employees;"` // 所在部门
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt time.Time `json:"updated_at"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"`
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"`
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"`
}

@ -0,0 +1,34 @@
package models
import (
"ims/util/db/dts"
"mime/multipart"
"time"
"gorm.io/gorm"
)
const (
FileForSupplierContract = "supplier-contract" // 供应商合同附件
FileForCertification = "certification" // 资质文件
)
// File 上传的文件
type File struct {
ID uint `json:"id" gorm:"primarykey"` // 标签编号
RawID dts.NullUint `json:"raw_id"` // 原始文件编号
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
IsDir bool `json:"is_dir"` // 是不是目录
Size uint `json:"size"` // 文件大小
Name string `json:"name"` // 文件名称
Mime string `json:"mime"` // 文件的MIME类型
UploaderID uint `json:"uploader_id"` // 上传者编号
UploaderType string `json:"uploader_type"` // 上传者类型
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次更新时间
DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间`
Base64File any `json:"-" gorm:"-"` // 上传的base64文件
MultipartFile multipart.File `json:"-" gorm:"-"` // 上传的文件
}

@ -0,0 +1,52 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// InboundType 产品入库类型
type InboundType int8
const (
InboundSellReturn InboundType = iota + 1 // 销售退货入库
InboundSellExchange // 销售换货入库
InboundPremium // 报溢入库 https://help.youzan.com/displaylist/detail_5_5-1-28504
InboundBorrow // 借入入库
InboundOpening // 期初入库
InboundOverage // 盘盈入库
InboundTransfer // 调拨入库
InboundDonated // 供应商受赠入库
InboundOther // 其他
)
// Inbound 产品入库单
type Inbound struct {
ID uint `json:"id" gorm:"primarykey"` // 入库ID
Type InboundType `json:"type"` // 入库类型
WarehouseID uint `json:"warehouse_id"` // 入库仓库
Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignkey:WarehouseID"` // 入库仓库
Quality Quality `json:"quality"` // 品质 - 全部合格、存在不合格品、全不合格
Images []string `json:"images" gorm:"serializer:json"` // 入库拍照
InspectorID dts.NullUint `json:"inspector_id"` // 质检员编号
Inspector *Employee `json:"inspector,omitempty" gorm:"foreignkey:InspectorID"` // 质检员
InspectedAt time.Time `json:"inspect_time"` // 质检时间
OperatorID uint `json:"operator_id"` // 入库员编号
Operator *Employee `json:"operator,omitempty" gorm:"foreignkey:OperatorID"` // 入库员
OperatedAt time.Time `json:"operate_time"` // 入库时间
Items []InboundItem `json:"items,omitempty" gorm:"foreignkey:InboundID"` // 入库单明细列表
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,24 @@
package models
import (
"time"
"gorm.io/gorm"
)
// InboundItem 入库单明细
type InboundItem struct {
Id uint `json:"id" gorm:"primarykey"` // 入库明细编号
InboundID uint `json:"inbound_id"` // 所属入库单编号
ProductId uint `json:"product_id"` // 入库商品编号
Product *Product `json:"product,omitempty" gorm:"foreignkey:ProductId"` // 入库产品
Batch string `json:"batch"` // 产品批次
WarehouseLocationID uint `json:"warehouse_location_id"` // 入库仓位编号
WarehouseLocation *WarehouseLocation `json:"warehouse_location" gorm:"foreignkey:WarehouseLocationID"` // 入库仓位
Amount int `json:"amount"` // 入库数量
CostPrice int `json:"cost_price"` // 成本单价
SellPrice int `json:"sell_price"` // 销售单价(含税)
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次操作时间
DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据时间
}

@ -0,0 +1,26 @@
package models
import (
"time"
"gorm.io/gorm"
)
const (
LinkmanForCustomer = "customer"
LinkmanForSupplier = "supplier"
)
// Linkman 联系人
type Linkman struct {
ID uint `json:"id" gorm:"primarykey"` // 联系人编号
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
Name string `json:"name"` // 联系人姓名
PhoneNumber string `json:"phone_number"` // 联系人手机
Position string `json:"position"` // 联系人职位
IsMain bool `json:"is_main"` // 是否主要联系人
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次操作时间
DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间
}

@ -0,0 +1,30 @@
package models
import (
"ims/util/db/dts"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// Material 物料
type Material struct {
ID uint `json:"id" gorm:"primarykey"`
Title string `json:"title"` // 物料名称
Images []string `json:"images" gorm:"default:null;serializer:json"` // 物料图片
Spec string `json:"spec"` // 物料规格
Cost decimal.Decimal `json:"cost"` // 物料成本
Stock int `json:"stock"` // 物料库存
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt time.Time `json:"updated_at"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,28 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
type ModelTacking struct {
ID uint `json:"id" gorm:"primarykey"` // 追踪ID
Sequence uint `json:"sequence"` // 追踪的序列
ModelName string `json:"model_name"` // 追踪的模型名称
RowID uint `json:"row_id"` // 追踪的模型ID
FieldName string `json:"field_name"` // 追踪的字段名称
OldValue string `json:"old_value"` // 旧值
NewValue string `json:"new_value"` // 新值
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,55 @@
package models
import (
"time"
"gorm.io/gorm"
)
// OutboundReason 产品出库类型
type OutboundReason int8
const (
OutboundSellExchangeOut OutboundReason = iota + 1 // 销售换货出库
OutboundPurchaseReturn // 采购退货出库
OutboundBreakage // 报损出库
OutboundLend // 借出出库
OutboundLendSend // 借出直发出库
OutboundInventoryLosses // 盘亏出库
OutboundTransfer // 调拨出库
OutboundTransferFailed // 调拨异常处理
OutboundGift // 销售赠品出库
OutboundOther // 其他
)
// OutboundTag 出库标签
type OutboundTag int8
const (
_ OutboundTag = iota + 1 // 出库
_ // 库存冻结
_ // 计划出库
)
// Outbound 产品出库单
type Outbound struct {
ID uint `json:"id" gorm:"primarykey"` // 出库单编号
Reason OutboundReason `json:"reason"` // 出库原因
Remark string `json:"remark"` // 备注信息
WarehouseID uint `json:"warehouse_id"` // 出库仓库
Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignkey:WarehouseID"` // 出库仓库
Images []string `json:"images" gorm:"serializer:json"` // 出库拍照
Tag OutboundTag `json:"tag"` // 出库标签
Address *Address `json:"address" gorm:"polymorphic:Owner;polymorphicValue:outbound"` // 收货地址
CustomerID uint `json:"customer_id"` // 收货客户编号
Customer *Customer `json:"customer" gorm:"foreignkey:CustomerID"` // 收货客户
IsTaken bool `json:"is_taken"` // 客户是否收货
OperatorID uint `json:"operator_id"` // 出库员编号
Operator *Employee `json:"operator,omitempty" gorm:"foreignkey:OperatorID"` // 出库员
OperatedAt time.Time `json:"operate_time"` // 出库时间
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次操作时间
DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间
Items []OutboundItem `json:"items,omitempty" gorm:"foreignkey:OutboundID"` // 出库单明细列表
}

@ -0,0 +1,24 @@
package models
import (
"time"
"gorm.io/gorm"
)
// OutboundItem 出库单明细
type OutboundItem struct {
ID uint `json:"id" gorm:"primarykey"` // 出库明细ID
OutboundID uint `json:"outbound_id"` // 所属出库单编号
ProductID uint `json:"product_id"` // 所属商品ID,特意设计的冗余字段
Product *Product `json:"product,omitempty" gorm:"foreignkey:ProductID"` // 出库产品
Batch string `json:"batch"` // 产品批次
WarehouseLocationID uint `json:"warehouse_location_id"` // 出库仓位编号
WarehouseLocation *WarehouseLocation `json:"warehouse_location" gorm:"foreignkey:WarehouseLocationID"` // 出库仓位
Amount int `json:"amount"` // 出库数量
CostPrice int `json:"cost_price"` // 成本单价
SellPrice int `json:"sell_price"` // 销售单价(含税)
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次操作时间
DeletedAt gorm.DeletedAt `json:"delete_time" gorm:"index"` // 删除数据的时间
}

@ -0,0 +1,40 @@
package models
import (
"ims/util/db/dts"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
const (
PaymentForPurchase = "purchase" // 采购
PaymentForSale = "sale" // 销售
)
// PaymentPlan 收付款计划
type PaymentPlan struct {
ID uint `json:"id" gorm:"primaryKey"` // 审核ID
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
BatchID uint `json:"batch_id"` // 收款批次
Ratio decimal.Decimal `json:"ratio"` //收款比例 %
Amount decimal.Decimal `json:"amount"` //收款金额/元
PaymentTypeID uint `json:"payment_type_id"` //收款方式
Date dts.Date `json:"date"` //计划收款日期
Remark string `json:"remark"` //备注
Batch *Tag `json:"batch,omitempty" gorm:"foreignKey:BatchID"`
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,54 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// ProductPower 产品权限
type ProductPower int8
const (
SellPower ProductPower = iota + 1 // 销售
PurchasePower // 采购
GiftPower // 赠送
)
func (p ProductPower) Valid() bool {
return p >= SellPower && p <= GiftPower
}
// Product 产品
type Product struct {
ID uint `json:"id" gorm:"primarykey"` // 产品编号
Code string `json:"code" gorm:"uniqueIndex"` // 产品编码,为系统扩展和对外开放时使用
Title string `json:"title" gorm:"uniqueIndex"` // 产品名称
Description dts.NullString `json:"description"` // 产品描述
Content dts.NullString `json:"content" gorm:"type:text"` // 产品内容
CategoryID uint `json:"category_id"` // 所属分类编号
Category *ProductCategory `json:"category,omitempty"` // 所属分类
Brand dts.NullString `json:"brand"` // 品牌,可选
Spec dts.NullString `json:"spec"` // 产品规格,可选
Unit string `json:"unit"` // 计量单位 - 台、套、箱、个、件、盒、片
CostPrice float64 `json:"cost_price" gorm:"type:numeric(8,2)"` // 成本单价,预设
SalePrice float64 `json:"sale_price" gorm:"type:numeric(8,2)"` // 标准售价(含税),预设
Photos []string `json:"photos" gorm:"type:text;serializer:json"` // 产品相册
Powers dts.Slice[ProductPower] `json:"powers"` // 产品权限
Inbound int `json:"inbound"` // 总入库数量,历次入库累加的结果
Outbound int `json:"outbound"` // 总出库数量,历次出库累加的结果
Stock int `json:"stock"` // 当前可用库存,与 Hold 相加之和为当前的总库存
Hold int `json:"hold"` // 锁定(冻结)库存,与 Stock 相加之和为当前的总库存
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,26 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// ProductCategory 产品分类
type ProductCategory struct {
ID uint `json:"id" gorm:"primarykey"` // 类型ID
Name string `json:"name" gorm:"uniqueIndex"` // 类型名称
Sort int `json:"sort"` // 分类排序
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt time.Time `json:"updated_at"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,36 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// PurchaseAudit 采购审核
//
// 可以对采购申请驳回,所以一个采购申请会存在多次审核
type PurchaseAudit struct {
ID uint `json:"id" gorm:"primaryKey"` // 审核ID
RequisitionID uint `json:"requisition_id"` // 采购单ID
AuditorID uint `json:"auditor_id"` // 审核人ID(公司员工)
DepartmentID uint `json:"department_id"` // 审批人归属部门ID
Result bool `json:"result"` // 审批结果 - true审批通过、false不通过
FailureReason dts.NullString `json:"failure_reason"` // 不通过原因
Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求
Auditor *Employee `json:"auditor,omitempty" gorm:"foreignKey:AuditorID"` // 审核人
Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 审批人归属部门
// 数据操作
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,36 @@
package models
import (
"database/sql"
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// PurchaseExecution 采购执行
type PurchaseExecution struct {
ID uint `json:"id" gorm:"primaryKey"` // 审核ID
RequisitionID uint `json:"requisition_id"` // 采购单ID
ExecutorID uint `json:"executor_id"` // 采购员ID(员工)
DepartmentID uint `json:"department_id"` // 采购归属部门ID
PurchasingDate dts.NullDate `json:"purchasing_date"` // 实际采购时间
EstimatedArrivalDate dts.NullDate `json:"estimated_arrival_date"` // 预计到货日期
Result sql.NullString `json:"purchased_result"` // 采购结果 - purchased已采购、sufficient库存充足
Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求
Executor *Employee `json:"executor,omitempty" gorm:"foreignKey:ExecutorID"` // 采购员(员工)
Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 采购归属部门
// 数据操作
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,57 @@
package models
import (
"ims/util/db/dts"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
type PurchaseOrderType int8
const (
PlannedOrder PurchaseOrderType = iota + 1 // 计划订单
PurchaseStorage // 采购入库
PurchaseReturn // 采购退货
)
// PurchaseOrder 采购订单
type PurchaseOrder struct {
// 采购订单基础信息
ID uint `json:"id" gorm:"primaryKey"` // 审核ID
Code string `json:"code"` // 采购订单编号
RequisitionID uint `json:"requisition_id"` // 采购单ID
Name string `json:"name"` // 采购订单名称
Type PurchaseOrderType `json:"type"` // 订单类型
ContractDate dts.Date `json:"contract_date"` // 订单签订日期
DeliveryDate dts.Date `json:"delivery_date"` // 订单交付日期
PurchaserID uint `json:"purchaser_id"` // 采购负责人ID
DepartmentID uint `json:"department_id"` // 采购归属部门ID
WarehouseID uint `json:"warehouse_id"` // 入库仓库ID
PreferentialPrice decimal.Decimal `json:"preferential_price"` // 优惠金额/元
RemittanceID uint `json:"remittance_id"` // 财务信息
SettlementMethodID uint `json:"settlement_method_id"` // 结算方式
//Attachments []types.File `json:"attachments"` // 合同附件上传
Products []PurchaseProduct `json:"products,omitempty" gorm:"foreignKey:OrderID"` // 采购产品明细
Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求
Purchaser *Employee `json:"purchaser,omitempty" gorm:"foreignKey:PurchaserID"` // 采购负责人
Department *Department `json:"department,omitempty" gorm:"foreignKey:DepartmentID"` // 采购归属部门
Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignKey:WarehouseID"` // 入库仓库
DistributionPlans []DistributionPlan `json:"distribution_plans" gorm:"polymorphic:Owner;polymorphicValue:purchase"` // 到货计划
PaymentPlans []PaymentPlan `json:"payment_plans" gorm:"polymorphic:Owner;polymorphicValue:purchase"` // 付款计划
Remittance *Remittance `json:"remittance,omitempty" gorm:"foreignKey:RemittanceID"` // 财务信息
SettlementMethod *Tag `json:"settlement_method" gorm:"foreignKey:SettlementMethodID"` // 结算方式
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,32 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// PurchaseProduct 管理产品
type PurchaseProduct struct {
ID uint `json:"id" gorm:"primarykey"` // 入库ID
RequisitionID dts.NullUint `json:"requisition_id"` // 采购请求ID
OrderID dts.NullUint `json:"order_id"` // 采购订单ID
ProductID uint `json:"product_id"` // 采购的商品ID
Amount uint `json:"amount"` // 采购数量需求
Requisition *PurchaseRequisition `json:"requisition,omitempty" gorm:"foreignKey:RequisitionID"` // 所属采购请求
Order *PurchaseOrder `json:"order,omitempty" gorm:"foreignKey:OrderID"` // 采购订单
Product *Product `json:"product,omitempty" gorm:"foreignKey:ProductID"` // 采购的商品
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,53 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
type ArrivedAddress struct {
ProvinceID int `json:"province_id"` // 省份编号
CityID int `json:"city_id"` // 城市编号
CountyID int `json:"county_id"` // 区县编号
Address string `json:"address"` // 详细地址
Province *District `json:"province,omitempty" gorm:"foreignKey:ProvinceID"`
City *District `json:"city,omitempty" gorm:"foreignKey:CityID"`
County *District `json:"county,omitempty" gorm:"foreignKey:CountyID"`
}
// PurchaseRequisition 请购单
type PurchaseRequisition struct {
ID uint `json:"id" gorm:"primaryKey"` // 入库ID
Code string `json:"code" gorm:"unique"` // 采购申请编号
RequesterID uint `json:"requester_id"` // 申请人ID(员工)
DepartmentID uint `json:"department_id"` // 申请人归属部门ID
PlannedPurchaseDate dts.Date `json:"planned_purchase_date"` // 计划采购日期
PlannedArrivedDate dts.Date `json:"planned_arrived_date"` // 计划到货日期
ReasonID dts.NullUint `json:"reason_id"` // 需求原因ID - 补货、库存预警、销售需求
WarehouseID dts.NullUint `json:"warehouse_id"` // 入库仓库ID
ArrivedTo *ArrivedAddress `json:"arrived_to" gorm:"embedded;embeddedPrefix:arrived_"` // 到货地址
Remark string `json:"remark"` // 备注
Requester *Employee `json:"requester" orm:"foreignKey:RequesterID"` // 申请人(员工)
Department *Department `json:"department" orm:"foreignKey:DepartmentID"` // 申请人归属部门
Reason *Tag `json:"reason,omitempty" gorm:"foreignKey:ReasonID"` // 需求原因
Warehouse *Warehouse `json:"warehouse,omitempty" gorm:"foreignKey:WarehouseID"` // 入库仓库
Products []PurchaseProduct `json:"products,omitempty" gorm:"foreignKey:RequisitionID"` // 采购商品列表
Audits []PurchaseAudit `json:"audits,omitempty" gorm:"foreignKey:RequisitionID"` // 审核记录
Executions []PurchaseExecution `json:"executions,omitempty" gorm:"foreignKey:RequisitionID"` // 执行记录
// 数据操作
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,49 @@
package models
import (
"time"
"gorm.io/gorm"
)
type TaxType int8
// 针对供应商
const (
_ TaxType = iota + 1 // 增值税专用发票
_ // 增值税普通发票
)
// 正对消费者
const (
_ TaxType = iota + 3 // 纸质专票
_ // 纸质普票
_ // 电子普票
_ // 电子专票
_ // 全电专票
_ // 全电普票
_ // 无需开票
)
const (
RemittanceForCustomer = "customer" // 消费者
RemittanceForSupplier = "supplier" // 供应商
)
// Remittance 汇款信息
type Remittance struct {
ID uint `json:"id" gorm:"primarykey"` // 汇款信息编号
OwnerID uint `json:"owner_id"` // 所属者编号
OwnerType string `json:"owner_type"` // 所属者类型
InvoiceTitle string `json:"invoice_title"` // 发票抬头
InvoiceNumber string `json:"invoice_number"` // 发票税号
TaxType TaxType `json:"tax_type"` // 税种
TaxRate int `json:"tax_rate"` // 增值税税率%
BlankAccount string `json:"blank_account"` // 银行账户
BankName string `json:"blank_name"` // 开户银行
Telephone string `json:"telephone"` // 开户电话
Email string `json:"email"` // 收票邮箱
CreatedAt time.Time `json:"create_time"` // 创建时间
UpdatedAt time.Time `json:"update_time"` // 上次更新时间
DeletedAt gorm.DeletedAt `json:"delete_time,omitempty"` // 数据删除时间
}

@ -0,0 +1,44 @@
package models
import (
"ims/util/db/dts"
"time"
"github.com/shopspring/decimal"
"gorm.io/gorm"
)
// Supplier 供应商
type Supplier struct {
ID uint `json:"id" gorm:"primarykey"` // 供应商编号
Code string `json:"code" gorm:"uniqueIndex"` // 供应商编码
Name string `json:"name" gorm:"uniqueIndex"` // 供应商名称
TypeID uint `json:"type_id"` // 分类编号
LevelID dts.NullUint `json:"level_id"` // 供应商级别
SignedAt time.Time `json:"signed_at"` // 签约时间(签约开始日期)
ExpiredAt dts.NullTime `json:"expired_at"` // 到期时间(签约结束日期)
SettlementID dts.NullUint `json:"settlement_id"` // 结算方式
CreditLimit decimal.Decimal `json:"credit_limit"` // 信用额度
PrincipalID dts.NullUint `json:"principal_id"` // 销售负责人编号
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt time.Time `json:"updated_at"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
Contracts []File `json:"contracts" gorm:"polymorphic:Owner;polymorphicValue:supplier-contract"` // 合同附件
Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 客户地址
Remittance *Remittance `json:"remittance" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 财务信息
Linkmen []Linkman `json:"linkmen,omitempty" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 联系人列表
Certifications []Certification `json:"certifications" gorm:"polymorphic:Owner;polymorphicValue:supplier"` // 资质列表
Principal *Employee `json:"principal" gorm:"foreignKey:PrincipalID"` // 销售负责人
Settlement *Tag `json:"settlement" gorm:"foreignKey:SettlementID"` // 结算方式
Type *Tag `json:"type,omitempty" gorm:"foreignKey:TypeID"` // 供应商分类 - 比如:成品供应商、零件供应商、集合供应商等
Level *Tag `json:"level,omitempty" gorm:"foreignKey:LevelID"` // 供应商等级
}

@ -0,0 +1,35 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// SupplyPrice 供应商价格表
// https://www.jiandaoyun.com/index/solution_center/app/21774?utm_src=fazxjxcshseo
type SupplyPrice struct {
ID uint `json:"id" gorm:"primarykey"` // 价格编号
SupplierID uint `json:"supplier_id"` // 供应商编号
ProductID uint `json:"product_id"` // 供应产品编号
ReferencePrice int `json:"reference_price"` // 标准采购单价(含税)
PurchasePriceWithTax int `json:"purchase_price_with_tax"` // 采购单价(含税)
DiscountRate int `json:"discount_rate"` // 折扣率
PurchasePrice int `json:"purchase_price"` // 采购单价(不含税)
TaxRate int `json:"tax_rate"` // 增值税税率
TaxValue int `json:"tax_value"` // 税额,单位分
SubmitterID uint `json:"submitter_id"` // 提交人编号
Submitter *Employee `json:"submitter" gorm:"foreignKey:SubmitterID"` // 提交人信息
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt time.Time `json:"updated_at"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,52 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// TagType 标签类型
type TagType int8
const (
TagCustomerType TagType = iota + 1 // 客户分类,比如:潜在客户、成交客户、战略合作等;
TagCustomerLabel // 客户标签
TagPriceStrategy // 价格策略,比如:标准售价、一级售价、二级售价等;
TagSettlementMethod // 结算方式,比如:现结、周结、月结、季结等
TagSupplierType // 供应商分类
TagSupplierLevel // 供应商等级
TagWarehouseType // 仓库类型
TagPurchaseReason // 需求原因,比如:补货、库存预警、销售需求等
TagPaymentType // 支付方式,比如:银行转账、现金支付、微信支付、支付宝支付、银行支票等
TagPaymentBatch // 支付批次,比如:整批货款、定金、第一批货款、第二批货款、第三批货款
TagDistributionBatch // 配送批次,比如:整批、第一批、第二批、第三批等
Tag_ // 在这个前面添加类型,而不用修改 Valid 方法
)
// Valid 是否有效
func (t TagType) Valid() bool {
return t >= TagCustomerType && t < Tag_
}
// Tag 通用标签
type Tag struct {
ID uint `json:"id" gorm:"primarykey"` // 标签编号
Type TagType `json:"type" gorm:"uniqueIndex:uni_typed_name"` // 标签类型
Name string `json:"name" gorm:"uniqueIndex:uni_typed_name"` // 标签名称
Color string `json:"color" gorm:"default:null"` // 标签颜色
Value dts.Any `json:"value"` // 绑定的值
Sort int `json:"sort"` // 标签排序
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
}

@ -0,0 +1,21 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// Tenant 平台租户
type Tenant struct {
ID uint `json:"id" gorm:"primaryKey;comment:租户ID"`
AccountId uint `json:"account_id" gorm:"comment:公司账户编号(创建者)"`
Name string `json:"name" gorm:"size:200;uniqueIndex;comment:'公司名称'"`
Description dts.NullString `json:"description" gorm:"size:500;comment:公司介绍"`
CreatedAt time.Time `json:"created_at" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updated_at" gorm:"comment:更新时间"`
DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"comment:删除时间"`
Account *Account `json:"account,omitempty" gorm:"foreignKey:AccountId"` // 公司账号
}

@ -0,0 +1,21 @@
package models
// SimpleStatus 简单状态
type SimpleStatus bool
const (
StatusOn SimpleStatus = true
StatusOff SimpleStatus = false
)
// AuditableStatus 具有中间能力的状态
type AuditableStatus int8
const (
StatusDisabled AuditableStatus = iota - 1 // 被禁用
StatusAuditing // 待审核
StatusEnabled // 已启用
)
// Quality 品种
type Quality int

@ -0,0 +1,90 @@
package models
import (
"errors"
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// Warehouse 仓库
type Warehouse struct {
Id uint `json:"id" gorm:"primarykey"` // 仓库ID
Name string `json:"name" gorm:"index:,unique,composite:uni_name_with_and_company"` // 仓库名称
Code string `json:"code"` // 仓库编码
TypeID uint `json:"type_id"` // 仓库类型编号
Address *Address `json:"address,omitempty" gorm:"polymorphic:Owner;polymorphicValue:customer"` // 仓库地址
Capacity int `json:"capacity"` // 仓库容量/立方
ManagerID dts.NullUint `json:"manager_id"` // 仓库主管编号
Status SimpleStatus `json:"status"` // 仓库状态
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
Type *Tag `json:"type" gorm:"foreignKey:TypeID"` // 仓库类型
Manager *Employee `json:"manager" gorm:"foreignKey:ManagerID"` // 仓库主管
}
func (w *Warehouse) testType(tx *gorm.DB) error {
var typ Tag
err := tx.Unscoped().Where("id", w.TypeID).First(&typ).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("仓库分类不存在")
}
return err
}
if typ.Type != TagWarehouseType {
return errors.New("不是有效的仓库分类")
}
if typ.DeletedAt.Valid {
return errors.New("仓库分类已删除")
}
return nil
}
func (w *Warehouse) testManager(tx *gorm.DB) error {
if !w.ManagerID.Valid {
return nil
}
var manager Employee
err := tx.Unscoped().First(&manager, "id", w.ManagerID.Uint).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("员工不存在")
}
return err
}
// TODO 验证是不是公司的员工
if manager.DeletedAt.Valid {
return errors.New("员工已删除")
}
return nil
}
func (w *Warehouse) test(tx *gorm.DB) error {
err := w.testType(tx)
if err != nil {
return err
}
return w.testManager(tx)
}
// BeforeCreate 实现 callbacks.BeforeCreateInterface 接口
func (w *Warehouse) BeforeCreate(tx *gorm.DB) error {
return w.test(tx)
}
// BeforeUpdate 实现 callback.BeforeUpdateInterface 接口
func (w *Warehouse) BeforeUpdate(tx *gorm.DB) error {
return w.test(tx)
}

@ -0,0 +1,34 @@
package models
import (
"ims/util/db/dts"
"time"
"gorm.io/gorm"
)
// WarehouseLocation 仓位
type WarehouseLocation struct {
Id uint `json:"id" gorm:"primarykey"` // 仓位ID
Code string `json:"code"` // 仓位编码
WarehouseId uint `json:"warehouse_id" gorm:"index:,unique,composite:uni_code_with_warehouse_and_company"` // 所属仓库ID
Sequence string `json:"sequence" gorm:"index:,unique,composite:uni_code_with_warehouse_and_company"` // 仓位序号
Capacity int `json:"capacity"` // 仓位容量/立方
Remark string `json:"remark"` // 仓位备注
ManagerId dts.NullUint `json:"manager_id"` // 仓位主管编号
Status SimpleStatus `json:"status"` // 是否启用
CreatedBy uint `json:"created_by"` // 创建者(员工编号)
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedBy dts.NullUint `json:"updated_by"` // 创建者(员工编号)
UpdatedAt dts.NullTime `json:"updated_at" gorm:"autoUpdateTime:false"` // 上次操作时间
DeletedBy dts.NullUint `json:"deleted_by"` // 删除者(员工编号)
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` // 删除数据时间
Creator *Employee `json:"creator,omitempty" gorm:"foreignKey:CreatedBy"` // 创建数据的员工
Updater *Employee `json:"updater,omitempty" gorm:"foreignKey:UpdatedBy"` // 上次更新数据的员工
Deleter *Employee `json:"deleter,omitempty" gorm:"foreignKey:DeletedBy"` // 删除数据的员工
Warehouse *Warehouse `json:"warehouse,omitempty"` // 所属仓库
Manager *Employee `json:"manager" gorm:"foreignKey:ManagerId"` // 仓位主管
}

@ -0,0 +1,13 @@
# 使用 gormigrate 库实现数据库迁移
迁移工具 Github
```text
https://github.com/go-gormigrate/gormigrate
```
## 迁移文件命名规范
```text
时间 + 顺序 + 行为
```

@ -0,0 +1,39 @@
package database
import (
"context"
"fmt"
"ims/database/system"
"ims/database/tenant"
"ims/util/db"
"ims/util/log"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
func Init(ctx context.Context) error {
return System(db.FromContext(ctx), func(m *db.Migrator) error {
return m.Migrate()
})
}
func System(tx *gorm.DB, fn func(m *db.Migrator) error) error {
return exec(tx, 0, fn, system.Migrations)
}
func Tenant(tx *gorm.DB, tenantId uint, fn func(m *db.Migrator) error) error {
return exec(tx, tenantId, fn, tenant.Migrations)
}
func exec(tx *gorm.DB, tenantId uint, fn func(m *db.Migrator) error, migrations []*gormigrate.Migration) error {
return fn(&db.Migrator{
Schema: db.TenantSchema(tenantId),
TenantId: tenantId,
DB: tx,
Migrations: migrations,
Log: func(s string, a ...any) {
log.Debug(fmt.Sprintf(s, a...))
},
})
}

@ -0,0 +1,21 @@
package system
import (
"ims/app/models"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var m202409291240_01_initialize_database = gormigrate.Migration{
ID: "202409291240_01_initialize_database",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&models.Account{},
&models.Tenant{},
)
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

@ -0,0 +1,32 @@
package system
import (
"ims/app/models"
"ims/util/db/dts"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var m202409291240_02_create_super_account = gormigrate.Migration{
ID: "202409291240_02_create_super_account",
Migrate: func(tx *gorm.DB) error {
return tx.Model(&models.Account{}).FirstOrCreate(
&models.Account{
ID: 1,
Nickname: "超级管理员",
AvatarUrl: "https://gravatar.com/userimage/256315286/98080c737d1d5c9f4d131266245ea8c9.jpeg?size=256",
Username: "admin",
RawPassword: "111111",
Email: dts.NullString{
String: "hupeh@qq.com",
Valid: true,
},
},
models.Account{ID: 1},
).Error
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

@ -0,0 +1,10 @@
package system
import (
"github.com/go-gormigrate/gormigrate/v2"
)
var Migrations = []*gormigrate.Migration{
&m202409291240_01_initialize_database,
&m202409291240_02_create_super_account,
}

@ -0,0 +1,49 @@
package tenant
import (
"ims/app/models"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var m202409291240_01_initialize_database = gormigrate.Migration{
ID: "202409291240_01_initialize_database",
Migrate: func(tx *gorm.DB) error {
return tx.AutoMigrate(
&models.Address{},
&models.Certification{},
&models.Customer{},
&models.CustomerTrack{},
&models.Department{},
&models.DistributionPlan{},
&models.District{},
&models.Employee{},
&models.File{},
&models.Inbound{},
&models.InboundItem{},
&models.Linkman{},
&models.Material{},
&models.Outbound{},
&models.OutboundItem{},
&models.PaymentPlan{},
&models.Product{},
&models.ProductCategory{},
&models.PurchaseAudit{},
&models.PurchaseExecution{},
&models.PurchaseOrder{},
&models.PurchaseProduct{},
&models.PurchaseRequisition{},
&models.Remittance{},
&models.Supplier{},
&models.SupplyPrice{},
&models.Tag{},
&models.Warehouse{},
&models.WarehouseLocation{},
)
},
Rollback: func(tx *gorm.DB) error {
//return tx.Migrator().DropTable("credit_cards")
return nil
},
}

@ -0,0 +1,18 @@
package tenant
import (
"ims/app/models"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var m202409291240_02_initialize_districts = gormigrate.Migration{
ID: "202409291240_02_initialize_districts",
Migrate: func(tx *gorm.DB) error {
return models.InstallDistricts(tx)
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("TRUNCATE TABLE " + tx.NamingStrategy.TableName("district")).Error
},
}

@ -0,0 +1,10 @@
package tenant
import (
"github.com/go-gormigrate/gormigrate/v2"
)
var Migrations = []*gormigrate.Migration{
&m202409291240_01_initialize_database,
&m202409291240_02_initialize_districts,
}

@ -0,0 +1,49 @@
module ims
go 1.22.0
require (
github.com/go-gormigrate/gormigrate/v2 v2.1.3
github.com/go-redis/cache/v9 v9.0.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/jinzhu/inflection v1.0.0
github.com/olekukonko/tablewriter v0.0.5
github.com/redis/go-redis/v9 v9.6.1
github.com/rs/xid v1.6.0
github.com/shopspring/decimal v1.4.0
golang.org/x/sync v0.8.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.12
zestack.dev/cast v0.0.0-20240523001414-e34212374a23
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f
zestack.dev/env v0.0.0-20240108012311-632035163eec
zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082
zestack.dev/log v0.0.0-20240523001421-24d58305cd03
zestack.dev/misc v0.0.0-20240815120320-73293b130b9f
zestack.dev/slim v0.0.0-20240913145858-96a68a237caf
zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c
)
require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/klauspost/compress v1.13.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.9 // indirect
github.com/vmihailenco/go-tinylfu v0.2.2 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.21.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
)

269
go.sum

@ -0,0 +1,269 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-gormigrate/gormigrate/v2 v2.1.3 h1:ei3Vq/rpPI/jCJY9mRHJAKg5vU+EhZyWhBAkaAomQuw=
github.com/go-gormigrate/gormigrate/v2 v2.1.3/go.mod h1:VJ9FIOBAur+NmQ8c4tDVwOuiJcgupTG105FexPFrXzA=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0=
github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU=
github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk=
github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0=
github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo=
github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw=
github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo=
github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc=
github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM=
github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg=
github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM=
github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y=
github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM=
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/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA=
github.com/redis/go-redis/v9 v9.6.1 h1:HHDteefn6ZkTtY5fGUE8tj8uy85AHk6zP7CpzIAM0y4=
github.com/redis/go-redis/v9 v9.6.1/go.mod h1:0C0c6ycQsdpVNQpxb1njEQIqkx5UcsM8FJCQLgE9+RA=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI=
github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q=
github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc=
github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA=
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
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/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
zestack.dev/cast v0.0.0-20240523001414-e34212374a23 h1:xlcaGxt2tomZX91jzN7mk4vyy+d3fImb7FQcr8hd9Aw=
zestack.dev/cast v0.0.0-20240523001414-e34212374a23/go.mod h1:Z0WKru8M3FN1fDhqVEzw+I8MNNOPo3bc9lzB6oEJW6s=
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f h1:5tSc5qVy2f6vyG5LVRETRe8tAFeZKxcjFsl0Jy7rRUQ=
zestack.dev/color v0.0.0-20240522040239-8edfb0bd027f/go.mod h1:lHvP85VRaqzm4E/9Q9kW/WmEnEHL4PQ9Fls5xDah3zI=
zestack.dev/env v0.0.0-20240108012311-632035163eec h1:VOz6k4dJeHHoozGrF6ISIGaR7amRXGCJfMH+0amYrf0=
zestack.dev/env v0.0.0-20240108012311-632035163eec/go.mod h1:TgAagxCpU5oBFIHQSSuVcGw3ygfNvm3CSU92t/ufEWA=
zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082 h1:RXuVELrTjlZcKHWxX4+GuRcHPRWoOYIkr0u7F+EtYP8=
zestack.dev/is v0.0.0-20240108012312-1fe7fd4da082/go.mod h1:JDC6HTLLY20gU2PWx7BHYun97lOQGtYQI2acQmTccy8=
zestack.dev/log v0.0.0-20240523001421-24d58305cd03 h1:ZNnBVHIiV4R2SBypa24SLuhkrc9unlvPdXg/u0ZqSro=
zestack.dev/log v0.0.0-20240523001421-24d58305cd03/go.mod h1:1ZoaLtIYHxx/J7jFoQpNLxsCIcpBZ1NQWFjafTEBzN8=
zestack.dev/misc v0.0.0-20240815120320-73293b130b9f h1:77RL7xP7l0kV8s12EDo/JcQDbYuuwfbBO3JhYu9V8MI=
zestack.dev/misc v0.0.0-20240815120320-73293b130b9f/go.mod h1:dfX3xtBz37zBEsTnO1u/XPpXeq7ZFZHDXqoz9vllfDw=
zestack.dev/slim v0.0.0-20240913145858-96a68a237caf h1:axDVuMqH1R8PkLLeiD+B9LcjfG/07oH0ZWgOv7XixnU=
zestack.dev/slim v0.0.0-20240913145858-96a68a237caf/go.mod h1:QhpKfYJGCC13b11uqWOjxgEGLJUUwLyJ6XPsGBLcjC0=
zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c h1:33nVg7TNj6lvcp8Uw3FaCAdQ62oS7Jj+vNlAPm5eLOE=
zestack.dev/v v0.0.0-20240502170943-a14e2d946f2c/go.mod h1:kebq/o/Qw/GVuDvt25DfNhXNAIi9/5U+szrGfPmekFk=

@ -0,0 +1,48 @@
package main
import (
"context"
"ims/app"
"ims/util/log"
"os"
"os/signal"
"syscall"
"zestack.dev/env"
)
func main() {
ctx, cancel := context.WithCancelCause(context.Background())
// 读取环境变量配置
if err := env.Init(); err != nil {
cancel(err)
log.Error("failed to initialize environ variables", "error", err)
os.Exit(1)
}
// 启动应用
if err := app.Start(ctx); err != nil {
cancel(err)
log.Error("failed to start the application", "error", err)
os.Exit(1)
}
// 监听信号
singleChannel := make(chan os.Signal, 1)
signal.Notify(singleChannel, syscall.SIGINT, syscall.SIGTERM)
select {
case <-ctx.Done():
case <-singleChannel:
}
// 停止应用
if err := app.Stop(); err != nil {
cancel(err)
log.Error("failed to stop the application", "error", err)
os.Exit(1)
}
cancel(context.Canceled)
os.Exit(0)
}

@ -0,0 +1,87 @@
package backoff
import (
"cmp"
"fmt"
"log"
"time"
)
// Default configuration values.
const (
DefaultMaxRetries = 3
DefaultRetryInterval = 2 * time.Second
DefaultMaxInterval = 30 * time.Second
)
// Options is the configuration options for retry operations.
type Options struct {
// MaxRetries is the maximum number of retry attempts.
// Default is 3.
MaxRetries int `json:"gmt_max_retries"`
// Interval is the initial interval between retry attempts.
// Default is 2 seconds.
Interval time.Duration `json:"gmt_retry_interval"`
// MaxInterval is the maximum interval between retry attempts.
// Default is 30 seconds.
MaxInterval time.Duration `json:"gmt_retry_max_interval"`
}
// Option is a function that applies an option to an Options instance.
type Option func(*Options)
func (o *Options) Apply(opts ...Option) {
for _, opt := range opts {
opt(o)
}
o.MaxRetries = cmp.Or(max(o.MaxRetries, 0), DefaultMaxRetries)
o.Interval = cmp.Or(max(o.Interval, 0), DefaultRetryInterval)
o.MaxInterval = cmp.Or(max(o.MaxInterval, 0), DefaultMaxInterval)
}
// WithMaxRetries sets the maximum number of retry attempts.
func WithMaxRetries(n int) Option {
return func(o *Options) {
o.MaxRetries = n
}
}
// WithRetryInterval sets the initial interval between retry attempts.
func WithRetryInterval(i time.Duration) Option {
return func(o *Options) {
o.Interval = i
}
}
// WithMaxInterval sets the maximum interval between retry attempts.
func WithMaxInterval(i time.Duration) Option {
return func(o *Options) {
o.MaxInterval = i
}
}
// Retry executes the provided function with retry logic using exponential backoff.
func Retry(fn func() error, opts ...Option) error {
o := new(Options)
o.Apply(opts...)
interval := o.Interval
for i := 0; i < o.MaxRetries; i++ {
if err := fn(); err != nil {
if i == o.MaxRetries-1 {
return fmt.Errorf("backoff: max retries (%d) exceeded: %w", o.MaxRetries, err)
}
time.Sleep(interval)
// TODO 日志
log.Printf("backoff: retrying after %s (attempt %d of %d) due to error: %v", interval, i+1, o.MaxRetries, err)
interval *= 2
if interval > o.MaxInterval {
interval = o.MaxInterval
}
} else {
return nil
}
}
return nil
}

@ -0,0 +1,57 @@
package backoff
import (
"errors"
"testing"
"time"
)
func TestRetry(t *testing.T) {
tests := []struct {
name string
fn func() error
opts []Option
expectError bool
}{
{
name: "Success on first try",
fn: func() error {
return nil
},
opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)},
expectError: false,
},
{
name: "Fail all retries",
fn: func() error {
return errors.New("fail")
},
opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)},
expectError: true,
},
{
name: "Success on second try",
fn: func() func() error {
static := 0
return func() error {
static++
if static == 2 {
return nil
}
return errors.New("fail")
}
}(),
opts: []Option{WithMaxRetries(3), WithRetryInterval(1 * time.Second)},
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Retry(tt.fn, tt.opts...)
if (err != nil) != tt.expectError {
t.Errorf("expected error: %v, got: %v", tt.expectError, err)
}
})
}
}

100
util/cache/cache.go vendored

@ -0,0 +1,100 @@
package cache
import (
"context"
"errors"
"fmt"
"time"
"ims/util/rdb"
"github.com/go-redis/cache/v9"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
"zestack.dev/env"
)
var (
engine *cache.Cache
sfg singleflight.Group
ErrCacheMiss = cache.ErrCacheMiss
)
func Init() error {
engine = cache.New(&cache.Options{
Redis: rdb.Redis(),
LocalCache: cache.NewTinyLFU(1000, time.Minute),
})
return nil
}
func realKey(key string) string {
return fmt.Sprintf("%s:%s", env.String("CACHE_PREFIX", "cache"), key)
}
func Get(ctx context.Context, key string, value any) error {
return engine.Get(ctx, realKey(key), value)
}
func GetOr(ctx context.Context, key string, value any, or func(ctx context.Context, key string, value any) error) error {
_, err, _ := sfg.Do(key, func() (interface{}, error) {
err := engine.Get(ctx, realKey(key), value)
if err == nil {
return nil, nil
}
if errors.Is(err, cache.ErrCacheMiss) {
err = or(ctx, key, value)
if err == nil {
_ = Set(ctx, key, value)
}
}
if errors.Is(err, redis.Nil) {
return nil, ErrCacheMiss
}
return nil, err
})
return err
}
// Set 设置缓存 默认 1 小时有效
func Set(ctx context.Context, key string, value any) error {
return engine.Set(&cache.Item{
Ctx: ctx,
Key: realKey(key),
Value: value,
TTL: env.Duration("CACHE_TTL", time.Hour),
})
}
// SetNX only sets the key if it does not already exist.
func SetNX(ctx context.Context, key string, value any) error {
return engine.Set(&cache.Item{
Ctx: ctx,
Key: realKey(key),
Value: value,
TTL: env.Duration("CACHE_TTL", time.Hour),
SetNX: true,
})
}
// SetXX only sets the key if it already exists.
func SetXX(ctx context.Context, key string, value any) error {
return engine.Set(&cache.Item{
Ctx: ctx,
Key: realKey(key),
Value: value,
TTL: env.Duration("CACHE_TTL", time.Hour),
SetXX: true,
})
}
func Exists(ctx context.Context, key string) bool {
return engine.Exists(ctx, realKey(key))
}
func Delete(ctx context.Context, key string) error {
return engine.Delete(ctx, realKey(key))
}

@ -0,0 +1,140 @@
package db
import (
"gorm.io/gorm/clause"
)
type null struct {
Column any
}
func (n null) Build(builder clause.Builder) {
builder.WriteQuoted(n.Column)
builder.WriteString(" IS NULL")
}
func (n null) NegationBuild(builder clause.Builder) {
builder.WriteQuoted(n.Column)
builder.WriteString(" IS NOT NULL")
}
type between struct {
Column any
Less any
More any
}
func (b between) Build(builder clause.Builder) {
b.build(builder, " BETWEEN ")
}
func (b between) NegationBuild(builder clause.Builder) {
b.build(builder, " NOT BETWEEN ")
}
func (b between) build(builder clause.Builder, op string) {
builder.WriteQuoted(b.Column)
builder.WriteString(op)
builder.AddVar(builder, b.Less)
builder.WriteString(" And ")
builder.AddVar(builder, b.More)
}
type exists struct {
expr any
}
func (e exists) Build(builder clause.Builder) {
e.build(builder, "EXISTS")
}
func (e exists) NegationBuild(builder clause.Builder) {
e.build(builder, "NOT EXISTS")
}
func (e exists) build(builder clause.Builder, op string) {
builder.WriteString(op)
builder.WriteString(" (")
builder.AddVar(builder, e.expr)
builder.WriteString(")")
}
func Eq(col string, val any) clause.Expression {
return clause.Eq{Column: col, Value: val}
}
func Neq(col string, val any) clause.Expression {
return clause.Neq{Column: col, Value: val}
}
func Lt(col string, val any) clause.Expression {
return clause.Lt{Column: col, Value: val}
}
func Lte(col string, val any) clause.Expression {
return clause.Lte{Column: col, Value: val}
}
func Gt(col string, val any) clause.Expression {
return clause.Gt{Column: col, Value: val}
}
func Gte(col string, val any) clause.Expression {
return clause.Gte{Column: col, Value: val}
}
func Between(col string, less, more any) clause.Expression {
return between{col, less, more}
}
func NotBetween(col string, less, more any) clause.Expression {
return clause.Not(between{col, less, more})
}
func IsNull(col string) clause.Expression {
return null{col}
}
func NotNull(col string) clause.Expression {
return clause.Not(null{col})
}
func Like(col, tpl string) clause.Expression {
return clause.Like{Column: col, Value: "%" + tpl + "%"}
}
func NotLike(col, tpl string) clause.Expression {
return clause.Not(clause.Like{Column: col, Value: "%" + tpl + "%"})
}
func HasPrefix(col, prefix string) clause.Expression {
return clause.Like{Column: col, Value: prefix + "%"}
}
func NotPrefix(col, prefix string) clause.Expression {
return clause.Not(HasPrefix(col, prefix))
}
func HasSuffix(col, suffix string) clause.Expression {
return clause.Like{Column: col, Value: "%" + suffix}
}
func NotSuffix(col, suffix string) clause.Expression {
return clause.Not(HasSuffix(col, suffix))
}
func In(col string, values ...any) clause.Expression {
return clause.IN{Column: col, Values: values}
}
func NotIn(col string, values ...any) clause.Expression {
return clause.Not(clause.IN{Column: col, Values: values})
}
func Exists(expr any) clause.Expression {
return exists{expr}
}
func NotExists(expr any) clause.Expression {
return clause.Not(exists{expr})
}

@ -0,0 +1,130 @@
package db
import (
"context"
"errors"
"ims/util/backoff"
"strings"
"gorm.io/gorm"
"zestack.dev/cast"
"zestack.dev/env"
)
const (
ctxKey = "ims/util/db:engine"
)
var (
// ErrExecSQL is returned when the migrator fails to execute SQL.
ErrExecSQL = errors.New("locking: failed to execute SQL")
// ErrAcquireLock is returned when the migrator fails to acquire an advisory lock.
ErrAcquireLock = errors.New("locking: failed to acquire advisory lock")
// ErrReleaseLock is returned when the migrator fails to release an advisory lock.
ErrReleaseLock = errors.New("locking: failed to release advisory lock")
// ErrSwitchSchema is returned when the migrator fails to switch to a schema.
ErrSwitchSchema = errors.New("db: nested schema switching")
db *gorm.DB
schemaHelper SchemaHelper
)
type SchemaHelper interface {
PublicSchema() string
TenantSchema(tenantId uint) string
CurrentSchema(tx *gorm.DB) string
UseSchema(tx *gorm.DB, schema string) (reset func() error, err error)
LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (unlock func() error, err error)
CreateSchema(tx *gorm.DB, schema string) error
DropSchema(tx *gorm.DB, schema string) error
}
// Engine 获取数据库操作引擎
func Engine() *gorm.DB {
return db
}
func NewContext(ctx context.Context) context.Context {
return context.WithValue(ctx, ctxKey, FromContext(ctx))
}
func FromContext(ctx context.Context) *gorm.DB {
if ctx == nil {
return Engine()
}
engine, ok := ctx.Value(ctxKey).(*gorm.DB)
if ok && engine != nil {
return engine
}
return Engine().WithContext(ctx)
}
// WithContext 派生出基于指定上下文的数据库操作引擎
func WithContext(ctx context.Context) *gorm.DB {
return Engine().WithContext(ctx)
}
func PublicSchema() string {
return schemaHelper.PublicSchema()
}
func TenantSchema(tenantId uint) string {
return schemaHelper.TenantSchema(tenantId)
}
// UseTenant 使用指定的租户
// 切记:应该在事务中使用
func UseTenant(tx *gorm.DB, tenant uint) (reset func() error, err error) {
return schemaHelper.UseSchema(tx, schemaHelper.TenantSchema(tenant))
}
func CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) {
schema := schemaHelper.CurrentSchema(tx)
prefix := env.String("DB_TENANT_PREFIX", "tenant_")
suffix := env.String("DB_TENANT_SUFFIX")
if schema != "" && prefix != "" && strings.HasPrefix(schema, prefix) {
schema = strings.TrimPrefix(schema, prefix)
}
if schema != "" && suffix != "" && strings.HasSuffix(schema, suffix) {
schema = strings.TrimSuffix(schema, suffix)
}
if schema == "" {
return
}
var err error
tenant, err = cast.Uint(schema)
ok = err == nil && tenant > 0
return
}
func WithTenant(tx *gorm.DB, tenant uint, fn func(tx *gorm.DB) error) error {
return tx.Transaction(func(tx *gorm.DB) error {
reset, err := UseTenant(tx, tenant)
if err != nil {
return err
}
defer reset()
return fn(tx)
})
}
func LockSchema(tx *gorm.DB, schema string, opts ...backoff.Option) (unlock func() error, err error) {
if len(opts) == 0 {
return schemaHelper.LockSchema(tx, schema, nil)
}
var retry *backoff.Options
retry.Apply(opts...)
return schemaHelper.LockSchema(tx, schema, retry)
}
func CreateSchema(tx *gorm.DB, schema string) error {
return schemaHelper.CreateSchema(tx, schema)
}
func DropSchema(tx *gorm.DB, schema string) error {
return schemaHelper.DropSchema(tx, schema)
}

@ -0,0 +1,91 @@
package dts
import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// Any give a generic data type for json encoded data.
type Any struct {
data any
}
func NewAny(data any) Any {
return Any{
data: data,
}
}
// Data return data with generic Type T
func (j Any) Data() any {
return j.data
}
// Value return json value, implement driver.Valuer interface
func (j Any) Value() (driver.Value, error) {
return json.Marshal(j.data)
}
// Scan scan value into Any, implements sql.Scanner interface
func (j *Any) Scan(value any) error {
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
}
return json.Unmarshal(bytes, &j.data)
}
// MarshalJSON to output non base64 encoded []byte
func (j Any) MarshalJSON() ([]byte, error) {
return json.Marshal(j.data)
}
// UnmarshalJSON to deserialize []byte
func (j *Any) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &j.data)
}
// GormDataType gorm common data type
func (Any) GormDataType() string {
return "json"
}
// GormDBDataType gorm db data type
func (Any) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
case "mysql":
return "JSON"
case "postgres":
return "JSONB"
}
return ""
}
func (js Any) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
data, _ := js.MarshalJSON()
switch db.Dialector.Name() {
case "mysql":
if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") {
return gorm.Expr("CAST(? AS JSON)", string(data))
}
}
return gorm.Expr("?", string(data))
}

@ -0,0 +1,84 @@
package dts
import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"zestack.dev/is"
)
// Color 颜色,仅支持16进制
type Color string
// Set 设置颜色值
func (c *Color) Set(s string) bool {
if s == "" || is.HEXColor(s) {
*c = Color(s)
return true
}
return false
}
func (c Color) Value() (driver.Value, error) {
if len(c) == 0 {
return nil, nil
}
return string(c), nil
}
func (c *Color) Scan(value any) error {
if value == nil {
*c = ""
return nil
}
var ok bool
switch v := value.(type) {
case []byte:
ok = c.Set(string(v))
case string:
ok = c.Set(v)
}
if !ok {
return errors.New(fmt.Sprint("failed to unmarshal value:", value))
}
return nil
}
func (c Color) MarshalJSON() ([]byte, error) {
return json.Marshal(string(c))
}
func (c *Color) UnmarshalJSON(b []byte) error {
var str string
err := json.Unmarshal(b, &str)
if err != nil {
return err
}
if !c.Set(str) {
return errors.New("failed to unmarshal value")
}
return nil
}
func (c Color) String() string {
return string(c)
}
// GormDBDataType gorm db data type
func (Color) GormDBDataType(db *gorm.DB, field *schema.Field) string {
return "varchar(7)"
}
func (c Color) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
if len(c) == 0 {
return gorm.Expr("NULL")
}
data, _ := c.MarshalJSON()
return gorm.Expr("?", string(data))
}

@ -0,0 +1,42 @@
package dts
import (
"database/sql"
"database/sql/driver"
"time"
)
type Date time.Time
func NewDate(str string) (Date, error) {
value, err := time.Parse(time.DateOnly, str)
if err != nil {
return Date{}, err
}
return Date(value), nil
}
func (date *Date) Scan(value interface{}) (err error) {
nullTime := &sql.NullTime{}
err = nullTime.Scan(value)
*date = Date(nullTime.Time)
return
}
func (date Date) Value() (driver.Value, error) {
y, m, d := time.Time(date).Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.Time(date).Location()), nil
}
// GormDataType gorm common data type
func (date Date) GormDataType() string {
return "date"
}
func (date Date) MarshalJSON() ([]byte, error) {
return time.Time(date).MarshalJSON()
}
func (date *Date) UnmarshalJSON(b []byte) error {
return (*time.Time)(date).UnmarshalJSON(b)
}

@ -0,0 +1,104 @@
package dts
import (
"bytes"
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// Map defined JSON data type, need to implements driver.Valuer, sql.Scanner interface
type Map map[string]any
func NewMap(m map[string]any) Map {
return Map(m)
}
// Value return json value, implement driver.Valuer interface
func (m Map) Value() (driver.Value, error) {
if m == nil {
return nil, nil
}
ba, err := m.MarshalJSON()
return string(ba), err
}
// Scan scan value into Jsonb, implements sql.Scanner interface
func (m *Map) Scan(val interface{}) error {
if val == nil {
*m = make(Map)
return nil
}
var ba []byte
switch v := val.(type) {
case []byte:
ba = v
case string:
ba = []byte(v)
default:
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", val))
}
t := map[string]any{}
rd := bytes.NewReader(ba)
decoder := json.NewDecoder(rd)
decoder.UseNumber()
err := decoder.Decode(&t)
*m = t
return err
}
// MarshalJSON to output non base64 encoded []byte
func (m Map) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("null"), nil
}
t := (map[string]interface{})(m)
return json.Marshal(t)
}
// UnmarshalJSON to deserialize []byte
func (m *Map) UnmarshalJSON(b []byte) error {
t := map[string]interface{}{}
err := json.Unmarshal(b, &t)
*m = Map(t)
return err
}
// GormDataType gorm common data type
func (m Map) GormDataType() string {
return "map"
}
// GormDBDataType gorm db data type
func (Map) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
case "mysql":
return "JSON"
case "postgres":
return "JSONB"
case "sqlserver":
return "NVARCHAR(MAX)"
}
return ""
}
func (m Map) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
data, _ := m.MarshalJSON()
switch db.Dialector.Name() {
case "mysql":
if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") {
return gorm.Expr("CAST(? AS JSON)", string(data))
}
}
return gorm.Expr("?", string(data))
}

@ -0,0 +1,64 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"time"
)
type NullDate struct {
Date time.Time
Valid bool // Valid is true if Date is not NULL
}
func (date NullDate) Any() any {
if date.Valid {
return date.Date
}
return nil
}
func (nd *NullDate) Scan(value any) error {
if value == nil {
nd.Date, nd.Valid = time.Time{}, false
return nil
}
nt := new(sql.NullTime)
err := nt.Scan(value)
nd.Date = nt.Time
nd.Valid = nt.Valid
return err
}
func (nd NullDate) Value() (driver.Value, error) {
if !nd.Valid {
return nil, nil
}
y, m, d := nd.Date.Date()
return time.Date(y, m, d, 0, 0, 0, 0, nd.Date.Location()), nil
}
// GormDataType gorm common data type
func (nd NullDate) GormDataType() string {
return "date"
}
func (nd NullDate) MarshalJSON() ([]byte, error) {
if nd.Valid {
return nd.Date.MarshalJSON()
}
return json.Marshal(nil)
}
func (nd *NullDate) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
nd.Valid = false
return nil
}
err := nd.Date.UnmarshalJSON(b)
if err == nil {
nd.Valid = true
}
return err
}

@ -0,0 +1,62 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
// NullString represents a string that may be null.
// NullString implements the [Scanner] interface so
// it can be used as a scan destination:
//
// var s NullString
// err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s)
// ...
// if s.Valid {
// // use s.String
// } else {
// // NULL value
// }
type NullString struct {
String string
Valid bool // Valid is true if String is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullString) Scan(value any) error {
if value == nil {
ns.String, ns.Valid = "", false
return nil
}
ss := new(sql.NullString)
err := ss.Scan(value)
ns.String = ss.String
ns.Valid = ss.Valid
return err
}
// Value implements the driver Valuer interface.
func (ns NullString) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return ns.String, nil
}
func (ns NullString) MarshalJSON() ([]byte, error) {
if ns.Valid {
return json.Marshal(ns.String)
}
return json.Marshal(nil)
}
func (ns *NullString) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
ns.Valid = false
return nil
}
err := json.Unmarshal(b, &ns.String)
ns.Valid = err == nil
return err
}

@ -0,0 +1,53 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
"time"
)
// NullTime represents a [time.Time] that may be null.
// NullTime implements the [Scanner] interface so
// it can be used as a scan destination, similar to [NullString].
type NullTime struct {
Time time.Time
Valid bool // Valid is true if Time is not NULL
}
// Scan implements the Scanner interface.
func (nt *NullTime) Scan(value any) error {
if value == nil {
nt.Time, nt.Valid = time.Time{}, false
return nil
}
st := new(sql.NullTime)
err := st.Scan(value)
nt.Time = st.Time
nt.Valid = st.Valid
return err
}
// Value implements the driver Valuer interface.
func (nt NullTime) Value() (driver.Value, error) {
if !nt.Valid {
return nil, nil
}
return nt.Time, nil
}
func (nt *NullTime) UnmarshalJSON(bytes []byte) error {
err := nt.Time.UnmarshalJSON(bytes)
if err != nil {
return err
}
nt.Valid = !nt.Time.IsZero()
return nil
}
func (nt NullTime) MarshalJSON() ([]byte, error) {
if !nt.Valid {
return json.Marshal(nil)
}
return nt.Time.MarshalJSON()
}

@ -0,0 +1,74 @@
package dts
import (
"database/sql"
"database/sql/driver"
"encoding/json"
)
type NullUint struct {
Uint uint
Valid bool // Valid is true if Uint is not NULL
}
func NewNullUint(n uint) NullUint {
return NullUint{
Uint: n,
Valid: n > 0,
}
}
func (n NullUint) Any() any {
if n.Valid {
return n.Uint
}
return nil
}
// Scan implements the Scanner interface.
func (n *NullUint) Scan(value any) error {
if value == nil {
n.Uint, n.Valid = 0, false
return nil
}
ni := new(sql.NullInt64)
err := ni.Scan(value)
if err != nil {
return err
}
if !ni.Valid {
n.Uint, n.Valid = 0, false
return nil
}
n.Uint = uint(ni.Int64)
n.Valid = true
return nil
}
// Value implements the driver Valuer interface.
func (n NullUint) Value() (driver.Value, error) {
if !n.Valid {
return nil, nil
}
// 注意:driver.Value 不支持 unit
return int64(n.Uint), nil
}
func (n NullUint) MarshalJSON() ([]byte, error) {
if n.Valid {
return json.Marshal(n.Uint)
}
return json.Marshal(nil)
}
func (n *NullUint) UnmarshalJSON(b []byte) error {
if string(b) == "null" {
n.Valid = false
return nil
}
err := json.Unmarshal(b, &n.Uint)
if err == nil {
n.Valid = true
}
return err
}

@ -0,0 +1,72 @@
package dts
import (
"context"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"strings"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
)
// Slice give a generic data type for json encoded slice data.
type Slice[T any] []T
func NewSlice[T any](s []T) Slice[T] {
return s
}
// Value return json value, implement driver.Valuer interface
func (j Slice[T]) Value() (driver.Value, error) {
return json.Marshal(j)
}
// Scan scan value into Slice[T], implements sql.Scanner interface
func (j *Slice[T]) Scan(value interface{}) error {
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New(fmt.Sprint("Failed to unmarshal JSONB value:", value))
}
return json.Unmarshal(bytes, &j)
}
// GormDataType gorm common data type
func (Slice[T]) GormDataType() string {
return "slices"
}
// GormDBDataType gorm db data type
func (Slice[T]) GormDBDataType(db *gorm.DB, field *schema.Field) string {
switch db.Dialector.Name() {
case "sqlite":
return "JSON"
case "mysql":
return "JSON"
case "postgres":
return "JSONB"
}
return ""
}
func (j Slice[T]) GormValue(ctx context.Context, db *gorm.DB) clause.Expr {
data, _ := json.Marshal(j)
switch db.Dialector.Name() {
case "mysql":
if v, ok := db.Dialector.(*mysql.Dialector); ok && !strings.Contains(v.ServerVersion, "MariaDB") {
return gorm.Expr("CAST(? AS JSON)", string(data))
}
}
return gorm.Expr("?", string(data))
}

@ -0,0 +1,70 @@
package dts
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
type URL url.URL
func NewURL(url url.URL) URL {
return URL(url)
}
func (u URL) Value() (driver.Value, error) {
return u.String(), nil
}
func (u *URL) Scan(value interface{}) error {
var us string
switch v := value.(type) {
case []byte:
us = string(v)
case string:
us = v
default:
return errors.New(fmt.Sprint("Failed to parse URL:", value))
}
uu, err := url.Parse(us)
if err != nil {
return err
}
*u = URL(*uu)
return nil
}
func (URL) GormDataType() string {
return "url"
}
func (URL) GormDBDataType(db *gorm.DB, field *schema.Field) string {
return "TEXT"
}
func (u *URL) String() string {
return (*url.URL)(u).String()
}
func (u URL) MarshalJSON() ([]byte, error) {
return json.Marshal(u.String())
}
func (u *URL) UnmarshalJSON(data []byte) error {
// ignore null
if string(data) == "null" {
return nil
}
uu, err := url.Parse(strings.Trim(string(data), `"'`))
if err != nil {
return err
}
*u = URL(*uu)
return nil
}

@ -0,0 +1,120 @@
package db
import (
"database/sql"
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"zestack.dev/env"
)
func Init() error {
var dialector gorm.Dialector
switch driver := env.String("DB_DRIVER", "postgres"); driver {
case "mysql":
schemaHelper = &mysqlSchemaHelper{}
dialector = openMysql()
case "postgres":
schemaHelper = &pgsqlSchemaHelper{}
dialector = openPostgres()
default:
return fmt.Errorf("unsupported database driver: %s", driver)
}
var err error
db, err = gorm.Open(dialector, &gorm.Config{
NamingStrategy: schema.NamingStrategy{
TablePrefix: env.String("DB_PREFIX"),
SingularTable: env.Bool("DB_SINGULAR_TABLE", false),
IdentifierMaxLength: env.Int("DB_IDENTIFIER_MAX_LENGTH", 0),
},
Logger: &dbLogger{200 * time.Millisecond},
// 在我们使用模型查询时,使用模型字段而不是通配符来查询。
// https://gorm.io/docs/advanced_query.html#Smart-Select-Fields
QueryFields: env.Bool("DB_QUERY_FIELDS", true),
// 在 AutoMigrate 或 CreateTable 时,GORM 会自动创建外键约束,
// 若要禁用该特性,可将其设置为 true 。
// https://gorm.io/docs/migration.html
DisableForeignKeyConstraintWhenMigrating: env.Bool("DB_DISABLE_FOREIGN_KEY_CONSTRAINT", false),
// 在 AutoMigrate 或 CreateTable 时,GORM 会自动创建索引,
// 若要禁用该特性,可将其设置为 true 。
IgnoreRelationshipsWhenMigrating: env.Bool("DB_IGNORE_RELATIONSHIPS", false),
// 开启方言错误转换,GORM 会将不同数据库的特定错误转换为常见的 GORM 错误类型,
// 这样,方便我们对错误进行统一处理。
// https://gorm.io/docs/error_handling.html#Dialect-Translated-Errors
// https://github.com/go-gorm/gorm/blob/master/errors.go
TranslateError: true,
})
if err != nil {
return err
}
// 如果使用的是 MySQL 数据库,则启用 InnoDB 引擎。
if db.Dialector.Name() == "mysql" {
db.Set("gorm:table_options", "ENGINE=InnoDB")
}
// 配置连接池
var raw *sql.DB
if raw, err = db.DB(); err == nil {
err = configRawDB(raw)
}
return err
}
func openMysql() gorm.Dialector {
return mysql.New(mysql.Config{
DSN: fmt.Sprintf(
"%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=%s",
env.String("DB_USER", "root"),
env.String("DB_AUTH", "password"),
env.String("DB_HOST", "localhost"),
env.Int("DB_PORT", 3306),
env.String("DB_NAME", "test"),
env.String("DB_CHARSET", "utf8mb4"),
time.Local.String(),
),
// string 类型字段的默认长度
DefaultStringSize: uint(env.Int("DB_STRING_SIZE", 256)),
// 禁用 datetime 精度,MySQL 5.6 之前的数据库不支持
DisableDatetimePrecision: true,
// 重命名索引时采用删除并新建的方式,MySQL 5.7 之前的数据库和 MariaDB 不支持重命名索引
DontSupportRenameIndex: true,
// 用 `change` 重命名列,MySQL 8 之前的数据库和 MariaDB 不支持重命名列
DontSupportRenameColumn: true,
})
}
func openPostgres() gorm.Dialector {
return postgres.Open(fmt.Sprintf(
"host=%s user=%s password=%s dbname=%s port=%d sslmode=%s TimeZone=%s",
env.String("DB_HOST", "localhost"),
env.String("DB_USER", "postgres"),
env.String("DB_AUTH", "password"),
env.String("DB_NAME", "postgres"),
env.Int("DB_PORT", 5432),
env.String("DB_SSLMODE", "disable"),
time.Local.String(),
))
}
func configRawDB(db *sql.DB) error {
// 用于设置连接池中空闲连接的最大数量。
if maxIdleConns := env.Int("DB_MAX_IDLE_CONNS", 0); maxIdleConns > 0 {
db.SetMaxIdleConns(maxIdleConns)
}
// 设置打开数据库连接的最大数量。
if maxOpenConns := env.Int("DB_MAX_OPEN_CONNS", 0); maxOpenConns > 0 {
db.SetMaxOpenConns(maxOpenConns)
}
// 设置了连接可复用的最大时间。
if connMaxLifetime := env.Duration("DB_CONN_MAX_LIFETIME", 0); connMaxLifetime > 0 {
db.SetConnMaxLifetime(connMaxLifetime)
}
return db.Ping()
}

@ -0,0 +1,84 @@
package db
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"gorm.io/gorm/logger"
"zestack.dev/slim"
)
// TODO 支持租户隔离
func getLogger(ctx context.Context) *slog.Logger {
if ctx != nil {
l, ok := ctx.Value("db:logger").(*slim.Logger)
if ok && l != nil {
return l.Logger
}
k, ok := ctx.Value("db:logger").(*slog.Logger)
if ok && k != nil {
return k
}
}
return slog.Default()
}
type dbLogger struct {
SlowThreshold time.Duration
}
// LogMode log mode
func (l *dbLogger) LogMode(logger.LogLevel) logger.Interface {
return l
}
// Info print info
func (l *dbLogger) Info(ctx context.Context, msg string, data ...any) {
getLogger(ctx).Info(fmt.Sprintf(msg, data...))
}
// Warn print warn messages
func (l *dbLogger) Warn(ctx context.Context, msg string, data ...any) {
getLogger(ctx).Warn(fmt.Sprintf(msg, data...))
}
// Error print error messages
func (l *dbLogger) Error(ctx context.Context, msg string, data ...any) {
getLogger(ctx).Error(fmt.Sprintf(msg, data...))
}
// Trace print sql message
func (l *dbLogger) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) {
elapsed := time.Since(begin)
switch {
case err != nil && !errors.Is(err, logger.ErrRecordNotFound):
sql, rows := fc()
if rows == -1 {
l.Error(ctx, "%s [rows:%v] %s [%.3fms]", err, "-", sql, float64(elapsed.Nanoseconds())/1e6)
} else {
l.Error(ctx, "%s [rows:%v] %s [%.3fms]", err, rows, sql, float64(elapsed.Nanoseconds())/1e6)
}
case elapsed > l.SlowThreshold && l.SlowThreshold != 0:
sql, rows := fc()
slowLog := fmt.Sprintf("SLOW SQL >= %v", l.SlowThreshold)
if rows == -1 {
l.Warn(ctx, "%s [rows:%v] %s [%.3fms]", slowLog, "-", sql, float64(elapsed.Nanoseconds())/1e6)
} else {
l.Warn(ctx, "%s [rows:%v] %s [%.3fms]", slowLog, rows, sql, float64(elapsed.Nanoseconds())/1e6)
}
default:
sql, rows := fc()
if rows == -1 {
l.Info(ctx, "[rows:%v] %s [%.3fms]", "-", sql, float64(elapsed.Nanoseconds())/1e6)
} else {
l.Info(ctx, "[rows:%v] %s [%.3fms]", rows, sql, float64(elapsed.Nanoseconds())/1e6)
}
}
}
func (l *dbLogger) ParamsFilter(_ context.Context, sql string, params ...any) (string, []any) {
return sql, params
}

@ -0,0 +1,162 @@
package db
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
type Migrator struct {
Schema string
TenantId uint
DB *gorm.DB
Migrations []*gormigrate.Migration
Log func(string, ...any)
}
func (m *Migrator) Migrate() error {
return m.migrate(func(migrator *gormigrate.Gormigrate) error {
return migrator.Migrate()
})
}
func (m *Migrator) MigrateTo(migrationID string) error {
return m.migrate(func(migrator *gormigrate.Gormigrate) error {
return migrator.MigrateTo(migrationID)
})
}
func (m *Migrator) migrate(fn func(migrator *gormigrate.Gormigrate) error) error {
if m.TenantId > 0 {
m.Log("⏳ migrating tables for tenant %d", m.TenantId)
} else {
m.Log("⏳ migrating public tables")
}
if err := CreateSchema(m.DB, m.Schema); err != nil {
if m.TenantId > 0 {
m.Log("❌ failed to create schema for tenant %d: %s", m.TenantId, err)
} else {
m.Log("❌ failed to create public schema: %s", err)
}
return err
}
tx := m.DB.Begin()
defer func() {
if tx.Error == nil {
tx.Commit()
if m.TenantId > 0 {
m.Log("✅ private tables migrated for tenant %d", m.TenantId)
} else {
m.Log("✅ public tables migrated for all tenants")
}
} else {
tx.Rollback()
}
}()
unlock, err := LockSchema(tx, m.Schema)
if err != nil {
m.Log("❌ failed to acquire advisory lock: %w", err)
return err
}
defer unlock()
reset := func() error { return nil }
if m.TenantId > 0 {
reset, err = UseTenant(tx, m.TenantId)
if err != nil {
m.Log("❌ failed to switch schema for tenant %d: %w", m.TenantId, err)
return err
}
}
defer reset()
err = fn(gormigrate.New(tx, &gormigrate.Options{
TableName: tx.NamingStrategy.TableName("migrations"),
}, m.Migrations))
if err != nil {
if m.TenantId > 0 {
m.Log("❌ failed to call migrate for tenant %d: %w", m.TenantId, err)
} else {
m.Log("❌ failed to migrate public tables: %w", err)
}
return err
}
return nil
}
func (m *Migrator) RollbackLast() error {
return m.rollback(func(migrator *gormigrate.Gormigrate) error {
return migrator.RollbackLast()
})
}
func (m *Migrator) RollbackTo(migrationID string) error {
return m.rollback(func(migrator *gormigrate.Gormigrate) error {
return migrator.MigrateTo(migrationID)
})
}
func (m *Migrator) rollback(fn func(migrator *gormigrate.Gormigrate) error) error {
if m.TenantId > 0 {
m.Log("⏳ rollbacking tables for tenant %d", m.TenantId)
} else {
m.Log("⏳ rollbacking public tables")
}
if err := CreateSchema(m.DB, m.Schema); err != nil {
if m.TenantId > 0 {
m.Log("❌ failed to create schema for tenant %d: %s", m.TenantId, err)
} else {
m.Log("❌ failed to create public schema: %s", err)
}
return err
}
tx := m.DB.Begin()
defer func() {
if tx.Error == nil {
tx.Commit()
if m.TenantId > 0 {
m.Log("✅ private tables rollbacked for tenant %d", m.TenantId)
} else {
m.Log("✅ public tables rollbacked for all tenants")
}
} else {
tx.Rollback()
}
}()
unlock, err := LockSchema(tx, m.Schema)
if err != nil {
m.Log("❌ failed to acquire advisory lock: %w", err)
return err
}
defer unlock()
reset := func() error { return nil }
if m.TenantId > 0 {
reset, err = UseTenant(tx, m.TenantId)
if err != nil {
m.Log("❌ failed to switch schema for tenant %d: %w", m.TenantId, err)
return err
}
}
defer reset()
err = fn(gormigrate.New(tx, &gormigrate.Options{
TableName: tx.NamingStrategy.TableName("migrations"),
}, m.Migrations))
if err != nil {
if m.TenantId > 0 {
m.Log("❌ failed to call rollback for tenant %d: %w", m.TenantId, err)
} else {
m.Log("❌ failed to rollback public tables: %w", err)
}
return err
}
return nil
}

@ -0,0 +1,57 @@
package mysql
import (
"strings"
"gorm.io/gorm"
"zestack.dev/cast"
)
// func init() {
// db.RegisterDriver("mysql", func(opts *db.DriverOptions) db.Driver {
// return &Driver{
// Migrator: Migrator{
// DB: opts.DB,
// Log: opts.Log,
// Retry: opts.Retry,
// },
// }
// })
// }
type Driver struct {
Migrator
}
func (*Driver) UseTenant(tx *gorm.DB, tenant uint) (reset func() error, err error) {
return UseDatabase(tx, TenantDatabase(tenant))
}
func (*Driver) CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) {
databse := CurrentDatabase(tx)
basename := TenantBasename()
if databse != "" && strings.HasPrefix(databse, basename) {
var err error
tenant, err = cast.Uint(databse[len(basename):])
ok = err == nil && tenant > 0
}
return
}
func createSharedSchema(tx *gorm.DB) error {
database := PublicDatabase()
sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database)
return tx.Exec(sql).Error
}
func (d *Driver) CreateTenantSchema(tx *gorm.DB, tenantId uint) error {
database := TenantDatabase(tenantId)
sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database)
return tx.Exec(sql).Error
}
func (d *Driver) DropSchemaForTenant(tenant uint) error {
return d.DropDatabaseForTenant(tenant)
}

@ -0,0 +1,93 @@
package mysql
import (
"database/sql"
"fmt"
"ims/util/backoff"
"ims/util/db"
"gorm.io/gorm"
)
// SQL statements for MySQL advisory locks.
// https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html
const (
// GET_LOCK(str, timeout) → int (1: lock acquired, 0: lock not acquired, NULL: an error occurred).
sqlGetLock = "SELECT GET_LOCK('%s', 0)"
// RELEASE_LOCK(str) → int (1: lock released, 0: lock not released, NULL: lock does not exist).
sqlReleaseLock = "SELECT RELEASE_LOCK('%s')"
)
// lock represents a MySQL advisory lock.
type lock struct {
tx *gorm.DB
lockKey string
}
func (a *lock) execute(sqlstr string) (bool, error) {
var result sql.NullInt64
if err := a.tx.Raw(sqlstr).Scan(&result).Error; err == nil {
if !result.Valid {
return false, db.ErrAcquireLock
}
switch result.Int64 {
case 1:
return true, nil
case 0:
return false, db.ErrAcquireLock
}
}
return false, fmt.Errorf("%w: %s", db.ErrExecSQL, sqlstr)
}
func (a *lock) acquire() (func() error, error) {
sqlstr := fmt.Sprintf(sqlGetLock, a.lockKey)
ok, err := a.execute(sqlstr)
if err != nil || !ok {
return nil, fmt.Errorf("%w for key %s: %v", db.ErrAcquireLock, a.lockKey, err)
}
return a.release, nil
}
func (a *lock) release() error {
sqlstr := fmt.Sprintf(sqlReleaseLock, a.lockKey)
success, err := a.execute(sqlstr)
if err != nil || !success {
return fmt.Errorf("%w for key %s: %v", db.ErrReleaseLock, a.lockKey, err)
}
return nil
}
// acquire acquires a MySQL advisory lock.
func acquire(tx *gorm.DB, lockKey string) (func() error, error) {
lock := &lock{tx: tx, lockKey: lockKey}
return lock.acquire()
}
// Acquire acquires a MySQL advisory lock.
// Returns a release function and an error.
//
// It's the responsibility of the caller to release the lock by calling the release function.
func AcquireLock(tx *gorm.DB, lockKey string, options *backoff.Options) (release func() error, err error) {
if options == nil {
release, err = acquire(tx, lockKey)
return
}
acquireFunc := func() error {
releaseFn, acquireErr := acquire(tx, lockKey)
if acquireErr != nil {
return acquireErr
}
release = releaseFn
return nil
}
err = backoff.Retry(acquireFunc, func(o *backoff.Options) {
*o = *options
})
return release, err
}

@ -0,0 +1,135 @@
package mysql
import (
"errors"
"ims/util/backoff"
"gorm.io/gorm"
)
type Migrator struct {
DB *gorm.DB
Retry *backoff.Options
Log func(format string, args ...any)
}
func (m Migrator) retry(fn func() error) error {
if m.Retry == nil {
return fn()
}
return backoff.Retry(fn, func(o *backoff.Options) {
*o = *m.Retry
})
}
func (m Migrator) acquireLock(tx *gorm.DB, database string) (func() error, error) {
return AcquireLock(tx, database, m.Retry)
}
// MigrateTenantModels creates a database for a specific tenant and migrates the tenant tables.
func (m Migrator) MigrateTenantModels(tenantId uint, models []any) (err error) {
m.Log("⏳ migrating tables for tenant %d", tenantId)
if len(models) == 0 {
err = errors.New("no tenant tables to migrate")
return
}
tx := m.DB.Session(&gorm.Session{})
database := TenantDatabase(tenantId)
sql := "CREATE DATABASE IF NOT EXISTS " + tx.Statement.Quote(database)
if err = tx.Exec(sql).Error; err != nil {
m.Log("❌ failed to create database '%s': %w", database, err)
return
}
unlock, lockErr := m.acquireLock(tx, database)
if lockErr != nil {
m.Log("❌ failed to acquire advisory lock for tenant %d: %w", tenantId, lockErr)
return lockErr
}
defer unlock()
err = tx.Transaction(func(tx *gorm.DB) error {
reset, err := UseDatabase(tx, database)
if err != nil {
m.Log("❌ failed to switch to tenant database %d: %w", tenantId, err)
return err
}
defer reset()
if err = tx.AutoMigrate(models...); err != nil {
m.Log("❌ failed to migrate tables for tenant %d: %w", tenantId, err)
return err
}
m.Log("✅ private tables migrated for tenant %d", tenantId)
return nil
})
return
}
// MigrateSharedModels migrates the shared tables in the database.
func (m Migrator) MigrateSharedModels(models []any) error {
m.Log("⏳ migrating public tables")
if len(models) == 0 {
return errors.New("no public tables to migrate")
}
db := m.DB.Session(&gorm.Session{})
database := PublicDatabase()
sql := "CREATE DATABASE IF NOT EXISTS " + db.Statement.Quote(database)
if err := db.Exec(sql).Error; err != nil {
m.Log("❌ failed to create public database '%s': %w", database, err)
return err
}
unlock, lockErr := m.acquireLock(m.DB, database)
if lockErr != nil {
m.Log("❌ failed to acquire advisory lock: %w", lockErr)
return lockErr
}
defer unlock()
tx := db.Begin()
defer func() {
if tx.Error == nil {
tx.Commit()
m.Log("✅ public tables migrated")
} else {
tx.Rollback()
}
}()
sql = "USE " + tx.Statement.Quote(database)
if err := tx.Exec(sql).Error; err != nil {
m.Log("❌ failed to switch to public database '%s': %w", database, err)
return err
}
if err := tx.AutoMigrate(models...); err != nil {
m.Log("❌ failed to migrate public tables: %w", err)
return err
}
return nil
}
// DropDatabaseForTenant drops the database for a specific tenant.
func (m Migrator) DropDatabaseForTenant(tenantId uint) error {
m.Log("⏳ dropping database for tenant %d", tenantId)
tx := m.DB.Session(&gorm.Session{})
database := TenantDatabase(tenantId)
return m.retry(func() error {
sql := "DROP DATABASE IF EXISTS " + tx.Statement.Quote(database)
if err := tx.Exec(sql).Error; err != nil {
m.Log("❌ failed to drop database '%s' for tenant %d: %w", database, tenantId, err)
return err
}
m.Log("✅ database dropped for tenant %d", tenantId)
return nil
})
}

@ -0,0 +1,60 @@
package mysql
import (
"errors"
"fmt"
"gorm.io/gorm"
"zestack.dev/env"
)
func PublicDatabase() string {
return env.String("DB_NAME")
}
func TenantBasename() string {
return env.String("TENANT_BASENAME", "tenant_")
}
func TenantDatabase(tenant uint) string {
if tenant == 0 {
return PublicDatabase()
}
return fmt.Sprintf("%s%d", TenantBasename(), tenant)
}
func UseDatabase(tx *gorm.DB, database string) (reset func() error, err error) {
if database == "" {
err = errors.New("database name is empty")
tx.AddError(err)
return
}
if database == CurrentDatabase(tx) {
reset = func() error { return nil }
return
}
sql := "USE " + tx.Statement.Quote(database)
if execErr := tx.Exec(sql).Error; execErr != nil {
err = fmt.Errorf("failed to set database %q: %w", database, execErr)
tx.AddError(err)
return
}
reset = func() error {
publicDatabase := PublicDatabase()
if database == publicDatabase {
return nil
}
return tx.Exec("USE " + tx.Statement.Quote(publicDatabase)).Error
}
return
}
func CurrentDatabase(tx *gorm.DB) string {
var database string
tx.Raw("SELECT DATABASE()").Row().Scan(&database)
return database
}

@ -0,0 +1,89 @@
package db
import (
"database/sql"
"fmt"
"ims/util/backoff"
"gorm.io/gorm"
)
// SQL statements for MySQL advisory locks.
// https://dev.mysql.com/doc/refman/8.4/en/locking-functions.html
const (
// GET_LOCK(str, timeout) → int (1: lock acquired, 0: lock not acquired, NULL: an error occurred).
mysqlGetLock = "SELECT GET_LOCK('%s', 0)"
// RELEASE_LOCK(str) → int (1: lock released, 0: lock not released, NULL: lock does not exist).
mysqlReleaseLock = "SELECT RELEASE_LOCK('%s')"
)
// mysqlLock represents a MySQL advisory mysqlLock.
type mysqlLock struct {
tx *gorm.DB
lockKey string
}
func (a *mysqlLock) execute(sqlstr string) (bool, error) {
var result sql.NullInt64
if err := a.tx.Raw(sqlstr).Scan(&result).Error; err == nil {
if !result.Valid {
return false, ErrAcquireLock
}
switch result.Int64 {
case 1:
return true, nil
case 0:
return false, ErrAcquireLock
}
}
return false, fmt.Errorf("%w: %s", ErrExecSQL, sqlstr)
}
func (a *mysqlLock) acquire() (func() error, error) {
sqlstr := fmt.Sprintf(mysqlGetLock, a.lockKey)
if ok, err := a.execute(sqlstr); err != nil || !ok {
return nil, fmt.Errorf("%w for key %s: %v", ErrAcquireLock, a.lockKey, err)
}
return a.release, nil
}
func (a *mysqlLock) release() error {
sqlstr := fmt.Sprintf(mysqlReleaseLock, a.lockKey)
if success, err := a.execute(sqlstr); err != nil || !success {
return fmt.Errorf("%w for key %s: %v", ErrReleaseLock, a.lockKey, err)
}
return nil
}
// acquire acquires a MySQL advisory lock.
func acquire(tx *gorm.DB, lockKey string) (func() error, error) {
lock := &mysqlLock{tx: tx, lockKey: lockKey}
return lock.acquire()
}
// Acquire acquires a MySQL advisory lock.
// Returns a release function and an error.
//
// It's the responsibility of the caller to release the lock by calling the release function.
func AcquireLock(tx *gorm.DB, lockKey string, options *backoff.Options) (release func() error, err error) {
if options == nil {
release, err = acquire(tx, lockKey)
return
}
acquireFunc := func() error {
releaseFn, acquireErr := acquire(tx, lockKey)
if acquireErr != nil {
return acquireErr
}
release = releaseFn
return nil
}
err = backoff.Retry(acquireFunc, func(o *backoff.Options) {
*o = *options
})
return release, err
}

@ -0,0 +1,99 @@
package db
import (
"errors"
"fmt"
"ims/util/backoff"
"gorm.io/gorm"
"zestack.dev/env"
)
type mysqlSchemaHelper struct{}
func (s *mysqlSchemaHelper) PublicSchema() string {
return env.String("DB_NAME")
}
func (s *mysqlSchemaHelper) TenantSchema(tenantId uint) string {
if tenantId == 0 {
return s.PublicSchema()
}
return fmt.Sprintf(
"%s%d%s",
env.String("DB_TENANT_PREFIX", "tenant_"),
tenantId,
env.String("DB_TENANT_SUFFIX", ""),
)
}
func (s *mysqlSchemaHelper) CurrentSchema(tx *gorm.DB) string {
var schema string
tx.Raw("SELECT DATABASE()").Row().Scan(&schema)
return schema
}
func (s *mysqlSchemaHelper) UseSchema(tx *gorm.DB, schema string) (func() error, error) {
if schema == "" {
err := errors.New("schema name is empty")
tx.AddError(err)
return nil, err
}
currentSchema := s.CurrentSchema(tx)
publicSchema := s.PublicSchema()
// 当前 schema 与目标 schema 相同,无需切换
if schema == currentSchema {
reset := func() error { return nil }
return reset, nil
}
// 不支持租户切换
if currentSchema != publicSchema && schema != publicSchema {
err := fmt.Errorf(
"failed to switch schema %s from current schema %s: %w",
schema,
currentSchema,
ErrSwitchSchema,
)
tx.AddError(err)
return nil, err
}
sqlstr := "USE " + tx.Statement.Quote(schema)
if execErr := tx.Exec(sqlstr).Error; execErr != nil {
err := fmt.Errorf("failed to set database %q: %w", schema, execErr)
tx.AddError(err)
return nil, err
}
if schema == publicSchema {
reset := func() error { return nil }
return reset, nil
}
reset := func() error {
return tx.Exec("USE " + tx.Statement.Quote(publicSchema)).Error
}
return reset, nil
}
func (s *mysqlSchemaHelper) LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (func() error, error) {
return AcquireLock(tx, schema, retry)
}
func (s *mysqlSchemaHelper) CreateSchema(tx *gorm.DB, schema string) error {
sql := fmt.Sprintf("CREATE DATABASE IF NOT EXISTS %s", tx.Statement.Quote(schema))
return tx.Exec(sql).Error
}
func (s *mysqlSchemaHelper) DropSchema(tx *gorm.DB, schema string) error {
sql := fmt.Sprintf("DROP DATABASE IF EXISTS %s", tx.Statement.Quote(schema))
return tx.Exec(sql).Error
}
func (s *mysqlSchemaHelper) String() string {
return "mysql"
}

@ -0,0 +1,41 @@
package pgsql
import (
"strings"
"gorm.io/gorm"
"zestack.dev/cast"
)
// func init() {
// db.RegisterDriver("pgsql", func(opts *db.DriverOptions) db.Driver {
// return &Driver{
// Migrator: Migrator{
// DB: opts.DB,
// Log: opts.Log,
// Retry: opts.Retry,
// },
// }
// })
// }
type Driver struct {
Migrator
}
func (*Driver) UseTenant(tx *gorm.DB, tenantId uint) (reset func() error, err error) {
return SetSearchPath(tx, TenantSchema(tenantId))
}
func (*Driver) CurrentTenant(tx *gorm.DB) (tenant uint, ok bool) {
databse := CurrentSearchPath(tx)
basename := TenantBasename()
if databse != "" && strings.HasPrefix(databse, basename) {
var err error
tenant, err = cast.Uint(databse[len(basename):])
ok = err == nil && tenant > 0
}
return
}

@ -0,0 +1,63 @@
package pgsql
import (
"fmt"
"hash/fnv"
"ims/util/backoff"
"ims/util/db"
"gorm.io/gorm"
)
// SQL statements for PostgreSQL advisory locks.
// https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
const (
// pg_try_advisory_xact_lock ( key bigint ) → boolean.
sqlTryAdvisoryXactLock = "SELECT pg_try_advisory_xact_lock(?)"
)
// lock represents a PostgreSQL transaction-level advisory lock.
type lock struct {
tx *gorm.DB
lockKey string
}
func (p *lock) execute(sqlstr string, args ...any) (bool, error) {
var result bool
if err := p.tx.Raw(sqlstr, args...).Scan(&result).Error; err == nil && result {
return true, nil
}
return false, fmt.Errorf("%w: %s", db.ErrExecSQL, sqlstr)
}
func (p *lock) acquire() error {
key := GenerateLockKey(p.lockKey)
ok, err := p.execute(sqlTryAdvisoryXactLock, key)
if err != nil || !ok {
return fmt.Errorf("%w for key %s: %v", db.ErrAcquireLock, p.lockKey, err)
}
return nil
}
func GenerateLockKey(s string) int64 {
hasher := fnv.New64a()
hasher.Write([]byte(s))
hash := hasher.Sum64()
return int64(hash)
}
// AcquireXact acquires a PostgreSQL transaction-level advisory lock.
// The caller is responsible for ensuring that a transaction is active,
// and that the lock is released after use.
func AcquireXact(tx *gorm.DB, lockKey string, opts *backoff.Options) error {
l := &lock{tx: tx, lockKey: lockKey}
if opts == nil {
return l.acquire()
}
return backoff.Retry(
func() error { return l.acquire() },
func(o *backoff.Options) { *o = *opts },
)
}

@ -0,0 +1,119 @@
package pgsql
import (
"errors"
"ims/util/backoff"
"gorm.io/gorm"
)
type Migrator struct {
DB *gorm.DB
Retry *backoff.Options
Log func(format string, args ...any)
}
func (m Migrator) retry(fn func() error) error {
if m.Retry == nil {
return fn()
}
return backoff.Retry(fn, func(o *backoff.Options) {
*o = *m.Retry
})
}
func (m Migrator) acquireXact(tx *gorm.DB, lockKey string) error {
return AcquireXact(tx, lockKey, m.Retry)
}
func (m Migrator) MigrateTenantModels(tenantId uint, models []any) error {
m.Log("⏳ migrating tables for tenant %d", tenantId)
if len(models) == 0 {
m.Log("😭 no tenant tables to migrate")
return errors.New("no tenant tables to migrate")
}
tx := m.DB.Session(&gorm.Session{})
schema := TenantSchema(tenantId)
sql := "CREATE SCHEMA IF NOT EXISTS " + tx.Statement.Quote(schema)
if err := tx.Exec(sql).Error; err != nil {
m.Log("❌ failed to create schema for tenant %d: %s", tenantId, err)
return err
}
err := tx.Transaction(func(tx *gorm.DB) error {
err := m.acquireXact(tx, schema)
if err != nil {
m.Log("❌ failed to acquire advisory lock for tenant %d: %w", tenantId, err)
return err
}
reset, searchPathErr := SetSearchPath(tx, schema)
if searchPathErr != nil {
m.Log("❌ failed to set search path '%s' to tenant %d: %w", schema, tenantId, searchPathErr)
return searchPathErr
}
defer reset()
if err := tx.AutoMigrate(models...); err != nil {
m.Log("❌ failed to migrate tenant tables for tenant %d: %w", tenantId, err)
return err
}
m.Log("✅ private tables migrated for tenant %d", tenantId)
return nil
})
if err != nil {
return err
}
return nil
}
// MigrateSharedModels migrates the public tables in the database.
func (m Migrator) MigrateSharedModels(models []any) error {
m.Log("⏳ migrating public tables")
if len(models) == 0 {
m.Log("😭 no public tables to migrate")
return errors.New("no public tables to migrate")
}
tx := m.DB.Begin()
defer func() {
if tx.Error == nil {
tx.Commit()
m.Log("✅ public tables migrated for all tenants")
} else {
tx.Rollback()
}
}()
if err := m.acquireXact(tx, PublicSchema()); err != nil {
m.Log("❌ failed to acquire advisory lock: %w", err)
return err
}
if err := tx.AutoMigrate(models...); err != nil {
m.Log("❌ failed to migrate public tables: %w", err)
return err
}
return nil
}
func (m Migrator) DropSchemaForTenant(tenantId uint) error {
m.Log("⏳ dropping schema for tenant %d", tenantId)
tx := m.DB.Session(&gorm.Session{})
schema := TenantSchema(tenantId)
return m.retry(func() error {
sql := "DROP SCHEMA IF EXISTS " + tx.Statement.Quote(schema) + " CASCADE"
if err := tx.Exec(sql).Error; err != nil {
m.Log("❌ failed to drop schema for tenant %d: %s", tenantId, err)
return err
}
m.Log("✅ schema dropped for tenant %d", tenantId)
return nil
})
}

@ -0,0 +1,65 @@
package pgsql
import (
"errors"
"fmt"
"gorm.io/gorm"
"zestack.dev/env"
)
func PublicSchema() string {
return env.String("DB_PUBLIC_SCHEMA", "public")
}
func TenantBasename() string {
return env.String("TENANT_BASENAME", "tenant_")
}
func TenantSchema(tenant uint) string {
if tenant == 0 {
return PublicSchema()
}
return fmt.Sprintf("%s%d", TenantBasename(), tenant)
}
func SetSearchPath(tx *gorm.DB, schema string) (reset func() error, err error) {
// tx = tx.Session(&gorm.Session{})
if schema == "" {
err = errors.New("schema name is empty")
tx.AddError(err)
return
}
if schema == CurrentSearchPath(tx) {
reset = func() error { return nil }
return
}
sqlstr := "SET search_path TO " + tx.Statement.Quote(schema)
if execErr := tx.Exec(sqlstr).Error; execErr != nil {
err = fmt.Errorf("failed to set search path %q: %w", schema, execErr)
tx.AddError(err)
return
}
reset = func() error {
publicSchema := PublicSchema()
if schema == publicSchema {
return nil
}
return tx.Exec("SET search_path TO " + tx.Statement.Quote(publicSchema)).Error
}
return
}
func CurrentSearchPath(tx *gorm.DB) string {
// tx = tx.Session(&gorm.Session{})
var searchPath string
tx.Raw("SHOW search_path").Scan(&searchPath)
if searchPath == `"$user", public` {
return "public"
}
return searchPath
}

@ -0,0 +1,68 @@
package db
import (
"fmt"
"hash/fnv"
"ims/util/backoff"
"gorm.io/gorm"
)
// SQL statements for PostgreSQL advisory locks.
// https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS
const (
// pg_try_advisory_xact_lock ( key bigint ) → boolean.
pgsqlTryAdvisoryXactLock = "SELECT pg_try_advisory_xact_lock(?)"
)
// pgsqlLock represents a PostgreSQL transaction-level advisory pgsqlLock.
type pgsqlLock struct {
tx *gorm.DB
lockKey string
}
func (p *pgsqlLock) execute(sqlstr string, args ...any) (bool, error) {
var result bool
if err := p.tx.Raw(sqlstr, args...).Scan(&result).Error; err == nil && result {
return true, nil
}
return false, fmt.Errorf("%w: %s", ErrExecSQL, sqlstr)
}
func (p *pgsqlLock) acquire() error {
key := generateLockKey(p.lockKey)
ok, err := p.execute(pgsqlTryAdvisoryXactLock, key)
if err != nil || !ok {
return fmt.Errorf("%w for key %s: %v", ErrAcquireLock, p.lockKey, err)
}
return nil
}
func generateLockKey(s string) int64 {
hasher := fnv.New64a()
hasher.Write([]byte(s))
hash := hasher.Sum64()
return int64(hash)
}
// AcquireXact acquires a PostgreSQL transaction-level advisory lock.
// The caller is responsible for ensuring that a transaction is active,
// and that the lock is released after use.
func AcquireXact(tx *gorm.DB, lockKey string, opts *backoff.Options) (release func() error, err error) {
l := &pgsqlLock{tx: tx, lockKey: lockKey}
if opts == nil {
err = l.acquire()
} else {
err = backoff.Retry(
func() error { return l.acquire() },
func(o *backoff.Options) { *o = *opts },
)
}
release = func() error {
return nil
}
return
}

@ -0,0 +1,107 @@
package db
import (
"errors"
"fmt"
"ims/util/backoff"
"gorm.io/gorm"
"zestack.dev/env"
)
type pgsqlSchemaHelper struct{}
func (s *pgsqlSchemaHelper) PublicSchema() string {
return env.String("DB_PUBLIC_SCHEMA", "public")
}
func (s *pgsqlSchemaHelper) TenantSchema(tenantId uint) string {
if tenantId == 0 {
return s.PublicSchema()
}
return fmt.Sprintf(
"%s%d%s",
env.String("DB_TENANT_PREFIX", "tenant_"),
tenantId,
env.String("DB_TENANT_SUFFIX", ""),
)
}
func (s *pgsqlSchemaHelper) CurrentSchema(tx *gorm.DB) string {
// tx = tx.Session(&gorm.Session{})
var schema string
tx.Raw("SHOW search_path").Scan(&schema)
if schema == `"$user", public` {
return "public"
}
return schema
}
func (s *pgsqlSchemaHelper) UseSchema(tx *gorm.DB, schema string) (func() error, error) {
// tx = tx.Session(&gorm.Session{})
if schema == "" {
err := errors.New("schema name is empty")
tx.AddError(err)
return nil, err
}
currentSchema := s.CurrentSchema(tx)
publicSchema := s.PublicSchema()
// 当前 schema 与目标 schema 相同,无需切换
if schema == s.CurrentSchema(tx) {
reset := func() error { return nil }
return reset, nil
}
// 不支持租户切换
if currentSchema != publicSchema && schema != publicSchema {
err := fmt.Errorf(
"failed to switch schema %s from current schema %s: %w",
schema,
currentSchema,
ErrSwitchSchema,
)
tx.AddError(err)
return nil, err
}
sqlstr := "SET search_path TO " + tx.Statement.Quote(schema)
if execErr := tx.Exec(sqlstr).Error; execErr != nil {
err := fmt.Errorf("failed to set search path %q: %w", schema, execErr)
tx.AddError(err)
return nil, err
}
if schema == publicSchema {
reset := func() error { return nil }
return reset, nil
}
reset := func() error {
return tx.Exec("SET search_path TO " + tx.Statement.Quote(publicSchema)).Error
}
return reset, nil
}
// AcquireXact acquires a PostgreSQL transaction-level advisory lock.
// The caller is responsible for ensuring that a transaction is active,
// and that the lock is released after use.
func (s *pgsqlSchemaHelper) LockSchema(tx *gorm.DB, schema string, retry *backoff.Options) (func() error, error) {
return AcquireXact(tx, schema, retry)
}
func (s *pgsqlSchemaHelper) CreateSchema(tx *gorm.DB, schema string) error {
sql := "CREATE SCHEMA IF NOT EXISTS " + tx.Statement.Quote(schema)
return tx.Exec(sql).Error
}
func (s *pgsqlSchemaHelper) DropSchema(tx *gorm.DB, schema string) error {
sql := "DROP SCHEMA IF EXISTS " + tx.Statement.Quote(schema) + " CASCADE"
return tx.Exec(sql).Error
}
func (s *pgsqlSchemaHelper) String() string {
return "pgsql"
}

@ -0,0 +1,46 @@
package evio
import (
"context"
"sync/atomic"
)
var defaultEvio atomic.Pointer[simple]
func init() {
evio := New(NewMatcher).(*simple)
defaultEvio.Store(evio)
}
func Default() Evio {
return defaultEvio.Load()
}
func Call(topic string, data any) {
Default().Call(topic, data)
}
func Publish(topic string, data any) error {
return Default().Publish(topic, data)
}
func Subscribe(topic string, handler Handler, opts ...SubscribeOption) (func() bool, error) {
return Default().Subscribe(topic, handler, opts...)
}
func Listen(topic string) (stop func(), data <-chan any, err error) {
return Default().Listen(topic)
}
func Start(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return Default().Start()
}
}
func Stop() error {
return Default().Stop()
}

@ -0,0 +1,102 @@
package evio
import "errors"
var ErrClosed = errors.New("evio was closed")
// Handler 事件处理器
type Handler interface {
// Handle 事件处理函数
Handle(any) error
}
type HandlerFunc func(any) error
func (h HandlerFunc) Handle(data any) error {
return h(data)
}
func Priority(priority int) SubscribeOption {
return func(h Handler) {
if x, ok := h.(interface{ SetPriority(int) }); ok {
x.SetPriority(priority)
}
}
}
func Once(once bool) SubscribeOption {
return func(h Handler) {
if x, ok := h.(interface{ SetOnce(bool) }); ok {
x.SetOnce(once)
}
}
}
// 订阅器初始化接口
type Initializer interface {
// Init 初始化订阅器
Init(Evio)
}
// Oncer 一次性订阅器接口,使订阅器只会被执行一次
type Oncer interface {
// Once 订阅器表示是否只执行一次
Once() bool
}
// Prioritizer 优先级接口,订阅器执行顺序,
type Prioritizer interface {
// Priority 返回优先级,值越小越先执行
// 如果订阅器为实现该接口,其优先级为 0
Priority() int
}
type Matcher interface {
Match(topic string) bool
}
type SubscribeOption func(Handler)
// Unsubscriber 订阅取消函数
type Unsubscriber func() bool
// Evio 时间管理接口
type Evio interface {
// Call 调用事件,是 Publish 的同步实现
//
// * "foo.bar" 具体事件
// * "*.bar" 后缀相同
// * "foo.*" 前缀相同
// * *.bar.* 中间包含
//
// 注意:前缀匹配必须包含符号 `.`。
Call(topic string, data any)
// Publish 发布事件,参数 topic 是模式。
Publish(topic string, data any) error
// Subscribe 订阅主题
Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Unsubscriber, error)
// Listen 监听主题值
Listen(topic string) (stop func(), data <-chan any, err error)
// Start 启动事件服务
Start() error
// Stop 停止
Stop() error
}
type Error struct {
err error
topic string
data any
}
func (e *Error) Error() string {
return e.err.Error()
}
func (e *Error) Topic() string {
return e.topic
}
func (e *Error) Data() any {
return e.data
}

@ -0,0 +1,257 @@
package evio
import (
"slices"
"sync"
"sync/atomic"
)
type subscription struct {
id int
priority null[int]
once null[bool]
topic string
evio *simple
unsubscribed int32
handler Handler
}
func (s *subscription) SetPriority(priority int) {
s.priority.val = priority
s.priority.ok = true
}
func (s *subscription) SetOnce(once bool) {
s.once.val = once
s.once.ok = true
}
func (s *subscription) Priority() int {
if p, ok := s.handler.(Prioritizer); ok {
return p.Priority()
}
if s.priority.ok {
return s.priority.val
}
return 0
}
func (s *subscription) Once() bool {
if o, ok := s.handler.(Oncer); ok {
return o.Once()
}
if s.once.ok {
return s.once.val
}
return false
}
func (s *subscription) Handle(data any) error {
if atomic.LoadInt32(&s.unsubscribed) > 0 {
return nil
}
return s.handler.Handle(data)
}
func unsubscribe(s *subscription) bool {
return atomic.SwapInt32(&s.unsubscribed, 1) == 0
}
type event struct {
topic string
data any
}
type simple struct {
mutex sync.RWMutex
pool sync.Pool
idgen int32
subs []*subscription
events chan event
closed chan struct{}
newMatcher func(topic string) Matcher
}
func New(newMatcher func(string) Matcher) Evio {
return &simple{
pool: sync.Pool{
New: func() any {
return make(chan any)
},
},
events: make(chan event, 1024),
closed: make(chan struct{}),
newMatcher: newMatcher,
}
}
type mistaker struct {
defers []func(data any)
}
func (m *mistaker) captch(s *simple, err error, topic string) {
m.defers = append(m.defers, func(data any) {
switch x := err.(type) {
case *Error:
s.Call("error", x)
case interface{ Unwrap() error }:
if err = x.Unwrap(); err != nil {
s.Call("error", &Error{err, topic, data})
}
case interface{ Unwrap() []error }:
for _, err := range x.Unwrap() {
s.Call("error", &Error{err, topic, data})
}
default:
s.Call("error", &Error{err, topic, data})
}
})
}
func (m *mistaker) flush(data any) {
for _, deferFunc := range m.defers {
deferFunc(data)
}
}
func (s *simple) Call(topic string, data any) {
matcher := s.newMatcher(topic)
s.mutex.RLock()
var subs []*subscription
var bads []*subscription
for _, sub := range s.subs {
if atomic.LoadInt32(&sub.unsubscribed) > 0 {
bads = append(bads, sub)
continue
}
if matcher.Match(sub.topic) {
subs = append(subs, sub)
}
}
slices.SortFunc(subs, func(a, b *subscription) int {
if n := a.Priority() - b.Priority(); n != 0 {
return n
}
return a.id - b.id
})
s.mutex.RUnlock()
// 删除失效的
if len(bads) > 0 {
s.mutex.Lock()
for _, sub := range bads {
s.subs = slices.DeleteFunc(s.subs, func(s *subscription) bool {
return sub.id == s.id
})
}
s.mutex.Unlock()
}
var m mistaker
defer m.flush(data)
// 广播
for _, sub := range subs {
if sub.Once() {
unsubscribe(sub)
}
if err := sub.Handle(data); err != nil {
m.captch(s, err, sub.topic)
}
}
}
func (s *simple) Publish(topic string, data any) error {
select {
case <-s.closed:
return ErrClosed
case s.events <- event{topic, data}:
return nil
}
}
func (s *simple) Subscribe(topic string, handler Handler, opts ...SubscribeOption) (Unsubscriber, error) {
select {
case <-s.closed:
return nil, ErrClosed
default:
}
var sub subscription
sub.id = int(atomic.AddInt32(&s.idgen, 1))
sub.topic = topic
sub.evio = s
sub.handler = handler
for _, option := range opts {
option(&sub)
}
s.mutex.Lock()
s.subs = append(s.subs, &sub)
s.mutex.Unlock()
if i, ok := handler.(Initializer); ok {
go i.Init(s)
}
return func() bool { return unsubscribe(&sub) }, nil
}
func (s *simple) Listen(topic string) (func(), <-chan any, error) {
var stopped int32
ch := s.pool.Get().(chan any)
unsubscribe, err := s.Subscribe(topic, HandlerFunc(func(data any) error {
if atomic.LoadInt32(&stopped) == 0 {
ch <- data
}
return nil
}))
if err != nil {
s.pool.Put(ch)
return nil, nil, err
}
stop := func() {
if atomic.CompareAndSwapInt32(&stopped, 0, 1) {
select {
case <-ch:
default:
}
s.pool.Put(ch)
unsubscribe()
}
}
return stop, ch, err
}
func (s *simple) Start() error {
go s.start()
return nil
}
func (s *simple) start() {
for {
select {
case <-s.closed:
for n := len(s.events); n > 0; n-- {
evt := <-s.events
s.Call(evt.topic, evt.data)
}
return
case evt := <-s.events:
s.Call(evt.topic, evt.data)
}
}
}
func (s *simple) Stop() error {
select {
case <-s.closed:
default:
close(s.closed)
}
return nil
}

@ -0,0 +1,54 @@
package evio
import "strings"
type null[T any] struct {
val T
ok bool
}
type matcher struct {
rawPattern string
pattern string
matchPrefix bool
matchSuffix bool
}
// TODO 优化,使用缓存,只处理一次
func NewMatcher(pattern string) Matcher {
m := matcher{
rawPattern: pattern,
pattern: pattern,
matchPrefix: strings.HasSuffix(pattern, ".*"), // foo.*
matchSuffix: strings.HasPrefix(pattern, ".*"), // *.bar
}
if m.matchPrefix {
m.pattern = pattern[:len(pattern)-1]
}
if m.matchSuffix {
m.pattern = pattern[1:]
}
return &m
}
func (m *matcher) Match(topic string) bool {
if m.rawPattern == topic {
return true
}
hasPrefix := strings.HasPrefix(topic, m.pattern)
hasSuffix := strings.HasSuffix(topic, m.pattern)
hasMiddle := strings.Contains(topic, m.pattern)
// *.bar.*
if !m.matchPrefix && !m.matchSuffix {
return hasMiddle && !hasPrefix && !hasSuffix
}
// foo.*
if m.matchPrefix {
return hasPrefix
}
// *.bar
if m.matchSuffix {
return hasSuffix
}
return false
}

@ -0,0 +1,52 @@
package util
import (
"context"
"ims/util/cache"
"ims/util/db"
"ims/util/jwt"
"ims/util/rdb"
"os"
"time"
"github.com/shopspring/decimal"
"zestack.dev/env"
)
func Init(_ context.Context) error {
// 设置 decimal 的序列化结果为不带引号的字符串,
// 也就是 json 数字为非字符串。
decimal.MarshalJSONWithoutQuotes = true
var err error
call := func(fn func() error) {
if err == nil {
err = fn()
}
}
call(initTimeZone)
call(db.Init)
call(rdb.Init)
call(cache.Init)
call(jwt.Init)
return err
}
// 初始化时区
func initTimeZone() error {
timeZone := env.String("TIME_ZONE", "Asia/Shanghai")
if tzdata := env.String("TZDATA_PATH"); tzdata != "" {
if data, err := os.ReadFile(tzdata); err != nil {
return err
} else if local, err := time.LoadLocationFromTZData(timeZone, data); err != nil {
return err
} else {
time.Local = local
}
} else if local, err := time.LoadLocation(timeZone); err != nil {
return err
} else {
time.Local = local
}
return nil
}

@ -0,0 +1,54 @@
package jwt
import (
"errors"
"zestack.dev/slim"
)
type AuthConfig struct {
Skipper func(c slim.Context) bool
Finder Finder
Anonymously bool
Claims func(c slim.Context, token string, claims *Claims) error
}
func (config AuthConfig) ToMiddleware() slim.MiddlewareFunc {
if config.Finder == nil {
config.Finder = DefaultFinder
}
return func(c slim.Context, next slim.HandlerFunc) error {
if config.Skipper != nil && config.Skipper(c) {
return next(c)
}
token := config.Finder(c)
if token == "" {
if config.Anonymously {
return next(c)
}
return ErrTokenNotFound
}
claims, err := Verify(token)
if errors.Is(err, ErrTokenExpired) {
c.SetHeader("X-Token-Expired", "true")
} else if err != nil {
return err
}
if config.Claims != nil {
err = config.Claims(c, token, claims)
if err != nil {
return err
}
}
if c.Written() {
return nil
}
c.Set("jwt:token", token)
c.Set("jwt:claims", claims)
return next(c)
}
}
func Auth(c AuthConfig) slim.MiddlewareFunc {
return c.ToMiddleware()
}

@ -0,0 +1,51 @@
package jwt
import (
"strings"
"zestack.dev/slim"
)
type Finder func(c slim.Context) string
func DefaultFinder(c slim.Context) string {
if s := FromQuery(c); s != "" {
return s
}
if s := FromHeader(c); s != "" {
return s
}
return FromCookie(c)
}
func FromCookie(c slim.Context, keys ...string) string {
for _, key := range keys {
cookie, err := c.Cookie(key)
if err == nil {
return cookie.Value
}
}
cookie, err := c.Cookie("jwt")
if err != nil {
return ""
}
return cookie.Value
}
func FromHeader(c slim.Context) string {
bearer := c.Header("Authorization")
if len(bearer) > 7 && strings.ToUpper(bearer[0:6]) == "BEARER" {
return bearer[7:]
}
return ""
}
func FromQuery(c slim.Context, keys ...string) string {
for _, key := range keys {
s := c.QueryParam(key)
if s != "" {
return s
}
}
return c.QueryParam("jwt")
}

@ -0,0 +1,36 @@
package jwt
import (
"encoding/base64"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/rs/xid"
"zestack.dev/env"
)
// Generate 生成令牌
func Generate(claims Claims) (string, error) {
if claims.ID == "" {
claims.ID = xid.New().String()
}
if claims.Issuer == "" {
claims.Issuer = env.String("JWT_ISSUER")
}
if claims.Subject == "" {
claims.Issuer = env.String("JWT_SUBJECT")
}
if claims.Audience == nil {
claims.Audience = env.List("JWT_AUDIENCE", []string{"*"})
}
if claims.ExpiresAt == nil {
ttl := env.Duration("JWT_TTL", time.Hour)
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(ttl))
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(privateKey)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString([]byte(signedToken)), nil
}

@ -0,0 +1,36 @@
package jwt
import (
"crypto/rsa"
"errors"
"github.com/golang-jwt/jwt/v5"
"zestack.dev/env"
)
var (
ErrUnauthorized = errors.New("jwt: unauthorized")
ErrForbidden = errors.New("jwt: forbidden")
ErrInvalidToken = errors.New("jwt: invalid token")
ErrTokenExpired = errors.New("jwt: token is expired")
ErrTokenNotFound = errors.New("jwt: token not found")
publicKey *rsa.PublicKey
privateKey *rsa.PrivateKey
)
type Claims = jwt.RegisteredClaims
func Init() (err error) {
publicKey, err = jwt.ParseRSAPublicKeyFromPEM(env.Bytes("JWT_PUBLIC_KEY"))
if err != nil {
return err
}
privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(env.Bytes("JWT_PRIVATE_KEY"))
if err != nil {
return
}
return
}

@ -0,0 +1,33 @@
package jwt
import (
"encoding/base64"
"errors"
"github.com/golang-jwt/jwt/v5"
)
// Verify 验证令牌
func Verify(tokenString string) (*Claims, error) {
bts, err := base64.RawURLEncoding.DecodeString(tokenString)
if err != nil {
return nil, err
}
var claims Claims
token, err := jwt.ParseWithClaims(string(bts), &claims, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, ErrInvalidToken
}
return publicKey, nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return &claims, ErrTokenExpired
}
return nil, err
}
if !token.Valid {
return nil, ErrInvalidToken
}
return &claims, nil
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save