commit
8216396227
@ -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 |
@ -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=* |
@ -0,0 +1 @@ |
|||||||
|
* text=auto eol=lf |
@ -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 |
||||||
|
) |
@ -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) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
@ -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…
Reference in new issue