b 4 月之前
父節點
當前提交
62fa4d2527

+ 57 - 0
src/gin/jwtx/CurrentAuth.go

@@ -0,0 +1,57 @@
+package jwtx
+
+import "github.com/gin-gonic/gin"
+
+// Current 结构体定义了当前登录用户的基本信息
+// 该结构体用于在请求处理过程中传递和访问认证信息
+type Current struct {
+	AccountID     uint64 // 账户 ID - 当前登录用户的唯一标识符
+	LoginGroup    string // 登录的分组 - 用户所属的认证分组(如:admin、user)
+	LoginTerminal string // 登录的终端 - 用户登录的设备类型(如:pc、mobile)
+	MakeTokenIP   string // 首次请求生成 token 的 IP 地址 - 用于安全审计和 IP 一致性校验
+}
+
+// CurrentAuth 获取当前登录的账户认证信息
+//
+// 该函数从 Gin 上下文中提取 JWT 中间件设置的认证信息
+// 必须与 jwtx.Singleton.Middleware() 中间件配合使用
+//
+// 参数说明:
+//   - c *gin.Context: Gin 框架的请求上下文对象
+//
+// 返回值:
+//   - Current: 包含当前登录用户认证信息的结构体
+//
+// 使用场景:
+//   - 在需要获取当前登录用户信息的业务处理函数中调用
+//   - 用于权限验证、审计日志、个性化设置等场景
+//
+// 使用示例:
+//
+//	func getUserInfo(c *gin.Context) {
+//	    current := jwtx.CurrentAuth(c)
+//	    fmt.Printf("当前用户ID: %d, 分组: %s, 终端: %s\n",
+//	        current.AccountID, current.LoginGroup, current.LoginTerminal)
+//	}
+//
+// 注意事项:
+//   - 必须在 JWT 中间件之后调用,否则无法获取正确的认证信息
+//   - 如果用户未登录或认证失败,返回的 AccountID 将为 0
+//   - 建议在业务逻辑中进行空值检查
+func CurrentAuth(c *gin.Context) Current {
+	// 从 Gin 上下文中提取账户 ID
+	// 使用类型断言确保安全获取 uint64 类型的账户 ID
+	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"),   // 生成 token 的原始 IP
+	}
+}

+ 4 - 4
src/gin/jwtx/InitGroup.go

@@ -64,9 +64,9 @@ func InitGroup(group, configPath, privateKeyPath string) *SingletonT {
 	// 取得单例 - 如果全局单例对象尚未初始化,则进行初始化
 	if Singleton == nil {
 		Singleton = &SingletonT{}
-		Singleton.DB = make(map[string]*gorm.DB)                   // 初始化数据库连接映射,按分组存储
-		Singleton.Config = make(map[string]GroupConfig)            // 初始化配置映射,按分组存储
-		Singleton.PrivateKey = make(map[string]ed25519.PrivateKey) // 初始化私钥映射,按分组存储
+		Singleton.DB = make(map[string]*gorm.DB)                    // 初始化数据库连接映射,按分组存储
+		Singleton.Config = make(map[string]GroupConfig)             // 初始化配置映射,按分组存储
+		Singleton.PrivateKey = make(map[string]*ed25519.PrivateKey) // 初始化私钥映射,按分组存储
 	}
 
 	// 引用简化 - 使用局部变量简化代码书写
@@ -99,7 +99,7 @@ func InitGroup(group, configPath, privateKeyPath string) *SingletonT {
 	if err != nil {
 		panic(err) // 私钥加载失败,系统无法正常运行
 	}
-	s.PrivateKey[group] = *privateKey // 将私钥存储到对应分组
+	s.PrivateKey[group] = privateKey // 将私钥存储到对应分组
 
 	return s // 返回单例对象,便于链式调用
 }

+ 2 - 2
src/gin/jwtx/Singleton.go

@@ -13,7 +13,7 @@ var Singleton *SingletonT
 type SingletonT struct {
 	DB         map[string]*gorm.DB
 	Config     map[string]GroupConfig
-	PrivateKey map[string]ed25519.PrivateKey
+	PrivateKey map[string]*ed25519.PrivateKey
 }
 
 // 分组配置
@@ -41,4 +41,4 @@ type GroupConfig struct {
 	AutomaticRenewal bool `yaml:"AutomaticRenewal"`
 	// 单次登录有效期(小时)
 	AccessExpireByHour int64 `yaml:"AccessExpireByHour"`
-}
+}

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

@@ -1,93 +0,0 @@
-// 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
-}

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

@@ -1,362 +0,0 @@
-// 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
-}

+ 2 - 2
src/gin/jwtx/db/gen.yml

@@ -2,7 +2,7 @@ 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"
+  dsn: "root:root@tcp(mysql:3306)/demo?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"
@@ -17,7 +17,7 @@ database:
     - jwtx_tokens
 
   # only generate models (without query file)
-  onlyModel: false
+  onlyModel: true
 
   # specify a directory for output
   # 指定输出目录

+ 141 - 0
src/gin/jwtx/s.Login.go

@@ -0,0 +1,141 @@
+package jwtx
+
+import (
+	"errors"
+	"time"
+
+	"git.ooo.ink/root/go-kit/src/gin/jwtx/db/dao/model"
+	"git.ooo.ink/root/go-kit/src/gin/jwtx/tool"
+	"github.com/gin-gonic/gin"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// Login 用户登录认证函数
+//
+// 该函数处理用户登录流程,包括清除旧会话、创建新 token、生成 JWT 字符串等完整流程
+// 支持多分组、多终端登录,提供完整的 JWT 认证解决方案
+//
+// 参数说明:
+//   - c             *gin.Context    : Gin 框架上下文对象,用于获取请求信息和设置响应
+//   - loginGroup    string          : 登录的 token 分组(如:"admin"、"user"、"api")
+//   - loginTerminal string          : 登录的终端名称(如:"pc"、"mobile"、"web")
+//     特殊值 "all" 用于强制登出所有终端
+//   - accountID     uint64          : 登录的账户唯一标识符
+//
+// 返回值:
+//   - error: 登录过程中出现的错误,成功时返回 nil
+//
+// 功能特性:
+//   - 支持多分组配置:不同分组可设置不同的过期时间、私钥等参数
+//   - 支持多终端登录:同一账户可在不同终端同时登录
+//   - 单端登录控制:通过清除旧会话实现单端登录逻辑
+//   - IP 地址记录:记录生成 token 时的客户端 IP,用于安全校验
+//   - 时间戳管理:记录创建时间、刷新时间、过期时间等关键时间点
+//   - 数据库关联:将 JWT token 与数据库记录关联,支持 token 管理
+//
+// 使用示例:
+//
+//	// 管理员在 PC 端登录
+//	jwtx.Singleton.Login(c, "admin", "pc", 1)
+//
+//	// 管理员在移动端登录
+//	jwtx.Singleton.Login(c, "admin", "mobile", 1)
+//
+//	// 普通用户在 Web 端登录
+//	jwtx.Singleton.Login(c, "user", "web", 12345)
+//
+// 安全说明:
+//   - 使用 Ed25519 签名算法,提供强大的加密保护
+//   - 每次登录前清除旧会话,防止 token 泄露风险
+//   - 记录客户端 IP,支持 IP 一致性校验
+//   - 返回统一的错误信息,避免泄露内部实现细节
+func (s *SingletonT) Login(c *gin.Context, loginGroup string, loginTerminal string, accountID uint64) (err error) {
+	// ===========================================
+	// 步骤 1: 获取分组配置信息
+	// ===========================================
+	// 从单例中获取指定分组的配置信息和私钥
+	// 支持多租户配置,不同分组可以有不同的安全策略
+	var (
+		config     = s.Config[loginGroup]     // 分组配置信息(包含过期时间、单端登录等设置)
+		privateKey = s.PrivateKey[loginGroup] // 分组私钥,用于 JWT token 签名(Ed25519 算法)
+	)
+
+	// ===========================================
+	// 步骤 2: 清除旧会话(单端登录控制)
+	// ===========================================
+	// 登录前先清除该账户的现有会话,确保单端登录或强制登出逻辑
+	// 防止 token 泄露或重复登录的安全风险
+	err = s.Logout(c, loginGroup, loginTerminal, accountID)
+	if err != nil {
+		// 清除旧 token 失败,直接返回错误
+		// 可能是数据库连接问题或权限不足
+		return
+	}
+
+	// ===========================================
+	// 步骤 3: 计算 token 时间信息
+	// ===========================================
+	// 基于配置计算 token 的有效期,支持灵活的过期时间设置
+	var (
+		now     = time.Now()                                                    // 当前时间,作为 token 创建和刷新基准
+		expTime = now.Add(time.Hour * time.Duration(config.AccessExpireByHour)) // 过期时间 = 当前时间 + 配置的小时数
+	)
+
+	// ===========================================
+	// 步骤 4: 创建数据库 token 记录
+	// ===========================================
+	// 构建完整的 token 记录,包含审计信息和时间戳
+	// 用于后续的 token 验证、刷新和管理操作
+	token := model.JwtxToken{
+		AccountID:      accountID,     // 账户唯一标识,关联用户表
+		LoginGroup:     loginGroup,    // 登录分组(如:admin、user),用于权限控制
+		LoginTerminal:  loginTerminal, // 登录终端(如:pc、mobile),支持多终端登录
+		MakeTokenIP:    c.ClientIP(),  // 客户端 IP 地址,用于 IP 一致性校验和安全审计
+		CreatedAt:      now,           // 创建时间,记录 token 生成时间点
+		LastRefreshAt:  now,           // 上次刷新时间(初始与创建时间相同),用于刷新逻辑
+		FinalRefreshAt: now,           // 最后刷新时间(初始与创建时间相同),用于过期判断
+		ExpirationAt:   expTime,       // 过期时间,基于配置计算的绝对时间
+	}
+
+	// ===========================================
+	// 步骤 5: 保存 token 记录到数据库
+	// ===========================================
+	// 使用 GORM 将 token 信息持久化到数据库
+	// 支持事务和上下文传递,确保数据一致性
+	result := s.DB[loginGroup].WithContext(c).Create(&token)
+	if result.Error != nil {
+		// 数据库创建失败,返回统一的错误信息
+		// 避免泄露数据库细节,提高安全性
+		return errors.New("create token fail")
+	}
+
+	// ===========================================
+	// 步骤 6: 生成 JWT token 字符串
+	// ===========================================
+	// 使用 Ed25519 算法生成安全的 JWT token
+	// token 包含标准声明字段,用于后续验证和刷新逻辑
+	tokenStr, err := tool.GenerateToken(privateKey, jwt.MapClaims{
+		"iat": now.Unix(),     // 签发时间 (Issued At) - token 创建的时间戳,用于计算刷新间隔
+		"exp": expTime.Unix(), // 过期时间 (Expiration) - token 失效的时间戳,基于配置的 AccessExpireByHour
+		"tid": token.ID,       // Token ID - 数据库中 token 记录的唯一标识,关联数据库记录
+	})
+	if err != nil {
+		// JWT token 生成失败 - 可能是私钥格式错误或签名算法问题
+		// 返回统一的错误信息,避免泄露内部细节
+		return errors.New("token refresh fail")
+	}
+
+	// ===========================================
+	// 步骤 7: 设置响应头
+	// ===========================================
+	// 将生成的 JWT token 添加到 HTTP 响应头中
+	// 客户端可以从响应头中获取 token 进行后续请求
+	c.Writer.Header().Add("token", tokenStr)
+
+	// ===========================================
+	// 步骤 8: 返回成功
+	// ===========================================
+	// 登录流程完成,返回 nil 表示成功
+	// 客户端可以开始使用返回的 token 进行认证请求
+	return
+}

+ 102 - 0
src/gin/jwtx/s.Logout.go

@@ -0,0 +1,102 @@
+package jwtx
+
+import (
+	"errors"
+
+	"git.ooo.ink/root/go-kit/src/gin/jwtx/db/dao/model"
+	"git.ooo.ink/root/go-kit/src/logx"
+	"github.com/gin-gonic/gin"
+)
+
+// Logout JWT Token 登出操作
+//
+// 该函数用于处理用户登出操作,根据配置和参数决定登出范围:
+// - 单端登录模式:登出该账户在该分组下的所有终端会话
+// - 多端登录模式:仅登出指定终端的会话
+// - 强制登出模式:当 terminal 参数为 "all" 时,无视配置强制登出所有终端
+//
+// 参数说明:
+//
+//	c             *gin.Context    - Gin 请求上下文,用于获取数据库连接和日志记录
+//	loginGroup    string          - 登录的 token 分组,标识认证分组(如:"admin", "user")
+//	loginTerminal string          - 登录的终端名称,标识登录设备类型
+//	                              - 特殊值 "all":强制登出该账户在该分组下的所有终端
+//	accountID     uint64          - 登录的账户 ID,标识需要登出的用户
+//
+// 返回值:
+//
+//	error - 操作成功返回 nil,失败返回错误信息
+//
+// 业务逻辑:
+// 1. 根据配置的 SingleEnd 标志决定登出范围
+// 2. 支持多租户场景,不同分组有独立的配置和数据库连接
+// 3. 提供强制登出功能,便于管理员操作
+//
+// 使用示例:
+//
+//	// 登出管理员账户在 PC 端的会话
+//	jwtx.Singleton.Logout(c, "admin", "pc", 1)
+//
+//	// 强制登出管理员账户在所有终端的会话
+//	jwtx.Singleton.Logout(c, "admin", "all", 1)
+//
+//	// 登出用户账户在移动端的会话
+//	jwtx.Singleton.Logout(c, "user", "mobile", 123)
+func (s *SingletonT) Logout(c *gin.Context, loginGroup string, loginTerminal string, accountID uint64) (err error) {
+	// ===========================================
+	// 步骤 1: 获取分组配置信息
+	// ===========================================
+	// 从单例配置映射中获取指定分组的配置信息
+	// 支持多租户配置,不同分组可以有不同的安全策略
+	var config = s.Config[loginGroup]
+
+	// ===========================================
+	// 步骤 2: 获取数据库连接
+	// ===========================================
+	// 获取对应分组的数据库连接,并绑定请求上下文
+	// 确保数据库操作与当前请求关联,支持事务和超时控制
+	db := s.DB[loginGroup].WithContext(c)
+
+	// ===========================================
+	// 步骤 3: 判断登出范围(单端登录 vs 多端登录)
+	// ===========================================
+	// 根据配置和参数决定登出范围:
+	// - 单端登录模式:登出该账户在该分组下的所有终端会话
+	// - 强制登出模式:当 terminal 参数为 "all" 时,无视配置强制登出所有终端
+	// - 多端登录模式:仅登出指定终端的会话
+	if config.SingleEnd || loginTerminal == "all" {
+		// ===========================================
+		// 分支 A: 强制单端登录场景
+		// ===========================================
+		// 删除该账户在该分组下的所有 token
+		// 清除该账户在该认证分组下的所有登录会话,确保单端登录安全
+		result := db.Where("account_id = ? AND login_group = ?", accountID, loginGroup).Delete(&model.JwtxToken{})
+		if result.Error != nil {
+			// 记录错误日志并返回错误信息 - 数据库操作失败
+			// 使用结构化日志记录详细错误信息,便于问题排查
+			logx.Error().Msg("强制单端登录,清除旧 token 失败 [sqlfail: " + result.Error.Error() + "]")
+			return errors.New("clear token fail")
+		}
+
+	} else {
+		// ===========================================
+		// 分支 B: 多端登录场景
+		// ===========================================
+		// 删除该账户在该分组和终端下的 token
+		// 仅清除指定终端的登录会话,允许用户在其他终端继续使用
+		result := db.Where("account_id = ? AND login_group = ? AND login_terminal = ?", accountID, loginGroup, loginTerminal).Delete(&model.JwtxToken{})
+		if result.Error != nil {
+			// 记录错误日志并返回错误信息 - 数据库操作失败
+			// 使用结构化日志记录详细错误信息,便于问题排查
+			logx.Error().Msg("多端登录,清除旧 token 失败 [sqlfail: " + result.Error.Error() + "]")
+			return errors.New("clear token fail")
+		}
+	}
+
+	// ===========================================
+	// 步骤 4: 返回成功结果
+	// ===========================================
+	// 成功执行数据库操作,返回 nil 表示登出成功
+	// 客户端可以确认会话已被安全清除
+	return
+}

+ 200 - 0
src/gin/jwtx/s.Middleware.go

@@ -0,0 +1,200 @@
+package jwtx
+
+import (
+	"context"
+	"crypto/ed25519"
+	"errors"
+	"time"
+
+	"git.ooo.ink/root/go-kit/src/gin/jwtx/db/dao/model"
+	"git.ooo.ink/root/go-kit/src/gin/jwtx/tool"
+	"git.ooo.ink/root/go-kit/src/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
+		// 从 Authorization 头中提取 JWT token 并进行验证
+		tokenStr := c.GetHeader("Authorization")
+		if tokenStr == "" {
+			MiddlewareFailHandlerFunc(c, errors.New("authorization header is required"))
+			return
+		}
+
+		// 使用对应分组的公钥验证 token 签名
+		claims, err := tool.ParseToken(tokenStr, s.PrivateKey[requestGroup].Public().(*ed25519.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 信息
+//
+// 参数说明:
+//   - group string: token 分组标识,用于获取对应的数据库连接
+//   - tid uint64: token 记录的唯一标识符
+//
+// 返回值:
+//   - *model.JwtxToken: 查询到的 token 记录
+//   - error: 查询过程中出现的错误
+//
+// 功能说明:
+//
+//	根据 token ID 从数据库中查询对应的 token 记录
+//	使用标准的 GORM 查询方式,避免使用 query 包
+func (s *SingletonT) getDBToken(group string, tid uint64) (token *model.JwtxToken, err error) {
+	// 使用标准的 GORM 查询方式查找 token
+	// 根据 token ID 查询对应的记录
+	token = &model.JwtxToken{}
+	err = s.DB[group].WithContext(context.Background()).
+		Where("id = ?", tid).
+		First(token).
+		Error
+
+	if err != nil {
+		// 查询失败,返回错误
+		return nil, err
+	}
+
+	// 查询成功,返回 token 记录
+	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
+			})
+			if err != nil {
+				return "", err
+			}
+
+			// 更新数据库 - 使用标准的 GORM 更新方式
+			// 更新 token 的刷新时间信息
+			err = s.DB[group].WithContext(context.Background()).
+				Model(&model.JwtxToken{}).
+				Where("id = ?", token.ID).
+				Updates(map[string]interface{}{
+					"last_refresh_at":  token.FinalRefreshAt, // 上次刷新时间设置为之前的最后刷新时间
+					"final_refresh_at": now,                  // 最后刷新时间更新为当前时间
+				}).Error
+			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
+}