feat: 新增 snowid(雪花ID+GORM钩子模板,吸收go-hua); wsc新增SessionClient会话层(吸收两端internal/cloud); jwtx重写为标准三段式HS256+密钥显式传参
This commit is contained in:
@@ -3,6 +3,7 @@ module git.zeroonesoft.cn/golib/zogo
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/go-playground/validator/v10 v10.30.4
|
github.com/go-playground/validator/v10 v10.30.4
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
@@ -10,6 +11,7 @@ require (
|
|||||||
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
|
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
|
||||||
github.com/mattn/go-colorable v0.1.15
|
github.com/mattn/go-colorable v0.1.15
|
||||||
github.com/sirupsen/logrus v1.10.2
|
github.com/sirupsen/logrus v1.10.2
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -23,6 +25,8 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
|
||||||
|
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
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/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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@@ -34,6 +36,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
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/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -52,6 +58,8 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
|
|||||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
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 h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -105,3 +113,7 @@ google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
|||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
+8
-12
@@ -1,23 +1,19 @@
|
|||||||
# jwtx
|
# jwtx
|
||||||
|
|
||||||
轻量 HMAC-SHA256 令牌(JWT 风格,无第三方依赖),用于登录态/服务间鉴权。
|
轻量 HS256 JWT(标准库实现,零外部依赖)。密钥显式传参,不落全局状态。
|
||||||
|
|
||||||
## 用法
|
## 用法
|
||||||
|
|
||||||
```go
|
```go
|
||||||
import "git.zeroonesoft.cn/golib/zogo/jwtx"
|
import "git.zeroonesoft.cn/golib/zogo/jwtx"
|
||||||
|
|
||||||
token, err := jwtx.Sign(uid, 3600) // 签发,TTL 秒
|
token, err := jwtx.Sign(secret, jwtx.Claims{UserID: 42, Role: "admin", Exp: time.Now().Add(time.Hour).Unix()})
|
||||||
claims, err := jwtx.Parse(token) // 校验签名+过期,返回 *Claims{Uid, Exp, Msg}
|
claims, err := jwtx.Parse(secret, token) // 签名错→ErrInvalidToken,过期→ErrExpired
|
||||||
```
|
```
|
||||||
|
|
||||||
## 格式
|
## 说明
|
||||||
|
|
||||||
`base64url(payload).base64url(hmac-sha256(payload))`,两段式(无 header 段),payload 为
|
- 标准三段式 `header.payload.signature`(HS256),`Claims{UserID, Role, Exp}` 为登录令牌
|
||||||
`Claims` JSON。
|
通用三字段;需要更多业务字段时建议换成 `github.com/golang-jwt/jwt`,本包定位是
|
||||||
|
零依赖场景的最小实现。
|
||||||
## 注意
|
- **密钥必须从配置/环境注入**,不要硬编码。
|
||||||
|
|
||||||
- **签名密钥当前硬编码在包内**(`zo-maintain-jwt-secret-2026`)。仅适用于内网/测试场景;
|
|
||||||
用于生产鉴权前应改为注入式(加 `SetSecret` 或从环境读取)。
|
|
||||||
- 过期校验在 `Parse` 内完成(`Exp` 与当前时间比较)。
|
|
||||||
|
|||||||
+41
-30
@@ -1,4 +1,7 @@
|
|||||||
// Package jwtx 轻量 JWT(HMAC-SHA256),无外部依赖。
|
// Package jwtx 轻量 HS256 JWT(标准库实现,零外部依赖)。
|
||||||
|
//
|
||||||
|
// 密钥显式传入(不落包内全局状态),由调用方从配置注入。
|
||||||
|
// Claims 载荷为登录令牌通用三字段(uid/role/exp),业务扩展字段请自行加签发方案。
|
||||||
package jwtx
|
package jwtx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,51 +14,59 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claims 登录令牌载荷。
|
var (
|
||||||
|
// ErrInvalidToken token 无效。
|
||||||
|
ErrInvalidToken = errors.New("无效的 token")
|
||||||
|
// ErrExpired token 已过期。
|
||||||
|
ErrExpired = errors.New("token 已过期")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims JWT 载荷(登录令牌通用字段)。
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
Uid int64 `json:"uid"`
|
UserID int64 `json:"uid"`
|
||||||
Exp int64 `json:"exp"`
|
Role string `json:"role"` // admin / user / console ...
|
||||||
Msg string `json:"msg,omitempty"`
|
Exp int64 `json:"exp"` // 过期时间(Unix 秒)
|
||||||
}
|
}
|
||||||
|
|
||||||
var secret = []byte("zo-maintain-jwt-secret-2026")
|
// Sign 生成 HS256 token。
|
||||||
|
func Sign(secret string, claims Claims) (string, error) {
|
||||||
// Sign 签发 token(TTL 秒)。
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||||
func Sign(uid int64, ttlSeconds int64) (string, error) {
|
payload, err := json.Marshal(claims)
|
||||||
payload, err := json.Marshal(Claims{Uid: uid, Exp: time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix()})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
body := base64.RawURLEncoding.EncodeToString(payload)
|
body := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
sig := sign(body)
|
signingInput := header + "." + body
|
||||||
return body + "." + sig, nil
|
sig := sign(signingInput, secret)
|
||||||
|
return signingInput + "." + sig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse 校验并解析 token。
|
// Parse 校验并解析 token(签名不符返回 ErrInvalidToken,过期返回 ErrExpired)。
|
||||||
func Parse(token string) (*Claims, error) {
|
func Parse(secret, token string) (*Claims, error) {
|
||||||
parts := strings.SplitN(token, ".", 2)
|
parts := strings.Split(token, ".")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 3 {
|
||||||
return nil, errors.New("令牌格式错误")
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
if sign(parts[0]) != parts[1] {
|
signingInput := parts[0] + "." + parts[1]
|
||||||
return nil, errors.New("令牌签名校验失败")
|
if !hmac.Equal([]byte(sign(signingInput, secret)), []byte(parts[2])) {
|
||||||
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.New("令牌载荷解析失败")
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
var c Claims
|
var claims Claims
|
||||||
if err = json.Unmarshal(payload, &c); err != nil {
|
if err = json.Unmarshal(payload, &claims); err != nil {
|
||||||
return nil, errors.New("令牌载荷解析失败")
|
return nil, ErrInvalidToken
|
||||||
}
|
}
|
||||||
if c.Exp < time.Now().Unix() {
|
if claims.Exp > 0 && time.Now().Unix() > claims.Exp {
|
||||||
return nil, errors.New("令牌已过期")
|
return nil, ErrExpired
|
||||||
}
|
}
|
||||||
return &c, nil
|
return &claims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sign(body string) string {
|
func sign(input, secret string) string {
|
||||||
mac := hmac.New(sha256.New, secret)
|
mac := hmac.New(sha256.New, []byte(secret))
|
||||||
mac.Write([]byte(body))
|
mac.Write([]byte(input))
|
||||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# snowid
|
||||||
|
|
||||||
|
基于 `bwmarrin/snowflake` 的雪花 ID 生成器(进程内单例)+ GORM 模型 ID 钩子模板。
|
||||||
|
|
||||||
|
## 用法
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "git.zeroonesoft.cn/golib/zogo/snowid"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := snowid.Init(1); err != nil { ... } // 节点ID 0-1023
|
||||||
|
id := snowid.Generate() // int64
|
||||||
|
s := snowid.GenerateString() // string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成模型(gorm.io/gen):让 bigint 主键自动填充
|
||||||
|
// gen-model 脚本里:
|
||||||
|
opts = append(opts, gen.WithMethod(snowid.IDMethod{}))
|
||||||
|
// 生成的模型自带 BeforeCreate:Id==0 时填 snowid.Generate()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- **必须先 Init 再 Generate**,否则 panic(启动期暴露,不留隐患)。
|
||||||
|
- `InitWithStartTime` 会改全局 Epoch,须在进程首个 NewNode 前调用;同一部署里
|
||||||
|
各服务的 Epoch 口径要一致,ID 才可排序比较。
|
||||||
|
- 多实例部署时节点 ID 不得重复,否则会生成重复 ID。
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Package snowid 基于雪花算法的全局唯一 ID 生成器(进程内单例,需先 Init)。
|
||||||
|
//
|
||||||
|
// 运行时:main 里 Init 后调用 Generate/GenerateString。
|
||||||
|
// GORM 模型:IDMethod 交给 gorm.io/gen 的 WithMethod 注入,
|
||||||
|
// 生成的模型自带 BeforeCreate 钩子(Id 为 0 时自动填充 Generate())。
|
||||||
|
package snowid
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/bwmarrin/snowflake"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
node *snowflake.Node
|
||||||
|
once sync.Once
|
||||||
|
initErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init 初始化雪花ID生成器
|
||||||
|
// nodeID: 节点ID,范围 0-1023
|
||||||
|
func Init(nodeID int64) error {
|
||||||
|
once.Do(func() {
|
||||||
|
node, initErr = snowflake.NewNode(nodeID)
|
||||||
|
})
|
||||||
|
return initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitWithStartTime 使用自定义起始时间初始化雪花ID生成器
|
||||||
|
// nodeID: 节点ID,范围 0-1023
|
||||||
|
// startTime: 自定义起始时间(Unix毫秒时间戳)
|
||||||
|
func InitWithStartTime(nodeID int64, startTime int64) error {
|
||||||
|
once.Do(func() {
|
||||||
|
snowflake.Epoch = startTime
|
||||||
|
node, initErr = snowflake.NewNode(nodeID)
|
||||||
|
})
|
||||||
|
return initErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate 生成一个新的雪花ID
|
||||||
|
func Generate() int64 {
|
||||||
|
if node == nil {
|
||||||
|
panic("snowid: not initialized, please call Init() first")
|
||||||
|
}
|
||||||
|
return node.Generate().Int64()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateString 生成字符串形式的雪花ID
|
||||||
|
func GenerateString() string {
|
||||||
|
if node == nil {
|
||||||
|
panic("snowid: not initialized, please call Init() first")
|
||||||
|
}
|
||||||
|
return node.Generate().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDMethod GORM 模型方法模板:交给 gen.WithMethod 注入后,
|
||||||
|
// 生成的模型在 Create 前自动生成雪花ID主键(字段名须为 Id)。
|
||||||
|
type IDMethod struct {
|
||||||
|
Id int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeCreate 创建前钩子,自动生成雪花ID
|
||||||
|
func (m *IDMethod) BeforeCreate(tx *gorm.DB) error {
|
||||||
|
if m.Id == 0 {
|
||||||
|
m.Id = Generate()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
// session_client.go 提供会话层客户端(SessionClient):在 Client(传输层)之上封装
|
||||||
|
// 「token 延迟连接、连上即注册、应用层心跳、重连回调」等生产编排,业务零样板直接用。
|
||||||
|
package wsc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SessionConfig 会话层配置(在 ClientConfig 之上做封装)。
|
||||||
|
type SessionConfig struct {
|
||||||
|
URL string // WebSocket 地址(必填),如 wss://host/api/ws
|
||||||
|
Token string // 鉴权 token,拼接在 URL ?token= 后;为空时不主动连接,待 UpdateToken 注入后再连
|
||||||
|
Header http.Header // 自定义请求头(如 Authorization),与 Token 二选一或并存
|
||||||
|
AutoReconnect bool // 是否自动重连,默认 true
|
||||||
|
HeartbeatInterval time.Duration // 应用层心跳(action=ping)间隔,默认 25s;<=0 关闭
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionClient 与服务器的会话客户端,基于 Client(传输层)封装:
|
||||||
|
// 负责连接、自动重连、发送注册/心跳并接收响应。
|
||||||
|
//
|
||||||
|
// 关键行为(生产验证过的编排):
|
||||||
|
// - token 为空时绝不空转重连(避免无凭证时的指数退避风暴),
|
||||||
|
// 等 UpdateToken 注入 token 后自动发起首次连接;
|
||||||
|
// - 连接成功(首次/重连)自动发送 register 注册请求;
|
||||||
|
// - 注册完成后再触发 OnReconnected——保证服务端先认识这个连接,再接收业务数据。
|
||||||
|
// 业务在 OnReconnected 中做全量对账重放,兜底断连期间丢失的增量上报。
|
||||||
|
//
|
||||||
|
// 用法:
|
||||||
|
//
|
||||||
|
// c := wsc.NewSessionClient(wsc.SessionConfig{URL: cfg.WsUrl, Token: cfg.Token})
|
||||||
|
// c.OnReconnected = func() { /* 全量对账 */ }
|
||||||
|
// c.OnBind("biz.action", func(resp *BizResp) { ... })
|
||||||
|
// c.Start(&RegisterReq{...})
|
||||||
|
// defer c.Close()
|
||||||
|
type SessionClient struct {
|
||||||
|
// Cli 底层传输层客户端,可直接设置 OnConnected / OnDisconnected /
|
||||||
|
// OnReconnecting 回调,或调用 On / Send 等方法。
|
||||||
|
Cli *Client
|
||||||
|
cfg SessionConfig
|
||||||
|
|
||||||
|
// registerPayload 注册请求载荷(业务自定义结构,如主机名/IP/版本号),
|
||||||
|
// 连接成功后发送;登录后可通过 UpdateRegisterInfo 更新。
|
||||||
|
registerPayload any
|
||||||
|
|
||||||
|
// OnReconnected 连接建立(首次连接与断线重连成功)后触发的回调。
|
||||||
|
// 业务可在此发起全量对账重放,兜底断连期间丢失的上报。
|
||||||
|
OnReconnected func()
|
||||||
|
|
||||||
|
heartbeatStop chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
connectOnce sync.Once // 保证首次 Connect 仅执行一次(由 Start 或 UpdateToken 触发)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSessionClient 创建会话客户端(尚未连接)。构造时会注册内置消息路由,
|
||||||
|
// 调用方应在 Start/Connect 前完成自定义配置(On/OnBind/OnReconnected 等)。
|
||||||
|
func NewSessionClient(cfg SessionConfig) *SessionClient {
|
||||||
|
url := appendToken(cfg.URL, cfg.Token)
|
||||||
|
|
||||||
|
wscCfg := DefaultClientConfig(url)
|
||||||
|
if !cfg.AutoReconnect {
|
||||||
|
wscCfg.AutoReconnect = false
|
||||||
|
}
|
||||||
|
// 加快重连节奏:默认退避上限 30s 体感太慢,收敛到 5s(与参考实现一致)。
|
||||||
|
wscCfg.MaxReconDelay = 5 * time.Second
|
||||||
|
if cfg.Header != nil {
|
||||||
|
wscCfg.Header = cfg.Header
|
||||||
|
}
|
||||||
|
if cfg.HeartbeatInterval == 0 {
|
||||||
|
cfg.HeartbeatInterval = 25 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := NewClient(wscCfg)
|
||||||
|
|
||||||
|
c := &SessionClient{Cli: cli, cfg: cfg, heartbeatStop: make(chan struct{})}
|
||||||
|
c.registerHandlers()
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// registerHandlers 注册内置消息路由(在 Connect 之前调用)。
|
||||||
|
func (c *SessionClient) registerHandlers() {
|
||||||
|
// pong — 服务端对心跳的响应
|
||||||
|
c.Cli.On(ActionPong, BindPayload(func(resp *PingResp) {
|
||||||
|
logrus.Debugf("[wsc-session] pong received, time=%d", resp.Time)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// register — 注册(上线)响应(请求与响应共用同一 action)
|
||||||
|
c.Cli.On(ActionRegister, BindPayload(func(resp *RegisterResp) {
|
||||||
|
if resp.Success {
|
||||||
|
logrus.Infof("[wsc-session] register success: %s", resp.Message)
|
||||||
|
} else {
|
||||||
|
logrus.Warnf("[wsc-session] register failed: %s", resp.Message)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 连接控制
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Start 启动客户端并连接。连接/重连成功后自动发送注册请求并启动心跳,
|
||||||
|
// 调用方无需在外部自行编排心跳循环(registerPayload 为 nil 时仅连接不注册)。
|
||||||
|
//
|
||||||
|
// 若 token 为空则跳过连接,等待 UpdateToken 注入 token 后再发起首次连接。
|
||||||
|
func (c *SessionClient) Start(registerPayload any) error {
|
||||||
|
c.registerPayload = registerPayload
|
||||||
|
prev := c.Cli.OnConnected
|
||||||
|
c.Cli.OnConnected = func() {
|
||||||
|
if prev != nil {
|
||||||
|
prev()
|
||||||
|
}
|
||||||
|
logrus.Infof("[wsc-session] connected to %s", c.cfg.URL)
|
||||||
|
if c.registerPayload != nil {
|
||||||
|
if err := c.SendRegister(c.registerPayload); err != nil {
|
||||||
|
logrus.Errorf("[wsc-session] send register failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 注册完成后再触发业务回调(全量对账重放),
|
||||||
|
// 保证服务端先认识这个连接,再接收业务数据。
|
||||||
|
if c.OnReconnected != nil {
|
||||||
|
c.OnReconnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go c.heartbeatLoop()
|
||||||
|
|
||||||
|
// 没有 token 肯定连不上,跳过连接,等 UpdateToken 注入 token 后再连。
|
||||||
|
if c.cfg.Token == "" {
|
||||||
|
logrus.Warnf("[wsc-session] token empty, skip connect, wait for UpdateToken")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.doConnect()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doConnect 发起首次连接(仅执行一次,由 Start 或 UpdateToken 触发)。
|
||||||
|
// 连接在独立 goroutine 中重试,不阻塞调用方(避免 UpdateToken/Start 的调用者被重连循环卡死)。
|
||||||
|
func (c *SessionClient) doConnect() {
|
||||||
|
c.connectOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
if err := c.Cli.Connect(); err != nil {
|
||||||
|
logrus.Errorf("[wsc-session] connect loop exited: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeatLoop 内部心跳协程:连接状态下按间隔发送 ping,断开或 Close 时退出。
|
||||||
|
// 业务编排收口在客户端内,调用方不必在外部再写一层心跳逻辑。
|
||||||
|
func (c *SessionClient) heartbeatLoop() {
|
||||||
|
if c.cfg.HeartbeatInterval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(c.cfg.HeartbeatInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.heartbeatStop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if !c.Connected() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := c.SendPing(); err != nil {
|
||||||
|
logrus.Errorf("[wsc-session] heartbeat send failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRegisterInfo 更新注册信息(如登录后拿到业务ID)。若已连接则立即重发注册,
|
||||||
|
// 确保服务端拿到完整字段;未连接则仅暂存,待连接成功后由 OnConnected 发送。
|
||||||
|
func (c *SessionClient) UpdateRegisterInfo(payload any) {
|
||||||
|
c.registerPayload = payload
|
||||||
|
if c.Connected() {
|
||||||
|
if err := c.SendRegister(payload); err != nil {
|
||||||
|
logrus.Errorf("[wsc-session] re-send register failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateToken 动态更新鉴权 token(重新拼接到 URL)。
|
||||||
|
// 若此前因 token 为空未连接,则在此发起首次连接;已连接/正在连接则不受影响
|
||||||
|
// (connectOnce 保证仅连一次,后续断连由底层 Client 自动重连并使用新 URL)。并发安全。
|
||||||
|
func (c *SessionClient) UpdateToken(token string) {
|
||||||
|
c.cfg.Token = token
|
||||||
|
c.Cli.SetURL(appendToken(c.cfg.URL, token))
|
||||||
|
// token 就绪:若此前因无 token 未连接,则在此发起首次连接(仅一次,异步不阻塞)。
|
||||||
|
if token != "" {
|
||||||
|
logrus.Infof("[wsc-session] token updated, triggering connect")
|
||||||
|
c.doConnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭连接、停止重连与心跳协程。
|
||||||
|
func (c *SessionClient) Close() {
|
||||||
|
c.closeOnce.Do(func() {
|
||||||
|
close(c.heartbeatStop)
|
||||||
|
})
|
||||||
|
c.Cli.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connected 返回当前是否已连接。
|
||||||
|
func (c *SessionClient) Connected() bool {
|
||||||
|
return c.Cli.Connected()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done 返回一个通道,客户端完全关闭后关闭。
|
||||||
|
func (c *SessionClient) Done() <-chan struct{} {
|
||||||
|
return c.Cli.Done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 发送消息
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// SendPing 发送心跳(action=ping)。
|
||||||
|
func (c *SessionClient) SendPing() error {
|
||||||
|
return c.Cli.Send(ActionPing, &PingReq{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRegister 发送注册(上线)请求(action=register)。
|
||||||
|
func (c *SessionClient) SendRegister(payload any) error {
|
||||||
|
return c.Cli.Send(ActionRegister, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 发送任意自定义消息(并发安全)。
|
||||||
|
func (c *SessionClient) Send(action string, payload any) error {
|
||||||
|
return c.Cli.Send(action, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendRaw 发送已序列化的原始字节(并发安全)。
|
||||||
|
// 注意:重连时底层 writeCh 会重建、旧缓冲数据被丢弃,
|
||||||
|
// 上层务必「未连接不投递」,未落盘的数据等待 OnReconnected 全量对账。
|
||||||
|
func (c *SessionClient) SendRaw(data []byte) error {
|
||||||
|
return c.Cli.SendRaw(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 接收消息:注册自定义处理器(直接透传到底层 Cli)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// On 注册 action 对应的消息处理器,回调拿到原始 payload(json.RawMessage),
|
||||||
|
// 在 Connect/Start 之前调用。
|
||||||
|
func (c *SessionClient) On(action string, handler PayloadHandler) {
|
||||||
|
c.Cli.On(action, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnBind 注册带类型反序列化的消息处理器(自动将 payload 反序列化到结构体指针)。
|
||||||
|
func (c *SessionClient) OnBind(action string, fn any) {
|
||||||
|
c.Cli.On(action, BindPayload(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendToken 把 token 拼接到 URL 查询串。
|
||||||
|
func appendToken(url, token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
sep := "?"
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
sep = "&"
|
||||||
|
}
|
||||||
|
return url + sep + "token=" + token
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// session_protocol.go 定义会话层协议:会话级 action 常量与心跳/注册消息。
|
||||||
|
// 业务 action(业务消息)由各业务模块自行定义,不放在框架层。
|
||||||
|
package wsc
|
||||||
|
|
||||||
|
// 会话级 action 常量:心跳与注册(所有业务共用)。
|
||||||
|
const (
|
||||||
|
ActionPing = "ping"
|
||||||
|
ActionPong = "pong"
|
||||||
|
ActionRegister = "register"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PingReq 心跳请求(无业务字段)。
|
||||||
|
type PingReq struct{}
|
||||||
|
|
||||||
|
// PingResp 心跳响应(action=pong)。
|
||||||
|
type PingResp struct {
|
||||||
|
Time int64 `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterResp 注册(上线)响应(action=register,与请求共用同一 action)。
|
||||||
|
type RegisterResp struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user