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