b 5 kuukautta sitten
vanhempi
sitoutus
488f38ecc0

+ 26 - 0
gin/jwtx2/CurrentAuth.go

@@ -0,0 +1,26 @@
+package jwtx
+
+import "github.com/gin-gonic/gin"
+
+type Current struct {
+	AccountID     uint64 // 账户 ID
+	LoginGroup    string // 登录的分组
+	LoginTerminal string // 登录的终端
+	MakeTokenIP   string // 首次请求生成 token 的 IP 地址
+}
+
+// 获取当前登录的账户信息,需配合中间件使用
+func CurrentAuth(c *gin.Context) Current {
+
+	var accountID uint64
+	if val, ok := c.Get("jwtx.accountID"); ok && val != nil {
+		accountID, _ = val.(uint64)
+	}
+
+	return Current{
+		AccountID:     accountID,
+		LoginGroup:    c.GetString("jwtx.loginGroup"),
+		LoginTerminal: c.GetString("jwtx.loginTerminal"),
+		MakeTokenIP:   c.GetString("jwtx.makeTokenIP"),
+	}
+}

+ 99 - 0
gin/jwtx2/InitGroup.go

@@ -0,0 +1,99 @@
+package jwtx
+
+import (
+	"crypto/ecdsa"
+	"time"
+
+	"github.com/5-say/go-tool/crypto/ecdsatool"
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/model"
+	"github.com/5-say/go-tool/gin/jwtx/tool"
+	"github.com/5-say/go-tool/gorm/mysqlx"
+	"github.com/5-say/go-tool/logx"
+	"github.com/5-say/go-tool/utilx"
+	"github.com/jinzhu/configor"
+	"gorm.io/gorm"
+	"gorm.io/gorm/logger"
+
+	_ "embed"
+)
+
+// 初始化分组(支持多次调用)
+//
+//	group          string 分组名称
+//	configPath     string 分组配置文件路径
+//	privateKeyPath string 分组私钥文件路径
+//
+// e.g.
+//
+//	jwtx.InitGroup("admin", "jwtx/config/admin.yaml", "jwtx/config/admin.key")
+//	jwtx.InitGroup("user",  "jwtx/config/user.yaml",  "jwtx/config/user.key")
+func InitGroup(group, configPath, privateKeyPath string) *SingletonT {
+	// 配置文件不存在则创建
+	createConfigFileIfNotExist(configPath)
+
+	// 私钥文件不存在则创建
+	createKeyFileIfNotExist(privateKeyPath)
+
+	// 取得单例
+	if Singleton == nil {
+		Singleton = &SingletonT{}
+		Singleton.DB = make(map[string]*gorm.DB)
+		Singleton.Config = make(map[string]GroupConfig)
+		Singleton.PrivateKey = make(map[string]*ecdsa.PrivateKey)
+	}
+
+	// 引用简化
+	var s = Singleton
+
+	// 初始化分组配置,每秒自动更新
+	var config GroupConfig
+	err := configor.New(&configor.Config{AutoReload: true}).Load(&config, configPath)
+	if err != nil {
+		panic(err)
+	}
+	s.Config[group] = config
+
+	// 初始化分组数据库连接
+	s.DB[group], err = mysqlx.New(config.MysqlDSN, logx.GormLogger(logger.Config{
+		SlowThreshold:             200 * time.Millisecond,
+		IgnoreRecordNotFoundError: false,
+		ParameterizedQueries:      false,
+		LogLevel:                  logger.Warn,
+	}))
+	if err != nil {
+		panic(err)
+	}
+
+	// 初始化数据库结构
+	s.DB[group].AutoMigrate(&model.JwtxToken{})
+
+	// 初始化分组私钥
+	privateKey, err := tool.GetPrivateKey(privateKeyPath)
+	if err != nil {
+		panic(err)
+	}
+	s.PrivateKey[group] = privateKey
+
+	return s
+}
+
+//go:embed example/demo.yaml
+var demoConfig string
+
+func createConfigFileIfNotExist(filePath string) {
+	utilx.ForceCreateWriteIfNotExist(filePath, 0755, demoConfig)
+}
+
+func createKeyFileIfNotExist(filePath string) {
+	key, _ := ecdsatool.GenerateKey(ecdsatool.P_384)
+	privateKeyPEMBytes, _ := ecdsatool.PEM_EncodePrivateKey(key)
+	content := "\n"
+	content += "密钥格式: NIST P-384"
+	content += "\n\n"
+	content += string(privateKeyPEMBytes)
+	content += "\n"
+	content += "密钥格式: NIST P-384"
+	content += "\n"
+
+	utilx.ForceCreateWriteIfNotExist(filePath, 0755, content)
+}

+ 18 - 0
gin/jwtx2/README.md

@@ -0,0 +1,18 @@
+# jwtx
+
+## 跨域资源共享 CORS
+
+使用官方 cors 全局中间件,并添加以下必要配置
+
+```golang
+r.Use(cors.New(cors.Config{
+
+    // 允许跨域请求携带 Authorization 头信息
+    AllowMethods:  []string{"OPTIONS"},
+    AllowHeaders:  []string{"Authorization"},
+
+    // 允许跨域请求获取 token 响应头
+    ExposeHeaders: []string{"token"},
+}))
+```
+

+ 44 - 0
gin/jwtx2/Singleton.go

@@ -0,0 +1,44 @@
+package jwtx
+
+import (
+	"crypto/ecdsa"
+
+	"gorm.io/gorm"
+)
+
+// 单例
+var Singleton *SingletonT
+
+// SingletonT ..
+type SingletonT struct {
+	DB         map[string]*gorm.DB
+	Config     map[string]GroupConfig
+	PrivateKey map[string]*ecdsa.PrivateKey
+}
+
+// 分组配置
+type GroupConfig struct {
+	// consult[https://gorm.io/docs/connecting_to_the_database.html]"
+	MysqlDSN string `yaml:"MysqlDSN"`
+
+	// 是否开启 IP一致性校验,当前 IP 必须与登录时 IP 一致
+	CheckIP bool `yaml:"CheckIP"`
+	// 是否开启 单端登录
+	SingleEnd bool `yaml:"SingleEnd"`
+
+	// 刷新时间间隔(秒)
+	//
+	// 超过该时间即可触发 token 刷新、旧 token 进入容错,间隔越小安全性越高
+	RefreshInterval int64 `yaml:"RefreshInterval"`
+	// 并发容错时间(秒)
+	FaultTolerance int64 `yaml:"FaultTolerance"`
+
+	// 是否开启 自动续期
+	//
+	// 假设:单次登录有效期(7天)
+	// true  只要 7天 内有操作触发刷新,登录状态就一直延续
+	// false 不论 7天 内是否操作,7天 后必定要求重新登录
+	AutomaticRenewal bool `yaml:"AutomaticRenewal"`
+	// 单次登录有效期(小时)
+	AccessExpireByHour int64 `yaml:"AccessExpireByHour"`
+}

+ 2 - 0
gin/jwtx2/db/Makefile

@@ -0,0 +1,2 @@
+run:
+	gentool -c "./gen.yml"

+ 29 - 0
gin/jwtx2/db/dao/model/jwtx_tokens.gen.go

@@ -0,0 +1,29 @@
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+
+package model
+
+import (
+	"time"
+)
+
+const TableNameJwtxToken = "jwtx_tokens"
+
+// JwtxToken jwtx token 信息表
+type JwtxToken struct {
+	ID             uint64    `gorm:"column:id;type:bigint unsigned;primaryKey;autoIncrement:true;comment:token ID" json:"id"`                              // token ID
+	AccountID      uint64    `gorm:"column:account_id;type:bigint unsigned;not null;index:account_id,priority:1;comment:账户 ID" json:"account_id"`          // 账户 ID
+	LoginGroup     string    `gorm:"column:login_group;type:varchar(255);not null;index:login_group,priority:1;comment:登录的分组" json:"login_group"`          // 登录的分组
+	LoginTerminal  string    `gorm:"column:login_terminal;type:varchar(255);not null;index:login_terminal,priority:1;comment:登录的终端" json:"login_terminal"` // 登录的终端
+	MakeTokenIP    string    `gorm:"column:make_token_ip;type:varchar(50);not null;comment:首次请求生成 token 的 IP 地址" json:"make_token_ip"`                     // 首次请求生成 token 的 IP 地址
+	CreatedAt      time.Time `gorm:"column:created_at;type:datetime;not null;comment:创建时间" json:"created_at"`                                              // 创建时间
+	LastRefreshAt  time.Time `gorm:"column:last_refresh_at;type:datetime;not null;comment:上次的刷新时间" json:"last_refresh_at"`                                 // 上次的刷新时间
+	FinalRefreshAt time.Time `gorm:"column:final_refresh_at;type:datetime;not null;comment:最后的刷新时间" json:"final_refresh_at"`                               // 最后的刷新时间
+	ExpirationAt   time.Time `gorm:"column:expiration_at;type:datetime;not null;comment:过期时间" json:"expiration_at"`                                        // 过期时间
+}
+
+// TableName JwtxToken's table name
+func (*JwtxToken) TableName() string {
+	return TableNameJwtxToken
+}

+ 93 - 0
gin/jwtx2/db/dao/query/gen.go

@@ -0,0 +1,93 @@
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+
+package query
+
+import (
+	"context"
+	"database/sql"
+
+	"gorm.io/gorm"
+
+	"gorm.io/gen"
+
+	"gorm.io/plugin/dbresolver"
+)
+
+func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
+	return &Query{
+		db:        db,
+		JwtxToken: newJwtxToken(db, opts...),
+	}
+}
+
+type Query struct {
+	db *gorm.DB
+
+	JwtxToken jwtxToken
+}
+
+func (q *Query) Available() bool { return q.db != nil }
+
+func (q *Query) clone(db *gorm.DB) *Query {
+	return &Query{
+		db:        db,
+		JwtxToken: q.JwtxToken.clone(db),
+	}
+}
+
+func (q *Query) ReadDB() *Query {
+	return q.ReplaceDB(q.db.Clauses(dbresolver.Read))
+}
+
+func (q *Query) WriteDB() *Query {
+	return q.ReplaceDB(q.db.Clauses(dbresolver.Write))
+}
+
+func (q *Query) ReplaceDB(db *gorm.DB) *Query {
+	return &Query{
+		db:        db,
+		JwtxToken: q.JwtxToken.replaceDB(db),
+	}
+}
+
+type queryCtx struct {
+	JwtxToken *jwtxTokenDo
+}
+
+func (q *Query) WithContext(ctx context.Context) *queryCtx {
+	return &queryCtx{
+		JwtxToken: q.JwtxToken.WithContext(ctx),
+	}
+}
+
+func (q *Query) Transaction(fc func(tx *Query) error, opts ...*sql.TxOptions) error {
+	return q.db.Transaction(func(tx *gorm.DB) error { return fc(q.clone(tx)) }, opts...)
+}
+
+func (q *Query) Begin(opts ...*sql.TxOptions) *QueryTx {
+	tx := q.db.Begin(opts...)
+	return &QueryTx{Query: q.clone(tx), Error: tx.Error}
+}
+
+type QueryTx struct {
+	*Query
+	Error error
+}
+
+func (q *QueryTx) Commit() error {
+	return q.db.Commit().Error
+}
+
+func (q *QueryTx) Rollback() error {
+	return q.db.Rollback().Error
+}
+
+func (q *QueryTx) SavePoint(name string) error {
+	return q.db.SavePoint(name).Error
+}
+
+func (q *QueryTx) RollbackTo(name string) error {
+	return q.db.RollbackTo(name).Error
+}

+ 362 - 0
gin/jwtx2/db/dao/query/jwtx_tokens.gen.go

@@ -0,0 +1,362 @@
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+// Code generated by gorm.io/gen. DO NOT EDIT.
+
+package query
+
+import (
+	"context"
+
+	"gorm.io/gorm"
+	"gorm.io/gorm/clause"
+	"gorm.io/gorm/schema"
+
+	"gorm.io/gen"
+	"gorm.io/gen/field"
+
+	"gorm.io/plugin/dbresolver"
+
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/model"
+)
+
+func newJwtxToken(db *gorm.DB, opts ...gen.DOOption) jwtxToken {
+	_jwtxToken := jwtxToken{}
+
+	_jwtxToken.jwtxTokenDo.UseDB(db, opts...)
+	_jwtxToken.jwtxTokenDo.UseModel(&model.JwtxToken{})
+
+	tableName := _jwtxToken.jwtxTokenDo.TableName()
+	_jwtxToken.ALL = field.NewAsterisk(tableName)
+	_jwtxToken.ID = field.NewUint64(tableName, "id")
+	_jwtxToken.AccountID = field.NewUint64(tableName, "account_id")
+	_jwtxToken.LoginGroup = field.NewString(tableName, "login_group")
+	_jwtxToken.LoginTerminal = field.NewString(tableName, "login_terminal")
+	_jwtxToken.MakeTokenIP = field.NewString(tableName, "make_token_ip")
+	_jwtxToken.CreatedAt = field.NewTime(tableName, "created_at")
+	_jwtxToken.LastRefreshAt = field.NewTime(tableName, "last_refresh_at")
+	_jwtxToken.FinalRefreshAt = field.NewTime(tableName, "final_refresh_at")
+	_jwtxToken.ExpirationAt = field.NewTime(tableName, "expiration_at")
+
+	_jwtxToken.fillFieldMap()
+
+	return _jwtxToken
+}
+
+// jwtxToken jwtx token 信息表
+type jwtxToken struct {
+	jwtxTokenDo jwtxTokenDo
+
+	ALL            field.Asterisk
+	ID             field.Uint64 // token ID
+	AccountID      field.Uint64 // 账户 ID
+	LoginGroup     field.String // 登录的分组
+	LoginTerminal  field.String // 登录的终端
+	MakeTokenIP    field.String // 首次请求生成 token 的 IP 地址
+	CreatedAt      field.Time   // 创建时间
+	LastRefreshAt  field.Time   // 上次的刷新时间
+	FinalRefreshAt field.Time   // 最后的刷新时间
+	ExpirationAt   field.Time   // 过期时间
+
+	fieldMap map[string]field.Expr
+}
+
+func (j jwtxToken) Table(newTableName string) *jwtxToken {
+	j.jwtxTokenDo.UseTable(newTableName)
+	return j.updateTableName(newTableName)
+}
+
+func (j jwtxToken) As(alias string) *jwtxToken {
+	j.jwtxTokenDo.DO = *(j.jwtxTokenDo.As(alias).(*gen.DO))
+	return j.updateTableName(alias)
+}
+
+func (j *jwtxToken) updateTableName(table string) *jwtxToken {
+	j.ALL = field.NewAsterisk(table)
+	j.ID = field.NewUint64(table, "id")
+	j.AccountID = field.NewUint64(table, "account_id")
+	j.LoginGroup = field.NewString(table, "login_group")
+	j.LoginTerminal = field.NewString(table, "login_terminal")
+	j.MakeTokenIP = field.NewString(table, "make_token_ip")
+	j.CreatedAt = field.NewTime(table, "created_at")
+	j.LastRefreshAt = field.NewTime(table, "last_refresh_at")
+	j.FinalRefreshAt = field.NewTime(table, "final_refresh_at")
+	j.ExpirationAt = field.NewTime(table, "expiration_at")
+
+	j.fillFieldMap()
+
+	return j
+}
+
+func (j *jwtxToken) WithContext(ctx context.Context) *jwtxTokenDo {
+	return j.jwtxTokenDo.WithContext(ctx)
+}
+
+func (j jwtxToken) TableName() string { return j.jwtxTokenDo.TableName() }
+
+func (j jwtxToken) Alias() string { return j.jwtxTokenDo.Alias() }
+
+func (j jwtxToken) Columns(cols ...field.Expr) gen.Columns { return j.jwtxTokenDo.Columns(cols...) }
+
+func (j *jwtxToken) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+	_f, ok := j.fieldMap[fieldName]
+	if !ok || _f == nil {
+		return nil, false
+	}
+	_oe, ok := _f.(field.OrderExpr)
+	return _oe, ok
+}
+
+func (j *jwtxToken) fillFieldMap() {
+	j.fieldMap = make(map[string]field.Expr, 9)
+	j.fieldMap["id"] = j.ID
+	j.fieldMap["account_id"] = j.AccountID
+	j.fieldMap["login_group"] = j.LoginGroup
+	j.fieldMap["login_terminal"] = j.LoginTerminal
+	j.fieldMap["make_token_ip"] = j.MakeTokenIP
+	j.fieldMap["created_at"] = j.CreatedAt
+	j.fieldMap["last_refresh_at"] = j.LastRefreshAt
+	j.fieldMap["final_refresh_at"] = j.FinalRefreshAt
+	j.fieldMap["expiration_at"] = j.ExpirationAt
+}
+
+func (j jwtxToken) clone(db *gorm.DB) jwtxToken {
+	j.jwtxTokenDo.ReplaceConnPool(db.Statement.ConnPool)
+	return j
+}
+
+func (j jwtxToken) replaceDB(db *gorm.DB) jwtxToken {
+	j.jwtxTokenDo.ReplaceDB(db)
+	return j
+}
+
+type jwtxTokenDo struct{ gen.DO }
+
+func (j jwtxTokenDo) Debug() *jwtxTokenDo {
+	return j.withDO(j.DO.Debug())
+}
+
+func (j jwtxTokenDo) WithContext(ctx context.Context) *jwtxTokenDo {
+	return j.withDO(j.DO.WithContext(ctx))
+}
+
+func (j jwtxTokenDo) ReadDB() *jwtxTokenDo {
+	return j.Clauses(dbresolver.Read)
+}
+
+func (j jwtxTokenDo) WriteDB() *jwtxTokenDo {
+	return j.Clauses(dbresolver.Write)
+}
+
+func (j jwtxTokenDo) Session(config *gorm.Session) *jwtxTokenDo {
+	return j.withDO(j.DO.Session(config))
+}
+
+func (j jwtxTokenDo) Clauses(conds ...clause.Expression) *jwtxTokenDo {
+	return j.withDO(j.DO.Clauses(conds...))
+}
+
+func (j jwtxTokenDo) Returning(value interface{}, columns ...string) *jwtxTokenDo {
+	return j.withDO(j.DO.Returning(value, columns...))
+}
+
+func (j jwtxTokenDo) Not(conds ...gen.Condition) *jwtxTokenDo {
+	return j.withDO(j.DO.Not(conds...))
+}
+
+func (j jwtxTokenDo) Or(conds ...gen.Condition) *jwtxTokenDo {
+	return j.withDO(j.DO.Or(conds...))
+}
+
+func (j jwtxTokenDo) Select(conds ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Select(conds...))
+}
+
+func (j jwtxTokenDo) Where(conds ...gen.Condition) *jwtxTokenDo {
+	return j.withDO(j.DO.Where(conds...))
+}
+
+func (j jwtxTokenDo) Order(conds ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Order(conds...))
+}
+
+func (j jwtxTokenDo) Distinct(cols ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Distinct(cols...))
+}
+
+func (j jwtxTokenDo) Omit(cols ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Omit(cols...))
+}
+
+func (j jwtxTokenDo) Join(table schema.Tabler, on ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Join(table, on...))
+}
+
+func (j jwtxTokenDo) LeftJoin(table schema.Tabler, on ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.LeftJoin(table, on...))
+}
+
+func (j jwtxTokenDo) RightJoin(table schema.Tabler, on ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.RightJoin(table, on...))
+}
+
+func (j jwtxTokenDo) Group(cols ...field.Expr) *jwtxTokenDo {
+	return j.withDO(j.DO.Group(cols...))
+}
+
+func (j jwtxTokenDo) Having(conds ...gen.Condition) *jwtxTokenDo {
+	return j.withDO(j.DO.Having(conds...))
+}
+
+func (j jwtxTokenDo) Limit(limit int) *jwtxTokenDo {
+	return j.withDO(j.DO.Limit(limit))
+}
+
+func (j jwtxTokenDo) Offset(offset int) *jwtxTokenDo {
+	return j.withDO(j.DO.Offset(offset))
+}
+
+func (j jwtxTokenDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *jwtxTokenDo {
+	return j.withDO(j.DO.Scopes(funcs...))
+}
+
+func (j jwtxTokenDo) Unscoped() *jwtxTokenDo {
+	return j.withDO(j.DO.Unscoped())
+}
+
+func (j jwtxTokenDo) Create(values ...*model.JwtxToken) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return j.DO.Create(values)
+}
+
+func (j jwtxTokenDo) CreateInBatches(values []*model.JwtxToken, batchSize int) error {
+	return j.DO.CreateInBatches(values, batchSize)
+}
+
+// Save : !!! underlying implementation is different with GORM
+// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
+func (j jwtxTokenDo) Save(values ...*model.JwtxToken) error {
+	if len(values) == 0 {
+		return nil
+	}
+	return j.DO.Save(values)
+}
+
+func (j jwtxTokenDo) First() (*model.JwtxToken, error) {
+	if result, err := j.DO.First(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.JwtxToken), nil
+	}
+}
+
+func (j jwtxTokenDo) Take() (*model.JwtxToken, error) {
+	if result, err := j.DO.Take(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.JwtxToken), nil
+	}
+}
+
+func (j jwtxTokenDo) Last() (*model.JwtxToken, error) {
+	if result, err := j.DO.Last(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.JwtxToken), nil
+	}
+}
+
+func (j jwtxTokenDo) Find() ([]*model.JwtxToken, error) {
+	result, err := j.DO.Find()
+	return result.([]*model.JwtxToken), err
+}
+
+func (j jwtxTokenDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.JwtxToken, err error) {
+	buf := make([]*model.JwtxToken, 0, batchSize)
+	err = j.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
+		defer func() { results = append(results, buf...) }()
+		return fc(tx, batch)
+	})
+	return results, err
+}
+
+func (j jwtxTokenDo) FindInBatches(result *[]*model.JwtxToken, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+	return j.DO.FindInBatches(result, batchSize, fc)
+}
+
+func (j jwtxTokenDo) Attrs(attrs ...field.AssignExpr) *jwtxTokenDo {
+	return j.withDO(j.DO.Attrs(attrs...))
+}
+
+func (j jwtxTokenDo) Assign(attrs ...field.AssignExpr) *jwtxTokenDo {
+	return j.withDO(j.DO.Assign(attrs...))
+}
+
+func (j jwtxTokenDo) Joins(fields ...field.RelationField) *jwtxTokenDo {
+	for _, _f := range fields {
+		j = *j.withDO(j.DO.Joins(_f))
+	}
+	return &j
+}
+
+func (j jwtxTokenDo) Preload(fields ...field.RelationField) *jwtxTokenDo {
+	for _, _f := range fields {
+		j = *j.withDO(j.DO.Preload(_f))
+	}
+	return &j
+}
+
+func (j jwtxTokenDo) FirstOrInit() (*model.JwtxToken, error) {
+	if result, err := j.DO.FirstOrInit(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.JwtxToken), nil
+	}
+}
+
+func (j jwtxTokenDo) FirstOrCreate() (*model.JwtxToken, error) {
+	if result, err := j.DO.FirstOrCreate(); err != nil {
+		return nil, err
+	} else {
+		return result.(*model.JwtxToken), nil
+	}
+}
+
+func (j jwtxTokenDo) FindByPage(offset int, limit int) (result []*model.JwtxToken, count int64, err error) {
+	result, err = j.Offset(offset).Limit(limit).Find()
+	if err != nil {
+		return
+	}
+
+	if size := len(result); 0 < limit && 0 < size && size < limit {
+		count = int64(size + offset)
+		return
+	}
+
+	count, err = j.Offset(-1).Limit(-1).Count()
+	return
+}
+
+func (j jwtxTokenDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+	count, err = j.Count()
+	if err != nil {
+		return
+	}
+
+	err = j.Offset(offset).Limit(limit).Scan(result)
+	return
+}
+
+func (j jwtxTokenDo) Scan(result interface{}) (err error) {
+	return j.DO.Scan(result)
+}
+
+func (j jwtxTokenDo) Delete(models ...*model.JwtxToken) (result gen.ResultInfo, err error) {
+	return j.DO.Delete(models)
+}
+
+func (j *jwtxTokenDo) withDO(do gen.Dao) *jwtxTokenDo {
+	j.DO = *do.(*gen.DO)
+	return j
+}

+ 56 - 0
gin/jwtx2/db/gen.yml

@@ -0,0 +1,56 @@
+version: "0.1"
+database:
+
+  # consult[https://gorm.io/docs/connecting_to_the_database.html]"
+  dsn: "root:root@tcp(mysql:3306)/dev_local_platform?charset=utf8mb4&parseTime=True&loc=Local"
+
+  # input mysql or postgres or sqlite or sqlserver. consult[https://gorm.io/docs/connecting_to_the_database.html]
+  db: "mysql"
+
+  # enter the required data table or leave it blank.You can input :
+  # 指定要生成的表名称,默认所有表
+  # tables  :
+  #   - orders
+  #   - users
+  #   - goods
+  tables:
+    - jwtx_tokens
+
+  # only generate models (without query file)
+  onlyModel: false
+
+  # specify a directory for output
+  # 指定输出目录
+  outPath: "./dao/query"
+
+  # query code file name, default: gen.go
+  # Genrated 查询代码文件名称
+  outFile: ""
+
+  # generate unit test for query code
+  # 生成单元测试
+  withUnitTest: false
+
+  # generated model code's package name
+  # 生成模型代码包名称
+  modelPkgName: ""
+
+  # generate with pointer when field is nullable
+  # 当字段允许空时用指针生成
+  fieldNullable: true
+
+  # generate with pointer when field has default value
+  # 当字段具有默认值时,使用指针生成
+  fieldCoverable: true
+
+  # generate field with gorm index tag
+  # 生成带有 gorm index 标签的字段
+  fieldWithIndexTag: true
+
+  # generate field with gorm column type tag
+  # 生成带有 gorm type 标签的字段
+  fieldWithTypeTag: true
+
+  # detect integer field's unsigned type, adjust generated data type
+  # 检测整数字段的无符号类型,调整生成的数据类型
+  fieldSignable: true

+ 19 - 0
gin/jwtx2/db/mysql/jwtx_tokens.sql

@@ -0,0 +1,19 @@
+-- Active: 1727011634447@@mysql@3306@dev_local_platform
+
+DROP TABLE IF EXISTS `jwtx_tokens`;
+CREATE TABLE `jwtx_tokens` (
+  `id`               bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'token ID',
+  `account_id`       bigint unsigned NOT NULL                COMMENT '账户 ID',
+  `login_group`      varchar(255)    NOT NULL                COMMENT '登录的分组',
+  `login_terminal`   varchar(255)    NOT NULL                COMMENT '登录的终端',
+  `make_token_ip`    varchar(50)     NOT NULL                COMMENT '首次请求生成 token 的 IP 地址',
+  `created_at`       datetime        NOT NULL                COMMENT '创建时间',
+  `last_refresh_at`  datetime        NOT NULL                COMMENT '上次的刷新时间',
+  `final_refresh_at` datetime        NOT NULL                COMMENT '最后的刷新时间',
+  `expiration_at`    datetime        NOT NULL                COMMENT '过期时间',
+  --
+  KEY `account_id` (`account_id`),
+  KEY `login_group` (`login_group`),
+  KEY `login_terminal` (`login_terminal`),
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='jwtx token 信息表';

+ 24 - 0
gin/jwtx2/example/demo.yaml

@@ -0,0 +1,24 @@
+
+# consult[https://gorm.io/docs/connecting_to_the_database.html]"
+MysqlDSN : "root:root@tcp(mysql:3306)/demo?charset=utf8mb4&parseTime=True&loc=Local"
+
+# 是否开启 IP一致性校验,当前 IP 必须与登录时 IP 一致
+CheckIP: true
+# 是否开启 单端登录
+SingleEnd: true
+
+# 刷新时间间隔(秒)
+#
+# 超过该时间即可触发 token 刷新、旧 token 进入容错,间隔越小安全性越高
+RefreshInterval: 300
+# 并发容错时间(秒)
+FaultTolerance: 3
+
+# 是否开启 自动续期
+#
+# 假设:单次登录有效期(7天)
+# true  只要 7天 内有操作触发刷新,登录状态就一直延续
+# false 不论 7天 内是否操作,7天 后必定要求重新登录
+AutomaticRenewal: true
+# 单次登录有效期(小时)
+AccessExpireByHour: 8

+ 73 - 0
gin/jwtx2/s.Login.go

@@ -0,0 +1,73 @@
+package jwtx
+
+import (
+	"errors"
+	"time"
+
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/model"
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/query"
+	"github.com/5-say/go-tool/gin/jwtx/tool"
+	"github.com/gin-gonic/gin"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// jwt token 登录
+//
+//	c             *gin.Context
+//	loginGroup    string       登录的 token 分组
+//	loginTerminal string       登录的 终端名称(后续登出时,若引用此处的传值为“all”,则无视配置,强制登出所有终端)
+//	accountID     uint64       登录的 账户 ID
+//
+// e.g.
+//
+//	jwtx.Singleton.Login(c, "admin", "pc", 1)
+//	jwtx.Singleton.Login(c, "admin", "mobile", 1)
+func (s *SingletonT) Login(c *gin.Context, loginGroup string, loginTerminal string, accountID uint64) (err error) {
+	// 获取分组配置
+	var (
+		config     = s.Config[loginGroup]
+		privateKey = s.PrivateKey[loginGroup]
+		q          = query.Use(s.DB[loginGroup])
+	)
+
+	// 清除旧 token
+	err = s.Logout(c, loginGroup, loginTerminal, accountID)
+	if err != nil {
+		return
+	}
+
+	var (
+		now     = time.Now()
+		expTime = now.Add(time.Hour * time.Duration(config.AccessExpireByHour))
+	)
+
+	// 创建新 token
+	token := model.JwtxToken{
+		AccountID:      accountID,
+		LoginGroup:     loginGroup,
+		LoginTerminal:  loginTerminal,
+		MakeTokenIP:    c.ClientIP(),
+		CreatedAt:      now,
+		LastRefreshAt:  now,
+		FinalRefreshAt: now,
+		ExpirationAt:   expTime,
+	}
+	if q.JwtxToken.WithContext(c).Create(&token) != nil {
+		return errors.New("create token fail")
+	}
+
+	// 构造 token 字符串
+	tokenStr, err := tool.GenerateToken(privateKey, jwt.MapClaims{
+		"iat": now.Unix(),     // 签发时间
+		"exp": expTime.Unix(), // 过期时间
+		"tid": token.ID,       // jwt token ID
+	}, tool.P_384)
+	if err != nil {
+		return errors.New("token refresh fail")
+	}
+
+	// 响应头填充 token
+	c.Writer.Header().Add("token", tokenStr)
+
+	return
+}

+ 52 - 0
gin/jwtx2/s.Logout.go

@@ -0,0 +1,52 @@
+package jwtx
+
+import (
+	"errors"
+
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/query"
+	"github.com/5-say/go-tool/logx"
+	"github.com/gin-gonic/gin"
+)
+
+// jwt token 登出
+//
+//	c             *gin.Context
+//	loginGroup    string       登录的 token 分组
+//	loginTerminal string       登录的 终端名称(当传值为“all”时,无视配置,强制登出所有终端)
+//	accountID     uint64       登录的 账户 ID
+//
+// e.g.
+//
+//	jwtx.Singleton.Logout(c, "admin", "pc", 1)
+//	jwtx.Singleton.Logout(c, "admin", "all", 1)
+func (s *SingletonT) Logout(c *gin.Context, loginGroup string, loginTerminal string, accountID uint64) (err error) {
+	// 获取分组配置
+	var (
+		q      = query.Use(s.DB[loginGroup])
+		config = s.Config[loginGroup]
+	)
+
+	if config.SingleEnd || loginTerminal == "all" { // 强制单端登录
+
+		if _, err := q.JwtxToken.WithContext(c).Where(
+			q.JwtxToken.AccountID.Eq(accountID),
+			q.JwtxToken.LoginGroup.Eq(loginGroup),
+		).Delete(); err != nil {
+			logx.Error().Msg("强制单端登录,清除旧 token 失败 [sqlfail: " + err.Error() + "]")
+			return errors.New("clear token fail")
+		}
+
+	} else { // 多端登录
+
+		if _, err := q.JwtxToken.WithContext(c).Where(
+			q.JwtxToken.AccountID.Eq(accountID),
+			q.JwtxToken.LoginGroup.Eq(loginGroup),
+			q.JwtxToken.LoginTerminal.Eq(loginTerminal),
+		).Delete(); err != nil {
+			logx.Error().Msg("多端登录,清除旧 token 失败 [sqlfail: " + err.Error() + "]")
+			return errors.New("clear token fail")
+		}
+	}
+
+	return
+}

+ 170 - 0
gin/jwtx2/s.Middleware.go

@@ -0,0 +1,170 @@
+package jwtx
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/model"
+	"github.com/5-say/go-tool/gin/jwtx/db/dao/query"
+	"github.com/5-say/go-tool/gin/jwtx/tool"
+	"github.com/5-say/go-tool/logx"
+	"github.com/gin-gonic/gin"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// 中间件认证失败后的处理函数 ..
+var MiddlewareFailHandlerFunc = func(c *gin.Context, err error) {
+	logx.Debug().Err(err)
+	c.AbortWithStatus(401)
+}
+
+// jwtx token 中间件(token 解析、校验、刷新) ..
+//
+//	requestGroup string 请求访问的分组
+//
+// e.g.
+//
+//	jwtx.Singleton.Middleware("admin")
+//	jwtx.Singleton.Middleware("merchant")
+func (s *SingletonT) Middleware(requestGroup string) gin.HandlerFunc {
+	return func(c *gin.Context) {
+
+		// 解析 token
+		claims, err := tool.ParseToken(c.GetHeader("Authorization"), &s.PrivateKey[requestGroup].PublicKey)
+		if err != nil {
+			MiddlewareFailHandlerFunc(c, err)
+			return
+		}
+
+		// 提取 claims
+		var (
+			now    = time.Now()                      // 当前时间
+			iat, _ = claims.GetIssuedAt()            // 签发时间
+			exp, _ = claims.GetExpirationTime()      // 过期时间
+			tid    = uint64(claims["tid"].(float64)) // token ID
+		)
+
+		// token 过期时间校验
+		if exp.Unix() < now.Unix() {
+			MiddlewareFailHandlerFunc(c, errors.New("token has expired"))
+			return
+		}
+
+		// 数据库中查找 token
+		token, err := s.getDBToken(requestGroup, tid)
+		if err != nil {
+			MiddlewareFailHandlerFunc(c, err)
+			return
+		}
+
+		// 校验数据库 token 信息
+		err = s.checkDBToken(requestGroup, token, now, c.ClientIP())
+		if err != nil {
+			MiddlewareFailHandlerFunc(c, err)
+			return
+		}
+
+		// 刷新 token
+		newToken, err := s.refreshToken(requestGroup, token, now, iat)
+		if err != nil {
+			MiddlewareFailHandlerFunc(c, err)
+			return
+		}
+
+		// 将当前登录的账户信息存储上下文
+		c.Set("jwtx.accountID", token.AccountID)         // 账户 ID
+		c.Set("jwtx.loginGroup", token.LoginGroup)       // 登录的分组
+		c.Set("jwtx.loginTerminal", token.LoginTerminal) // 登录的终端
+		c.Set("jwtx.makeTokenIP", token.MakeTokenIP)     // 首次请求生成 token 的 IP 地址
+
+		// 请求前
+		c.Next()
+		// 请求后
+
+		// 响应头填充刷新后的 token
+		c.Writer.Header().Add("token", newToken)
+	}
+}
+
+// 获取数据库 token 信息
+func (s *SingletonT) getDBToken(group string, tid uint64) (token *model.JwtxToken, err error) {
+	// 查找 token
+	q := query.Use(s.DB[group])
+	o := q.JwtxToken
+	token, err = o.WithContext(context.Background()).Where(o.ID.Eq(tid)).First()
+	if err != nil {
+		return nil, err
+	}
+	return token, nil
+}
+
+// 校验数据库 token 信息
+func (s *SingletonT) checkDBToken(group string, token *model.JwtxToken, now time.Time, clientIP string) (err error) {
+	// 分组校验
+	if token.LoginGroup != group {
+		return errors.New("auth group fail")
+	}
+	// 过期时间校验
+	if token.ExpirationAt.Unix() < now.Unix() {
+		return errors.New("the token has expired")
+	}
+	// IP 一致性校验
+	if s.Config[group].CheckIP {
+		if clientIP != token.MakeTokenIP {
+			return errors.New("client ip is changed, please login again")
+		}
+	}
+	return
+}
+
+// 自动刷新 token
+func (s *SingletonT) refreshToken(group string, token *model.JwtxToken, now time.Time, iat *jwt.NumericDate) (newToken string, err error) {
+
+	var config = s.Config[group]
+
+	if iat.Unix() == token.FinalRefreshAt.Unix() { // token 未刷新
+
+		// 原始的 token 过期时间
+		expTime := token.ExpirationAt
+
+		// 需要刷新 token
+		if iat.Unix()+config.RefreshInterval < now.Unix() {
+
+			// 自动续期
+			if config.AutomaticRenewal {
+				expTime = now.Add(time.Duration(config.AccessExpireByHour) * time.Hour)
+			}
+
+			// 构造 token 字符串(过期时间不变,签发时间顺延)
+			newToken, err = tool.GenerateToken(s.PrivateKey[group], jwt.MapClaims{
+				"iat": now.Unix(),     // 签发时间
+				"exp": expTime.Unix(), // 过期时间
+				"tid": token.ID,       // jwt token ID
+			}, tool.P_384)
+			if err != nil {
+				return "", err
+			}
+
+			// 更新数据库
+			q := query.Use(s.DB[group])
+			o := q.JwtxToken
+			_, err = o.WithContext(context.Background()).Where(o.ID.Eq(token.ID)).UpdateSimple(
+				o.LastRefreshAt.Value(token.FinalRefreshAt),
+				o.FinalRefreshAt.Value(now),
+			)
+			if err != nil {
+				return "", err
+			}
+		}
+
+	} else if iat.Unix() == token.LastRefreshAt.Unix() { // token 已刷新
+
+		// 当前时间 超出 并发容错时间(不允许继续使用)
+		if now.Unix() > token.FinalRefreshAt.Unix()+config.FaultTolerance {
+			return "", errors.New("out of concurrent fault tolerance time")
+		}
+	}
+
+	return
+}

+ 18 - 0
gin/jwtx2/test/GenerateKey_test.go

@@ -0,0 +1,18 @@
+package test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/5-say/go-tool/crypto/ecdsatool"
+)
+
+func Test_GenerateKey(t *testing.T) {
+	key, _ := ecdsatool.GenerateKey(ecdsatool.P_384)
+	privateKeyPEMBytes, _ := ecdsatool.PEM_EncodePrivateKey(key)
+	fmt.Println()
+	fmt.Println("密钥格式: NIST P-384")
+	fmt.Printf("\n%s\n", privateKeyPEMBytes)
+	fmt.Println("密钥格式: NIST P-384")
+	fmt.Println()
+}

+ 30 - 0
gin/jwtx2/test/GenerateToken_test.go

@@ -0,0 +1,30 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/5-say/go-tool/gin/jwtx/tool"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+func Test_GenerateToken(t *testing.T) {
+	key, err := tool.GetPrivateKey("jwtx/admin.key")
+	// key, err := ecdsatool.GenerateKey(ecdsatool.P_384)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	token, err := tool.GenerateToken(key, jwt.MapClaims{
+		"test": "demo",
+	}, tool.P_384)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	claims, err := tool.ParseToken(token, &key.PublicKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Log(claims["test"])
+}

+ 10 - 0
gin/jwtx2/test/jwtx/admin.key

@@ -0,0 +1,10 @@
+密钥格式: NIST P-384
+
+-----BEGIN EC PRIVATE KEY-----
+MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDAjnvXNxyHaw9VtbFa9
+PbnUHcWzCDTyQo7OmLCcaslC53DP2LrPDPNvgBvcpzzeNNOhZANiAASPwE1OHVHN
+BUfK9GKYvI/WTirmMRKCI7X6ga30DxJD3P9rN9tCclBR7EGmZjI9YlIpew4c4HlD
+A1AzyUA/m+FDdGYioNYc3KdwLI7SIFf3FtOubfUfReKFtN8zKgO4Kjc=
+-----END EC PRIVATE KEY-----
+
+密钥格式: NIST P-384

+ 24 - 0
gin/jwtx2/test/jwtx/admin.yaml

@@ -0,0 +1,24 @@
+
+# consult[https://gorm.io/docs/connecting_to_the_database.html]"
+MysqlDSN : "root:root@tcp(mysql:3306)/jwtx?charset=utf8mb4&parseTime=True&loc=Local"
+
+# 是否开启 IP一致性校验,当前 IP 必须与登录时 IP 一致
+CheckIP: true
+# 是否开启 单端登录
+SingleEnd: true
+
+# 刷新时间间隔(秒)
+#
+# 超过该时间即可触发 token 刷新、旧 token 进入容错,间隔越小安全性越高
+RefreshInterval: 30
+# 并发容错时间(秒)
+FaultTolerance: 3
+
+# 是否开启 自动续期
+#
+# 假设:单次登录有效期(7天)
+# true  只要 7天 内有操作触发刷新,登录状态就一直延续
+# false 不论 7天 内是否操作,7天 后必定要求重新登录
+AutomaticRenewal: true
+# 单次登录有效期(小时)
+AccessExpireByHour: 8

+ 21 - 0
gin/jwtx2/tool/GenerateToken.go

@@ -0,0 +1,21 @@
+package tool
+
+import (
+	"crypto/ecdsa"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// GenerateToken ..
+func GenerateToken(key *ecdsa.PrivateKey, claims jwt.MapClaims, cs CurveStandard) (token string, err error) {
+	switch cs {
+	case P_256:
+		return jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key)
+	case P_384:
+		return jwt.NewWithClaims(jwt.SigningMethodES384, claims).SignedString(key)
+	case P_521:
+		return jwt.NewWithClaims(jwt.SigningMethodES512, claims).SignedString(key)
+	default:
+		return "", nil
+	}
+}

+ 18 - 0
gin/jwtx2/tool/GetPrivateKey.go

@@ -0,0 +1,18 @@
+package tool
+
+import (
+	"crypto/ecdsa"
+	"os"
+
+	"github.com/5-say/go-tool/crypto/ecdsatool"
+)
+
+// GetPrivateKey .. 从 PEM 格式文件 获取 私钥对象
+func GetPrivateKey(filePath string) (privateKey *ecdsa.PrivateKey, err error) {
+	data, err := os.ReadFile(filePath)
+	if err != nil {
+		return
+	}
+
+	return ecdsatool.PEM_DecodePrivateKey(data)
+}

+ 26 - 0
gin/jwtx2/tool/ParseToken.go

@@ -0,0 +1,26 @@
+package tool
+
+import (
+	"crypto/ecdsa"
+	"errors"
+
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// ParseToken ..
+func ParseToken(tokenStr string, publicKey *ecdsa.PublicKey) (claims jwt.MapClaims, err error) {
+
+	jwtToken, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) {
+		return publicKey, nil
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	claims, ok := jwtToken.Claims.(jwt.MapClaims)
+	if !ok {
+		return nil, errors.New("claims parse fail")
+	}
+
+	return
+}

+ 10 - 0
gin/jwtx2/tool/const.go

@@ -0,0 +1,10 @@
+package tool
+
+// 数学曲线标准
+type CurveStandard int
+
+const (
+	P_256 CurveStandard = iota + 1
+	P_384
+	P_521
+)

+ 61 - 0
go.mod

@@ -0,0 +1,61 @@
+module git.ooo.ink/root/go-kit
+
+go 1.25.0
+
+require (
+	github.com/5-say/go-tool v0.6.1
+	github.com/gin-gonic/gin v1.12.0
+	github.com/golang-jwt/jwt/v5 v5.3.1
+	github.com/jinzhu/configor v1.2.2
+	gorm.io/gen v0.3.27
+	gorm.io/gorm v1.31.1
+	gorm.io/plugin/dbresolver v1.6.2
+)
+
+require (
+	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/BurntSushi/toml v1.3.2 // indirect
+	github.com/bytedance/gopkg v0.1.3 // indirect
+	github.com/bytedance/sonic v1.15.0 // indirect
+	github.com/bytedance/sonic/loader v0.5.0 // indirect
+	github.com/cloudwego/base64x v0.1.6 // indirect
+	github.com/gabriel-vasile/mimetype v1.4.12 // indirect
+	github.com/gin-contrib/sse v1.1.0 // indirect
+	github.com/go-playground/locales v0.14.1 // indirect
+	github.com/go-playground/universal-translator v0.18.1 // indirect
+	github.com/go-playground/validator/v10 v10.30.1 // indirect
+	github.com/go-sql-driver/mysql v1.8.1 // indirect
+	github.com/goccy/go-json v0.10.5 // indirect
+	github.com/goccy/go-yaml v1.19.2 // indirect
+	github.com/google/uuid v1.3.0 // indirect
+	github.com/jinzhu/inflection v1.0.0 // indirect
+	github.com/jinzhu/now v1.1.5 // indirect
+	github.com/json-iterator/go v1.1.12 // indirect
+	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+	github.com/leodido/go-urn v1.4.0 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.20 // indirect
+	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+	github.com/modern-go/reflect2 v1.0.2 // indirect
+	github.com/natefinch/lumberjack v2.0.0+incompatible // indirect
+	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
+	github.com/quic-go/qpack v0.6.0 // indirect
+	github.com/quic-go/quic-go v0.59.0 // indirect
+	github.com/rs/zerolog v1.32.0 // indirect
+	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+	github.com/ugorji/go/codec v1.3.1 // indirect
+	go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
+	golang.org/x/arch v0.22.0 // indirect
+	golang.org/x/crypto v0.48.0 // indirect
+	golang.org/x/mod v0.32.0 // indirect
+	golang.org/x/net v0.51.0 // indirect
+	golang.org/x/sync v0.19.0 // indirect
+	golang.org/x/sys v0.41.0 // indirect
+	golang.org/x/text v0.34.0 // indirect
+	golang.org/x/tools v0.41.0 // indirect
+	google.golang.org/protobuf v1.36.10 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+	gorm.io/datatypes v1.2.4 // indirect
+	gorm.io/driver/mysql v1.5.7 // indirect
+	gorm.io/hints v1.1.2 // indirect
+)

+ 181 - 0
go.sum

@@ -0,0 +1,181 @@
+filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
+filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+github.com/5-say/go-tool v0.6.1 h1:62wCJUx83kj/vKjHxAmZaA/BP5iO4XzGeC4EdDAdWv8=
+github.com/5-say/go-tool v0.6.1/go.mod h1:XtqJJWHnhqdeGbn2IxxsYgsJehH0oydUeXymLrp79fI=
+github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
+github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
+github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
+github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
+github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
+github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
+github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
+github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
+github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
+github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
+github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
+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/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
+github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
+github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
+github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
+github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
+github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
+github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
+github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+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-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
+github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/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/configor v1.2.2 h1:sLgh6KMzpCmaQB4e+9Fu/29VErtBUqsS2t8C9BNIVsA=
+github.com/jinzhu/configor v1.2.2/go.mod h1:iFFSfOBKP3kC2Dku0ZGB3t3aulfQgTGJknodhFavsU8=
+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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+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-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
+github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
+github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk=
+github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
+github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
+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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
+github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
+github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
+github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
+github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
+github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
+github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
+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/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
+github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
+go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
+golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
+golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
+golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
+gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
+gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
+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/datatypes v1.2.4 h1:uZmGAcK/QZ0uyfCuVg0VQY1ZmV9h1fuG0tMwKByO1z4=
+gorm.io/datatypes v1.2.4/go.mod h1:f4BsLcFAX67szSv8svwLRjklArSHAvHLeE3pXAS5DZI=
+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.0 h1:u2FXTy14l45qc3UeCJ7QaAXZmZfDDv0YrthvmRq1l0U=
+gorm.io/driver/postgres v1.5.0/go.mod h1:FUZXzO+5Uqg5zzwzv4KK49R8lvGIyscBOqYrtI1Ce9A=
+gorm.io/driver/sqlite v1.5.0/go.mod h1:kDMDfntV9u/vuMmz8APHtHF0b4nyBB7sfCieC6G8k8I=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
+gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
+gorm.io/gen v0.3.27 h1:ziocAFLpE7e0g4Rum69pGfB9S6DweTxK8gAun7cU8as=
+gorm.io/gen v0.3.27/go.mod h1:9zquz2xD1f3Eb/eHq4oLn2z6vDVvQlCY5S3uMBLv4EA=
+gorm.io/gorm v1.24.7-0.20230306060331-85eaf9eeda11/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
+gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
+gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
+gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/hints v1.1.2 h1:b5j0kwk5p4+3BtDtYqqfY+ATSxjj+6ptPgVveuynn9o=
+gorm.io/hints v1.1.2/go.mod h1:/ARdpUHAtyEMCh5NNi3tI7FsGh+Cj/MIUlvNxCNCFWg=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=