60 changed files with 2570 additions and 135 deletions
@ -0,0 +1,5 @@ |
|||
use context7 |
|||
后端技术栈: |
|||
go-zero |
|||
gorm |
|||
|
@ -1,2 +1,3 @@ |
|||
/etcd |
|||
/redis |
|||
*.exe |
@ -0,0 +1,7 @@ |
|||
# modd.conf 文件用于配置 modd 工具 |
|||
# backend 后台监听文件变化,自动重启服务 |
|||
# 1. usercenter 服务 |
|||
usercenter/*{ |
|||
prep: go build -o usercenter/usercenter -v usercenter/usercenter.go |
|||
daemon +sigkill: ./usercenter/usercenter -f usercenter/etc/usercenter.yaml |
|||
} |
@ -0,0 +1,126 @@ |
|||
# backend 说明 |
|||
|
|||
## 主要包 |
|||
|
|||
- go-zero 框架 |
|||
|
|||
```bash |
|||
go get github.com/zeromicro/go-zero |
|||
``` |
|||
|
|||
- gorm 数据库 |
|||
|
|||
```bash |
|||
go get gorm.io/gorm |
|||
``` |
|||
|
|||
- redis 缓存 |
|||
|
|||
```bash |
|||
go get github.com/redis/go-redis/v9 |
|||
go get github.com/redis/go-redis/extra/redisotel/v9 |
|||
``` |
|||
|
|||
## 工具包 |
|||
|
|||
- bcrypt 密码加密 |
|||
|
|||
```bash |
|||
go get golang.org/x/crypto/bcrypt |
|||
``` |
|||
|
|||
- jwt 认证 |
|||
|
|||
```bash |
|||
go get github.com/golang-jwt/jwt/v4 |
|||
``` |
|||
|
|||
- zap 日志 |
|||
|
|||
```bash |
|||
go get go.uber.org/zap |
|||
``` |
|||
|
|||
- viper 配置 |
|||
|
|||
```bash |
|||
go get github.com/spf13/viper |
|||
``` |
|||
|
|||
- copier 数据拷贝 |
|||
|
|||
```bash |
|||
go get github.com/jinzhu/copier/v2 |
|||
``` |
|||
|
|||
- strconv 字符串转换 |
|||
|
|||
```bash |
|||
go get strconv |
|||
``` |
|||
|
|||
- time 时间 |
|||
|
|||
```bash |
|||
go get time |
|||
``` |
|||
|
|||
- errors 错误 |
|||
|
|||
```bash |
|||
go get errors |
|||
``` |
|||
|
|||
- regexp 正则 |
|||
|
|||
```bash |
|||
go get regexp |
|||
``` |
|||
|
|||
- context 上下文 |
|||
|
|||
```bash |
|||
go get context |
|||
``` |
|||
|
|||
- fmt 格式化 |
|||
|
|||
```bash |
|||
go get fmt |
|||
``` |
|||
|
|||
- cast 类型转换 |
|||
|
|||
```bash |
|||
go get github.com/spf13/cast |
|||
``` |
|||
|
|||
- carbon 时间 |
|||
|
|||
```bash |
|||
go get github.com/golang-module/carbon/v2 |
|||
``` |
|||
|
|||
- base64Captcha 验证码 |
|||
|
|||
```bash |
|||
go get github.com/mojocn/base64Captcha |
|||
``` |
|||
|
|||
## 常用命令 |
|||
|
|||
- 生成 api 文件 |
|||
|
|||
```bash |
|||
goctl api new xxx -style go_zero |
|||
// 指定仓库地址,注意goctl版本 |
|||
goctl api new xxx -remote https://gitea.gxxhygroup.com/dark/goctl184.git -style go_zero |
|||
``` |
|||
|
|||
- api 生成 go 文件 |
|||
|
|||
```bash |
|||
goctl api go -api usercenter.api -dir . -style go_zero |
|||
// 指定仓库地址,注意goctl版本 |
|||
goctl api go -api usercenter.api -dir . -remote https://gitea.gxxhygroup.com/dark/goctl184.git -style go_zero |
|||
``` |
@ -0,0 +1,50 @@ |
|||
// 用户管理 |
|||
syntax = "v1" |
|||
|
|||
type RegisterRequest { |
|||
Username string `json:"username"` |
|||
Password string `json:"password"` |
|||
Email string `json:"email,optional"` |
|||
Phone string `json:"phone"` |
|||
Nickname string `json:"nickname,optional"` |
|||
Avatar string `json:"avatar,optional"` |
|||
Role string `json:"role,optional"` |
|||
Status string `json:"status,optional"` |
|||
} |
|||
|
|||
type LoginRequest { |
|||
Identity string `json:"identity"` |
|||
Password string `json:"password"` |
|||
} |
|||
|
|||
type LogoutRequest { |
|||
Token string `json:"token"` |
|||
} |
|||
|
|||
type UsersResponse { |
|||
Code int `json:"code"` |
|||
Message string `json:"message"` |
|||
Data interface{} `json:"data"` |
|||
Success bool `json:"success"` |
|||
} |
|||
|
|||
@server ( |
|||
// jwt: Auth |
|||
// middleware: CheckPermission |
|||
group: user |
|||
) |
|||
// 用户管理 |
|||
service usercenter-api { |
|||
@doc "用户注册" |
|||
@handler UserRegisterHandler |
|||
post /register (RegisterRequest) returns (UsersResponse) |
|||
|
|||
@doc "用户登录" |
|||
@handler UserLoginHandler |
|||
post /login (LoginRequest) returns (UsersResponse) |
|||
|
|||
@doc "用户登出" |
|||
@handler UserLogoutHandler |
|||
post /logout (LogoutRequest) returns (UsersResponse) |
|||
} |
|||
|
@ -0,0 +1,23 @@ |
|||
Name: usercenter-api |
|||
Host: 0.0.0.0 |
|||
Port: 8889 |
|||
MySQL: |
|||
Host: 127.0.0.1 |
|||
Port: 3306 |
|||
User: root |
|||
Password: root |
|||
DBName: usercenter |
|||
MaxIdleConns: 10 |
|||
MaxOpenConns: 100 |
|||
Redis: |
|||
Host: 127.0.0.1 |
|||
Port: 6379 |
|||
Type: node |
|||
Pass: xhy.dev |
|||
Tls: false |
|||
TkDB: 1 |
|||
CasheDb: 0 |
|||
Auth: |
|||
AccessSecret: dev.gxxhygroup.com |
|||
AccessExpire: 604800 |
|||
TkStore: true |
@ -0,0 +1,26 @@ |
|||
package config |
|||
|
|||
import "github.com/zeromicro/go-zero/rest" |
|||
|
|||
type Config struct { |
|||
rest.RestConf |
|||
MySQL struct { |
|||
Host string |
|||
Port int |
|||
User string |
|||
Password string |
|||
DBName string |
|||
} |
|||
Redis struct { |
|||
Host string |
|||
Port int |
|||
Pass string |
|||
TkDB int |
|||
CasheDb int |
|||
} |
|||
Auth struct { |
|||
AccessSecret string |
|||
AccessExpire int64 |
|||
} |
|||
TkStore bool |
|||
} |
@ -0,0 +1,21 @@ |
|||
package health |
|||
|
|||
import ( |
|||
"net/http" |
|||
|
|||
"backend/usercenter/api/internal/logic/health" |
|||
"backend/usercenter/api/internal/svc" |
|||
"github.com/zeromicro/go-zero/rest/httpx" |
|||
) |
|||
|
|||
func UsercenterPingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
l := health.NewUsercenterPingLogic(r.Context(), svcCtx) |
|||
resp, err := l.UsercenterPing() |
|||
if err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
} else { |
|||
httpx.OkJsonCtx(r.Context(), w, resp) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,49 @@ |
|||
// Code generated by goctl. DO NOT EDIT.
|
|||
// goctl 1.8.4
|
|||
|
|||
package handler |
|||
|
|||
import ( |
|||
"net/http" |
|||
|
|||
health "backend/usercenter/api/internal/handler/health" |
|||
user "backend/usercenter/api/internal/handler/user" |
|||
"backend/usercenter/api/internal/svc" |
|||
|
|||
"github.com/zeromicro/go-zero/rest" |
|||
) |
|||
|
|||
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) { |
|||
server.AddRoutes( |
|||
[]rest.Route{ |
|||
{ |
|||
Method: http.MethodGet, |
|||
Path: "/ping", |
|||
Handler: health.UsercenterPingHandler(serverCtx), |
|||
}, |
|||
}, |
|||
) |
|||
|
|||
server.AddRoutes( |
|||
[]rest.Route{ |
|||
{ |
|||
// 用户登录
|
|||
Method: http.MethodPost, |
|||
Path: "/login", |
|||
Handler: user.UserLoginHandler(serverCtx), |
|||
}, |
|||
{ |
|||
// 用户登出
|
|||
Method: http.MethodPost, |
|||
Path: "/logout", |
|||
Handler: user.UserLogoutHandler(serverCtx), |
|||
}, |
|||
{ |
|||
// 用户注册
|
|||
Method: http.MethodPost, |
|||
Path: "/register", |
|||
Handler: user.UserRegisterHandler(serverCtx), |
|||
}, |
|||
}, |
|||
) |
|||
} |
@ -0,0 +1,29 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"net/http" |
|||
|
|||
"backend/usercenter/api/internal/logic/user" |
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
"github.com/zeromicro/go-zero/rest/httpx" |
|||
) |
|||
|
|||
// 用户登录
|
|||
func UserLoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
var req types.LoginRequest |
|||
if err := httpx.Parse(r, &req); err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
return |
|||
} |
|||
|
|||
l := user.NewUserLoginLogic(r.Context(), svcCtx) |
|||
resp, err := l.UserLogin(&req) |
|||
if err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
} else { |
|||
httpx.OkJsonCtx(r.Context(), w, resp) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,38 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"context" |
|||
"net/http" |
|||
|
|||
"backend/usercenter/api/internal/logic/user" |
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
|
|||
"github.com/zeromicro/go-zero/rest/httpx" |
|||
) |
|||
|
|||
// 用户登出
|
|||
func UserLogoutHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
var req types.LogoutRequest |
|||
if err := httpx.Parse(r, &req); err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
return |
|||
} |
|||
// 获取请求头里的token
|
|||
token := r.Header.Get("Authorization") |
|||
// 如果有token, 使用context传值到logic中
|
|||
if token != "" { |
|||
ctx := context.WithValue(r.Context(), "token", token) |
|||
r = r.WithContext(ctx) |
|||
} |
|||
|
|||
l := user.NewUserLogoutLogic(r.Context(), svcCtx) |
|||
resp, err := l.UserLogout(&req) |
|||
if err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
} else { |
|||
httpx.OkJsonCtx(r.Context(), w, resp) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,28 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"net/http" |
|||
|
|||
"backend/usercenter/api/internal/logic/user" |
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
"github.com/zeromicro/go-zero/rest/httpx" |
|||
) |
|||
|
|||
func UserRegisterHandler(svcCtx *svc.ServiceContext) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
var req types.RegisterRequest |
|||
if err := httpx.Parse(r, &req); err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
return |
|||
} |
|||
|
|||
l := user.NewUserRegisterLogic(r.Context(), svcCtx) |
|||
resp, err := l.UserRegister(&req) |
|||
if err != nil { |
|||
httpx.ErrorCtx(r.Context(), w, err) |
|||
} else { |
|||
httpx.OkJsonCtx(r.Context(), w, resp) |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,34 @@ |
|||
package health |
|||
|
|||
import ( |
|||
"context" |
|||
|
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
// github.com/spf13/cast // 类型转换
|
|||
// github.com/golang-module/carbon/v2 // 日期时间处理
|
|||
// github.com/jinzhu/copier/v2 // 结构体复制
|
|||
// orm 目录下的 orm 包
|
|||
) |
|||
|
|||
type UsercenterPingLogic struct { |
|||
logx.Logger |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
} |
|||
|
|||
func NewUsercenterPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UsercenterPingLogic { |
|||
return &UsercenterPingLogic{ |
|||
Logger: logx.WithContext(ctx), |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
} |
|||
} |
|||
|
|||
func (l *UsercenterPingLogic) UsercenterPing() (resp *types.CommonResponse, err error) { |
|||
// todo: add your logic here and delete this line
|
|||
|
|||
return |
|||
} |
@ -0,0 +1,93 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"context" |
|||
|
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
"backend/usercenter/orm" |
|||
"backend/utils" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
"gorm.io/gorm" |
|||
// github.com/spf13/cast // 类型转换
|
|||
// github.com/golang-module/carbon/v2 // 日期时间处理
|
|||
// github.com/jinzhu/copier/v2 // 结构体复制
|
|||
// orm 目录下的 orm 包
|
|||
) |
|||
|
|||
type UserLoginLogic struct { |
|||
logx.Logger |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
} |
|||
|
|||
// 用户登录
|
|||
func NewUserLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UserLoginLogic { |
|||
return &UserLoginLogic{ |
|||
Logger: logx.WithContext(ctx), |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
} |
|||
} |
|||
|
|||
func (l *UserLoginLogic) UserLogin(req *types.LoginRequest) (resp *types.UsersResponse, err error) { |
|||
// 查找用户名或者手机号
|
|||
user := new(orm.User) |
|||
result := l.svcCtx.Db.Where("name = ? or phone = ?", req.Identity, req.Identity).First(user) |
|||
if result.Error != nil && result.Error != gorm.ErrRecordNotFound { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: result.Error.Error(), |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
if result.Error == gorm.ErrRecordNotFound { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "用户名或者手机号不存在", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 检查密码
|
|||
password := req.Password |
|||
if password == "" { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "密码不能为空", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 检查密码是否正确
|
|||
if !utils.CheckPassword(user.Password, req.Password) { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "密码错误", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 生成token
|
|||
jwtUtil := utils.NewJWTUtil(l.svcCtx.Config.Auth.AccessSecret, l.svcCtx.Config.Auth.AccessExpire, l.svcCtx.RedisClient, l.svcCtx.Config.TkStore) |
|||
token, err := jwtUtil.GenerateToken(l.ctx, int64(user.ID), user.Name) |
|||
if err != nil { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: err.Error(), |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
return &types.UsersResponse{ |
|||
Success: true, |
|||
Message: "登录成功", |
|||
Data: token, |
|||
Code: 200, |
|||
}, nil |
|||
} |
@ -0,0 +1,85 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
"context" |
|||
"fmt" |
|||
"regexp" |
|||
"strings" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
// github.com/spf13/cast // 类型转换
|
|||
// github.com/golang-module/carbon/v2 // 日期时间处理
|
|||
// github.com/jinzhu/copier/v2 // 结构体复制
|
|||
// orm 目录下的 orm 包
|
|||
) |
|||
|
|||
type UserLogoutLogic struct { |
|||
logx.Logger |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
} |
|||
|
|||
// 用户登出
|
|||
func NewUserLogoutLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UserLogoutLogic { |
|||
return &UserLogoutLogic{ |
|||
Logger: logx.WithContext(ctx), |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
} |
|||
} |
|||
|
|||
func (l *UserLogoutLogic) UserLogout(req *types.LogoutRequest) (resp *types.UsersResponse, err error) { |
|||
// 获取请求头里的token
|
|||
// 从 context 中获取请求头里的 token
|
|||
token := l.ctx.Value("token").(string) |
|||
fmt.Println(token) |
|||
if token == "" { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "未获取到token", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
re := regexp.MustCompile(`(?i)^bearer\s*`) |
|||
token = re.ReplaceAllString(strings.TrimSpace(token), "") |
|||
fmt.Println(token) |
|||
// 查找token是否存在
|
|||
exists, err := l.svcCtx.RedisClient.Exists(l.ctx, token).Result() |
|||
if err != nil { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "登出失败", |
|||
Data: err.Error(), |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
if exists == 0 { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "token不存在", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 删除token
|
|||
err = l.svcCtx.RedisClient.Del(l.ctx, token).Err() |
|||
if err != nil { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "登出失败", |
|||
Data: err.Error(), |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
return &types.UsersResponse{ |
|||
Success: true, |
|||
Message: "登出成功", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
@ -0,0 +1,147 @@ |
|||
package user |
|||
|
|||
import ( |
|||
"context" |
|||
"regexp" |
|||
|
|||
"backend/usercenter/api/internal/svc" |
|||
"backend/usercenter/api/internal/types" |
|||
"backend/usercenter/orm" |
|||
"backend/utils" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
"gorm.io/gorm" |
|||
// github.com/spf13/cast // 类型转换
|
|||
// github.com/golang-module/carbon/v2 // 日期时间处理
|
|||
// github.com/jinzhu/copier/v2 // 结构体复制
|
|||
// orm 目录下的 orm 包
|
|||
) |
|||
|
|||
type UserRegisterLogic struct { |
|||
logx.Logger |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
} |
|||
|
|||
func NewUserRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UserRegisterLogic { |
|||
return &UserRegisterLogic{ |
|||
Logger: logx.WithContext(ctx), |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
} |
|||
} |
|||
|
|||
func (l *UserRegisterLogic) UserRegister(req *types.RegisterRequest) (resp *types.UsersResponse, err error) { |
|||
// 1. 检查用户名是否符合要求
|
|||
if len(req.Username) < 4 || len(req.Username) > 16 { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "用户名长度不能小于4位或大于16位", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 1.1 正则检查用户名是否符合要求
|
|||
usernameRegex := regexp.MustCompile(`^[a-zA-Z0-9]+$`) |
|||
if !usernameRegex.MatchString(req.Username) { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "用户名只能包含字母和数字", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 1. 检查用户名,手机号是否存在
|
|||
user := new(orm.User) |
|||
result := l.svcCtx.Db.Where("name = ? or phone = ?", req.Username, req.Phone).First(user) |
|||
if result.Error != nil && result.Error != gorm.ErrRecordNotFound { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: result.Error.Error(), |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
if result.RowsAffected > 0 { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "用户名或手机号已存在", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 2. 检查密码是否符合要求
|
|||
if len(req.Password) < 6 || len(req.Password) > 16 { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "密码长度不能小于6位或大于16位", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 2.1 检查手机号是否符合要求
|
|||
if len(req.Phone) != 11 { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "手机号长度不正确", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
// 2.2 正则检查手机号是否符合要求
|
|||
phoneRegex := regexp.MustCompile(`^1[3-9]\d{9}$`) |
|||
if !phoneRegex.MatchString(req.Phone) { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: "手机号格式不正确", |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 3. 加密密码,使用utils.Bcrypt.GenerateFromPassword
|
|||
hashedPassword, err := utils.HashPassword(req.Password) |
|||
if err != nil { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: err.Error(), |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
user.Password = string(hashedPassword) |
|||
// 如果其他字段有值,则赋值
|
|||
if req.Email != "" { |
|||
user.Email = req.Email |
|||
} |
|||
|
|||
// 4. 创建用户
|
|||
user.Name = req.Username |
|||
user.Password = hashedPassword |
|||
user.Phone = req.Phone |
|||
|
|||
// 4. 创建用户
|
|||
result = l.svcCtx.Db.Create(user) |
|||
if result.Error != nil { |
|||
return &types.UsersResponse{ |
|||
Success: false, |
|||
Message: result.Error.Error(), |
|||
Data: nil, |
|||
Code: 200, |
|||
}, nil |
|||
} |
|||
|
|||
// 5. 返回用户信息
|
|||
resp = &types.UsersResponse{ |
|||
Success: true, |
|||
Message: "注册成功", |
|||
Data: user.ID, |
|||
Code: 200, |
|||
} |
|||
|
|||
return |
|||
} |
@ -0,0 +1,33 @@ |
|||
package svc |
|||
|
|||
import ( |
|||
"backend/usercenter/api/internal/config" |
|||
"fmt" |
|||
|
|||
"github.com/redis/go-redis/v9" |
|||
"gorm.io/driver/mysql" |
|||
"gorm.io/gorm" |
|||
) |
|||
|
|||
type ServiceContext struct { |
|||
Config config.Config |
|||
Db *gorm.DB |
|||
RedisClient *redis.Client |
|||
} |
|||
|
|||
func NewServiceContext(c config.Config) *ServiceContext { |
|||
dns := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local", c.MySQL.User, c.MySQL.Password, c.MySQL.Host, c.MySQL.Port, c.MySQL.DBName) |
|||
db, err := gorm.Open(mysql.Open(dns), &gorm.Config{}) |
|||
if err != nil { |
|||
panic(err) |
|||
} |
|||
return &ServiceContext{ |
|||
Config: c, |
|||
Db: db, |
|||
RedisClient: redis.NewClient(&redis.Options{ |
|||
Addr: fmt.Sprintf("%s:%d", c.Redis.Host, c.Redis.Port), |
|||
Password: c.Redis.Pass, |
|||
DB: c.Redis.TkDB, |
|||
}), |
|||
} |
|||
} |
@ -0,0 +1,38 @@ |
|||
// Code generated by goctl. DO NOT EDIT.
|
|||
// goctl 1.8.4
|
|||
|
|||
package types |
|||
|
|||
type CommonResponse struct { |
|||
Code int `json:"code"` |
|||
Message string `json:"message"` |
|||
Data interface{} `json:"data"` |
|||
Success bool `json:"success"` |
|||
} |
|||
|
|||
type LoginRequest struct { |
|||
Identity string `json:"identity"` |
|||
Password string `json:"password"` |
|||
} |
|||
|
|||
type LogoutRequest struct { |
|||
Token string `json:"token"` |
|||
} |
|||
|
|||
type RegisterRequest struct { |
|||
Username string `json:"username"` |
|||
Password string `json:"password"` |
|||
Email string `json:"email,optional"` |
|||
Phone string `json:"phone"` |
|||
Nickname string `json:"nickname,optional"` |
|||
Avatar string `json:"avatar,optional"` |
|||
Role string `json:"role,optional"` |
|||
Status string `json:"status,optional"` |
|||
} |
|||
|
|||
type UsersResponse struct { |
|||
Code int `json:"code"` |
|||
Message string `json:"message"` |
|||
Data interface{} `json:"data"` |
|||
Success bool `json:"success"` |
|||
} |
@ -0,0 +1,23 @@ |
|||
syntax = "v1" |
|||
|
|||
// 用户管理 |
|||
import "apis/user.api" |
|||
|
|||
// 公共响应 |
|||
type CommonResponse { |
|||
Code int `json:"code"` |
|||
Message string `json:"message"` |
|||
Data interface{} `json:"data"` |
|||
Success bool `json:"success"` |
|||
} |
|||
|
|||
@server ( |
|||
// jwt: Auth |
|||
// middleware: CheckPermission |
|||
group: health |
|||
) |
|||
service usercenter-api { |
|||
@handler UsercenterPingHandler |
|||
get /ping returns (CommonResponse) |
|||
} |
|||
|
@ -0,0 +1,31 @@ |
|||
package main |
|||
|
|||
import ( |
|||
"flag" |
|||
"fmt" |
|||
|
|||
"backend/usercenter/api/internal/config" |
|||
"backend/usercenter/api/internal/handler" |
|||
"backend/usercenter/api/internal/svc" |
|||
|
|||
"github.com/zeromicro/go-zero/core/conf" |
|||
"github.com/zeromicro/go-zero/rest" |
|||
) |
|||
|
|||
var configFile = flag.String("f", "etc/usercenter-api.yaml", "the config file") |
|||
|
|||
func main() { |
|||
flag.Parse() |
|||
|
|||
var c config.Config |
|||
conf.MustLoad(*configFile, &c) |
|||
|
|||
server := rest.MustNewServer(c.RestConf) |
|||
defer server.Stop() |
|||
|
|||
ctx := svc.NewServiceContext(c) |
|||
handler.RegisterHandlers(server, ctx) |
|||
|
|||
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port) |
|||
server.Start() |
|||
} |
@ -1,7 +0,0 @@ |
|||
package main |
|||
|
|||
import "backend/usercenter/orm" |
|||
|
|||
func main() { |
|||
orm.AutoMigrate() |
|||
} |
@ -1,30 +0,0 @@ |
|||
package logic |
|||
|
|||
import ( |
|||
"context" |
|||
|
|||
"backend/usercenter/internal/svc" |
|||
"backend/usercenter/pb/usercenter" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
) |
|||
|
|||
type LoginLogic struct { |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
logx.Logger |
|||
} |
|||
|
|||
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { |
|||
return &LoginLogic{ |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
Logger: logx.WithContext(ctx), |
|||
} |
|||
} |
|||
|
|||
func (l *LoginLogic) Login(in *usercenter.LoginRequest) (*usercenter.LoginResponse, error) { |
|||
// todo: add your logic here and delete this line
|
|||
|
|||
return &usercenter.LoginResponse{}, nil |
|||
} |
@ -1,30 +0,0 @@ |
|||
package logic |
|||
|
|||
import ( |
|||
"context" |
|||
|
|||
"backend/usercenter/internal/svc" |
|||
"backend/usercenter/pb/usercenter" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
) |
|||
|
|||
type RegisterLogic struct { |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
logx.Logger |
|||
} |
|||
|
|||
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { |
|||
return &RegisterLogic{ |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
Logger: logx.WithContext(ctx), |
|||
} |
|||
} |
|||
|
|||
func (l *RegisterLogic) Register(in *usercenter.RegisterRequest) (*usercenter.RegisterResponse, error) { |
|||
// todo: add your logic here and delete this line
|
|||
|
|||
return &usercenter.RegisterResponse{}, nil |
|||
} |
@ -1,13 +0,0 @@ |
|||
package svc |
|||
|
|||
import "backend/usercenter/internal/config" |
|||
|
|||
type ServiceContext struct { |
|||
Config config.Config |
|||
} |
|||
|
|||
func NewServiceContext(c config.Config) *ServiceContext { |
|||
return &ServiceContext{ |
|||
Config: c, |
|||
} |
|||
} |
Binary file not shown.
@ -1,2 +0,0 @@ |
|||
file appendonly.aof.1.base.rdb seq 1 type b |
|||
file appendonly.aof.1.incr.aof seq 1 type i startoffset 0 endoffset 0 |
Binary file not shown.
@ -0,0 +1,61 @@ |
|||
package logic |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
|
|||
"backend/usercenter/orm" |
|||
"backend/usercenter/rpc/internal/svc" |
|||
"backend/usercenter/rpc/pb/usercenter" |
|||
"backend/utils" |
|||
|
|||
"github.com/spf13/cast" |
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
"gorm.io/gorm" |
|||
) |
|||
|
|||
type LoginLogic struct { |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
logx.Logger |
|||
} |
|||
|
|||
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic { |
|||
return &LoginLogic{ |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
Logger: logx.WithContext(ctx), |
|||
} |
|||
} |
|||
|
|||
func (l *LoginLogic) Login(in *usercenter.LoginRequest) (*usercenter.LoginResponse, error) { |
|||
// 1. 验证用户是否存在,支持用户名和手机号登录
|
|||
user := new(orm.User) |
|||
result := l.svcCtx.UsercenterDB.Where("name = ? OR phone = ?", in.Identity, in.Identity).First(&user) |
|||
if result.Error != nil && result.Error != gorm.ErrRecordNotFound { |
|||
return nil, result.Error |
|||
} |
|||
if result.RowsAffected == 0 { |
|||
return nil, errors.New("用户不存在") |
|||
} |
|||
|
|||
// 2. 验证密码,使用 utils.bcrypt.CheckPassword 工具类进行比较
|
|||
if !utils.CheckPassword(user.Password, in.Password) { |
|||
return nil, errors.New("密码错误") |
|||
} |
|||
|
|||
// 3. 生成token,使用 utils.GenerateToken 工具类生成
|
|||
jwtUtil := utils.NewJWTUtil(l.svcCtx.Config.AuthRpc.AccessSecret, l.svcCtx.Config.AuthRpc.AccessExpire, l.svcCtx.RedisClient, l.svcCtx.Config.TkStore) |
|||
token, err := jwtUtil.GenerateToken(l.ctx, cast.ToInt64(user.ID), user.Name) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
// 4. 返回token
|
|||
|
|||
return &usercenter.LoginResponse{ |
|||
UserId: cast.ToString(user.ID), |
|||
Token: token, |
|||
Message: "登录成功", |
|||
}, nil |
|||
} |
@ -0,0 +1,86 @@ |
|||
package logic |
|||
|
|||
import ( |
|||
"backend/usercenter/orm" |
|||
"backend/usercenter/rpc/internal/svc" |
|||
"backend/usercenter/rpc/pb/usercenter" |
|||
"backend/utils" |
|||
"context" |
|||
"errors" |
|||
"regexp" |
|||
"strconv" |
|||
|
|||
"github.com/zeromicro/go-zero/core/logx" |
|||
"gorm.io/gorm" |
|||
) |
|||
|
|||
type RegisterLogic struct { |
|||
ctx context.Context |
|||
svcCtx *svc.ServiceContext |
|||
logx.Logger |
|||
} |
|||
|
|||
func NewRegisterLogic(ctx context.Context, svcCtx *svc.ServiceContext) *RegisterLogic { |
|||
return &RegisterLogic{ |
|||
ctx: ctx, |
|||
svcCtx: svcCtx, |
|||
Logger: logx.WithContext(ctx), |
|||
} |
|||
} |
|||
|
|||
func (l *RegisterLogic) Register(in *usercenter.RegisterRequest) (*usercenter.RegisterResponse, error) { |
|||
// 1. 检查用户是否存在
|
|||
user := new(orm.User) |
|||
res := l.svcCtx.UsercenterDB.First(user, "name = ?", in.Username) |
|||
// 有错误返回错误,找到数据返回找到
|
|||
if res.Error != nil && res.Error != gorm.ErrRecordNotFound { |
|||
return nil, errors.New(res.Error.Error()) |
|||
} |
|||
if res.RowsAffected > 0 { |
|||
return nil, errors.New("用户已存在") |
|||
} |
|||
|
|||
// 2. 检查手机号是否存在
|
|||
res = l.svcCtx.UsercenterDB.First(user, "phone = ?", in.Mobile) |
|||
if res.Error != nil && res.Error != gorm.ErrRecordNotFound { |
|||
return nil, errors.New(res.Error.Error()) |
|||
} |
|||
if res.RowsAffected > 0 { |
|||
return nil, errors.New("手机号已存在") |
|||
} |
|||
// 3. 检查用户名是否符合要求
|
|||
if len(in.Username) < 4 || len(in.Username) > 16 { |
|||
return nil, errors.New("用户名长度必须在4-16之间") |
|||
} |
|||
// 4. 检查密码是否符合要求
|
|||
if len(in.Password) < 6 || len(in.Password) > 16 { |
|||
return nil, errors.New("密码长度必须在6-16之间") |
|||
} |
|||
// 5. 检查手机号是否符合要求
|
|||
if len(in.Mobile) != 11 { |
|||
return nil, errors.New("手机号长度必须为11位") |
|||
} |
|||
// 6. 正则检查手机号
|
|||
if !regexp.MustCompile(`^1[3-9]\d{9}$`).MatchString(in.Mobile) { |
|||
return nil, errors.New("手机号格式不正确") |
|||
} |
|||
// 检查完成,创建用户
|
|||
// 密码加盐, utils.bcrypt.HashPassword
|
|||
salt, err := utils.HashPassword(in.Password) |
|||
if err != nil { |
|||
return nil, errors.New(err.Error()) |
|||
} |
|||
user.Name = in.Username |
|||
user.Phone = in.Mobile |
|||
user.Password = salt |
|||
|
|||
res = l.svcCtx.UsercenterDB.Create(user) |
|||
if res.Error != nil { |
|||
return nil, errors.New(res.Error.Error()) |
|||
} |
|||
// 创建成功,返回成功
|
|||
return &usercenter.RegisterResponse{ |
|||
UserId: strconv.FormatUint(uint64(user.ID), 10), |
|||
Message: "注册成功", |
|||
}, nil |
|||
} |
@ -0,0 +1,39 @@ |
|||
package svc |
|||
|
|||
import ( |
|||
// 引入配置文件
|
|||
"backend/usercenter/rpc/internal/config" |
|||
// 引入GORM
|
|||
"gorm.io/driver/mysql" |
|||
"gorm.io/gorm" |
|||
|
|||
// 引入redis
|
|||
"fmt" |
|||
|
|||
"github.com/redis/go-redis/v9" |
|||
) |
|||
|
|||
// 服务上下文
|
|||
type ServiceContext struct { |
|||
Config config.Config |
|||
UsercenterDB *gorm.DB |
|||
RedisClient *redis.Client // 注意类型
|
|||
} |
|||
|
|||
func NewServiceContext(c config.Config) *ServiceContext { |
|||
usercenterDB, err := gorm.Open(mysql.Open(c.MySQL.DSN()), &gorm.Config{}) |
|||
if err != nil { |
|||
panic(err) |
|||
} |
|||
redisClient := redis.NewClient(&redis.Options{ |
|||
Addr: fmt.Sprintf("%s:%d", c.RedisGo.Host, c.RedisGo.Port), |
|||
Password: c.RedisGo.Pass, // 或 Password,取决于你的 config.go/yaml
|
|||
DB: c.RedisGo.DB, |
|||
}) |
|||
|
|||
return &ServiceContext{ |
|||
Config: c, |
|||
UsercenterDB: usercenterDB, |
|||
RedisClient: redisClient, |
|||
} |
|||
} |
@ -0,0 +1,362 @@ |
|||
# JWT 工具使用说明 |
|||
|
|||
这是一个基于 Go-Zero 框架的 JWT 工具,支持 token 的生成、验证、刷新和管理功能,并将 token 存储在 Redis 中。 |
|||
|
|||
## 功能特性 |
|||
|
|||
- ✅ JWT token 生成和验证 |
|||
- ✅ Redis 存储管理 |
|||
- ✅ Token 刷新机制 |
|||
- ✅ 用户多 token 管理 |
|||
- ✅ 批量 token 操作 |
|||
- ✅ 完整的错误处理 |
|||
- ✅ 性能优化 |
|||
- ✅ 完整的测试覆盖 |
|||
|
|||
## 依赖说明 |
|||
|
|||
```bash |
|||
# 主要依赖 |
|||
go get github.com/golang-jwt/jwt/v5 |
|||
go get github.com/redis/go-redis/v9 |
|||
|
|||
# 测试依赖 |
|||
go get github.com/stretchr/testify |
|||
``` |
|||
|
|||
## 配置说明 |
|||
|
|||
### 1. 配置文件 (usercenter.yaml) |
|||
|
|||
```yaml |
|||
Auth: |
|||
AccessSecret: "your-secret-key" |
|||
AccessExpire: 604800 # 7天过期时间(秒) |
|||
``` |
|||
|
|||
### 2. 配置结构体 (config.go) |
|||
|
|||
```go |
|||
type Auth struct { |
|||
AccessSecret string // JWT 密钥 |
|||
AccessExpire int64 // JWT 过期时间(秒) |
|||
} |
|||
``` |
|||
|
|||
## 基本使用方法 |
|||
|
|||
### 1. 初始化 JWT 工具 |
|||
|
|||
```go |
|||
import ( |
|||
"backend/utils" |
|||
"github.com/redis/go-redis/v9" |
|||
) |
|||
|
|||
// 创建 Redis 客户端 |
|||
redisClient := redis.NewClient(&redis.Options{ |
|||
Addr: "localhost:6379", |
|||
Password: "", |
|||
DB: 0, |
|||
}) |
|||
|
|||
// 创建 JWT 工具实例 |
|||
jwtUtil := utils.NewJWTUtil( |
|||
"your-secret-key", // 访问密钥 |
|||
7*24*60*60, // 过期时间(秒) |
|||
redisClient, // Redis 客户端 |
|||
) |
|||
``` |
|||
|
|||
### 2. 生成 Token |
|||
|
|||
```go |
|||
ctx := context.Background() |
|||
userID := int64(123) |
|||
username := "john_doe" |
|||
|
|||
token, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
if err != nil { |
|||
log.Printf("生成 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Printf("生成的 token: %s\n", token) |
|||
``` |
|||
|
|||
### 3. 验证 Token |
|||
|
|||
```go |
|||
claims, err := jwtUtil.ValidateToken(ctx, token) |
|||
if err != nil { |
|||
log.Printf("验证 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Printf("用户ID: %d, 用户名: %s\n", claims.UserID, claims.Username) |
|||
``` |
|||
|
|||
### 4. 刷新 Token |
|||
|
|||
```go |
|||
newToken, err := jwtUtil.RefreshToken(ctx, oldToken) |
|||
if err != nil { |
|||
log.Printf("刷新 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Printf("新的 token: %s\n", newToken) |
|||
``` |
|||
|
|||
### 5. 删除 Token (登出) |
|||
|
|||
```go |
|||
err := jwtUtil.DeleteToken(ctx, token) |
|||
if err != nil { |
|||
log.Printf("删除 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Println("登出成功") |
|||
``` |
|||
|
|||
## 高级功能 |
|||
|
|||
### 1. 管理用户多个 Token |
|||
|
|||
```go |
|||
// 获取用户所有 token |
|||
tokens, err := jwtUtil.GetUserTokens(ctx, userID) |
|||
if err != nil { |
|||
log.Printf("获取用户 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Printf("用户拥有 %d 个 token\n", len(tokens)) |
|||
|
|||
// 删除用户所有 token (强制登出) |
|||
err = jwtUtil.DeleteAllUserTokens(ctx, userID) |
|||
if err != nil { |
|||
log.Printf("删除用户所有 token 失败: %v", err) |
|||
return |
|||
} |
|||
``` |
|||
|
|||
### 2. 检查 Token 是否存在 |
|||
|
|||
```go |
|||
exists, err := jwtUtil.TokenExists(ctx, token) |
|||
if err != nil { |
|||
log.Printf("检查 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
if exists { |
|||
fmt.Println("Token 有效") |
|||
} else { |
|||
fmt.Println("Token 不存在或已过期") |
|||
} |
|||
``` |
|||
|
|||
### 3. 解析 Token (不验证签名) |
|||
|
|||
```go |
|||
claims, err := jwtUtil.ParseTokenUnverified(token) |
|||
if err != nil { |
|||
log.Printf("解析 token 失败: %v", err) |
|||
return |
|||
} |
|||
|
|||
fmt.Printf("Token 中的用户ID: %d\n", claims.UserID) |
|||
``` |
|||
|
|||
## 在 Go-Zero 服务中集成 |
|||
|
|||
### 1. 更新服务上下文 |
|||
|
|||
```go |
|||
// internal/svc/servicecontext.go |
|||
type ServiceContext struct { |
|||
Config config.Config |
|||
JWTUtil *utils.JWTUtil |
|||
// ... 其他字段 |
|||
} |
|||
|
|||
func NewServiceContext(c config.Config) *ServiceContext { |
|||
// 创建 Redis 客户端 |
|||
redisClient := redis.NewClient(&redis.Options{ |
|||
Addr: "localhost:6379", |
|||
DB: 0, |
|||
}) |
|||
|
|||
// 创建 JWT 工具 |
|||
jwtUtil := utils.NewJWTUtil( |
|||
c.Auth.AccessSecret, |
|||
c.Auth.AccessExpire, |
|||
redisClient, |
|||
) |
|||
|
|||
return &ServiceContext{ |
|||
Config: c, |
|||
JWTUtil: jwtUtil, |
|||
} |
|||
} |
|||
``` |
|||
|
|||
### 2. 在登录逻辑中使用 |
|||
|
|||
```go |
|||
// internal/logic/loginlogic.go |
|||
func (l *LoginLogic) Login(in *pb.LoginRequest) (*pb.LoginResponse, error) { |
|||
// 验证用户凭证... |
|||
|
|||
// 生成 JWT token |
|||
token, err := l.svcCtx.JWTUtil.GenerateToken( |
|||
l.ctx, |
|||
user.ID, |
|||
user.Username, |
|||
) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
|
|||
return &pb.LoginResponse{ |
|||
Token: token, |
|||
User: user, |
|||
}, nil |
|||
} |
|||
``` |
|||
|
|||
### 3. 在需要认证的逻辑中使用 |
|||
|
|||
```go |
|||
// internal/logic/getprofilelogic.go |
|||
func (l *GetProfileLogic) GetProfile(in *pb.GetProfileRequest) (*pb.GetProfileResponse, error) { |
|||
// 验证 token |
|||
claims, err := l.svcCtx.JWTUtil.ValidateToken(l.ctx, in.Token) |
|||
if err != nil { |
|||
return nil, errors.New("无效的 token") |
|||
} |
|||
|
|||
// 使用 claims 中的用户信息 |
|||
userID := claims.UserID |
|||
|
|||
// 获取用户信息... |
|||
|
|||
return &pb.GetProfileResponse{ |
|||
User: user, |
|||
}, nil |
|||
} |
|||
``` |
|||
|
|||
## 中间件示例 |
|||
|
|||
```go |
|||
// JWT 认证中间件 |
|||
func JWTAuthMiddleware(jwtUtil *utils.JWTUtil) func(next http.HandlerFunc) http.HandlerFunc { |
|||
return func(next http.HandlerFunc) http.HandlerFunc { |
|||
return func(w http.ResponseWriter, r *http.Request) { |
|||
// 从 Header 中获取 token |
|||
tokenString := r.Header.Get("Authorization") |
|||
if tokenString == "" { |
|||
http.Error(w, "缺少 token", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
// 去掉 "Bearer " 前缀 |
|||
if strings.HasPrefix(tokenString, "Bearer ") { |
|||
tokenString = tokenString[7:] |
|||
} |
|||
|
|||
// 验证 token |
|||
claims, err := jwtUtil.ValidateToken(r.Context(), tokenString) |
|||
if err != nil { |
|||
http.Error(w, "无效的 token", http.StatusUnauthorized) |
|||
return |
|||
} |
|||
|
|||
// 将用户信息存储到上下文中 |
|||
ctx := context.WithValue(r.Context(), "userID", claims.UserID) |
|||
ctx = context.WithValue(ctx, "username", claims.Username) |
|||
|
|||
// 继续处理请求 |
|||
next(w, r.WithContext(ctx)) |
|||
} |
|||
} |
|||
} |
|||
``` |
|||
|
|||
## 错误处理 |
|||
|
|||
JWT 工具返回的错误信息包括: |
|||
|
|||
- `生成 token 失败`: token 生成过程中的错误 |
|||
- `存储 token 到 Redis 失败`: Redis 存储错误 |
|||
- `解析 token 失败`: token 格式错误或签名验证失败 |
|||
- `无效的 token`: token 无效或已过期 |
|||
- `token 已过期或不存在`: Redis 中不存在该 token |
|||
- `token 中的用户ID与 Redis 中的不匹配`: 数据不一致错误 |
|||
|
|||
## 性能优化建议 |
|||
|
|||
1. **Redis 连接池**: 使用 Redis 连接池避免频繁创建连接 |
|||
2. **Token 缓存**: 对于高频访问的 token,可以考虑内存缓存 |
|||
3. **批量操作**: 使用批量 Redis 操作提高性能 |
|||
4. **异步删除**: 过期 token 的清理可以异步进行 |
|||
|
|||
## 安全建议 |
|||
|
|||
1. **密钥管理**: 使用强密钥并定期轮换 |
|||
2. **HTTPS**: 始终在 HTTPS 环境中传输 token |
|||
3. **过期时间**: 设置合理的 token 过期时间 |
|||
4. **Redis 安全**: 确保 Redis 访问安全 |
|||
5. **日志记录**: 记录认证相关的操作日志 |
|||
|
|||
## 测试 |
|||
|
|||
运行单元测试: |
|||
|
|||
```bash |
|||
cd backend/utils |
|||
go test -v |
|||
``` |
|||
|
|||
运行基准测试: |
|||
|
|||
```bash |
|||
go test -bench=. |
|||
``` |
|||
|
|||
## 常见问题 |
|||
|
|||
### Q: 如何处理 token 过期? |
|||
|
|||
A: 使用 `RefreshToken` 方法刷新 token,或者重新登录获取新的 token。 |
|||
|
|||
### Q: 如何实现强制登出? |
|||
|
|||
A: 使用 `DeleteAllUserTokens` 方法删除用户的所有 token。 |
|||
|
|||
### Q: Redis 连接失败怎么处理? |
|||
|
|||
A: 检查 Redis 服务是否正常运行,并确保连接参数正确。 |
|||
|
|||
### Q: Token 验证失败的常见原因? |
|||
|
|||
A: |
|||
|
|||
- Token 格式错误 |
|||
- 签名密钥不匹配 |
|||
- Token 已过期 |
|||
- Redis 中不存在该 token |
|||
- 网络连接问题 |
|||
|
|||
## 更新日志 |
|||
|
|||
### v1.0.0 |
|||
|
|||
- 基础 JWT 功能实现 |
|||
- Redis 存储支持 |
|||
- Token 刷新机制 |
|||
- 用户多 token 管理 |
|||
- 完整的测试覆盖 |
@ -0,0 +1,119 @@ |
|||
// 密码加密
|
|||
package utils |
|||
|
|||
import ( |
|||
"errors" |
|||
|
|||
"golang.org/x/crypto/bcrypt" |
|||
) |
|||
|
|||
const ( |
|||
// DefaultCost 默认加密成本,平衡安全性和性能
|
|||
DefaultCost = bcrypt.DefaultCost // 通常是10
|
|||
// MinCost 最小加密成本
|
|||
MinCost = bcrypt.MinCost // 4
|
|||
// MaxCost 最大加密成本
|
|||
MaxCost = bcrypt.MaxCost // 31
|
|||
) |
|||
|
|||
var ( |
|||
// ErrPasswordTooLong 密码过长错误
|
|||
ErrPasswordTooLong = errors.New("password is too long") |
|||
// ErrPasswordEmpty 密码为空错误
|
|||
ErrPasswordEmpty = errors.New("password cannot be empty") |
|||
// ErrInvalidCost 加密成本无效错误
|
|||
ErrInvalidCost = errors.New("invalid cost value") |
|||
) |
|||
|
|||
// HashPassword 使用bcrypt对密码进行加密
|
|||
// password: 原始密码
|
|||
// 返回加密后的密码哈希值和错误信息
|
|||
func HashPassword(password string) (string, error) { |
|||
return HashPasswordWithCost(password, DefaultCost) |
|||
} |
|||
|
|||
// HashPasswordWithCost 使用指定成本对密码进行加密
|
|||
// password: 原始密码
|
|||
// cost: 加密成本 (4-31),成本越高越安全但速度越慢
|
|||
// 返回加密后的密码哈希值和错误信息
|
|||
func HashPasswordWithCost(password string, cost int) (string, error) { |
|||
// 验证输入
|
|||
if password == "" { |
|||
return "", ErrPasswordEmpty |
|||
} |
|||
|
|||
// bcrypt有72字节的限制
|
|||
if len(password) > 72 { |
|||
return "", ErrPasswordTooLong |
|||
} |
|||
|
|||
// 验证成本值
|
|||
if cost < MinCost || cost > MaxCost { |
|||
return "", ErrInvalidCost |
|||
} |
|||
|
|||
// 生成密码哈希
|
|||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), cost) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
|
|||
return string(hashedPassword), nil |
|||
} |
|||
|
|||
// CheckPassword 验证密码是否正确
|
|||
// hashedPassword: 存储的密码哈希值
|
|||
// password: 用户输入的原始密码
|
|||
// 返回验证结果
|
|||
func CheckPassword(hashedPassword, password string) bool { |
|||
// 验证输入
|
|||
if hashedPassword == "" || password == "" { |
|||
return false |
|||
} |
|||
|
|||
// 比较密码
|
|||
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) |
|||
return err == nil |
|||
} |
|||
|
|||
// CheckPasswordWithError 验证密码是否正确,返回详细错误信息
|
|||
// hashedPassword: 存储的密码哈希值
|
|||
// password: 用户输入的原始密码
|
|||
// 返回验证结果和错误信息
|
|||
func CheckPasswordWithError(hashedPassword, password string) error { |
|||
// 验证输入
|
|||
if hashedPassword == "" { |
|||
return errors.New("hashed password cannot be empty") |
|||
} |
|||
if password == "" { |
|||
return ErrPasswordEmpty |
|||
} |
|||
|
|||
// 比较密码
|
|||
return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) |
|||
} |
|||
|
|||
// GetCost 获取密码哈希的加密成本
|
|||
// hashedPassword: 密码哈希值
|
|||
// 返回加密成本和错误信息
|
|||
func GetCost(hashedPassword string) (int, error) { |
|||
if hashedPassword == "" { |
|||
return 0, errors.New("hashed password cannot be empty") |
|||
} |
|||
|
|||
cost, err := bcrypt.Cost([]byte(hashedPassword)) |
|||
return cost, err |
|||
} |
|||
|
|||
// NeedsRehash 检查密码是否需要重新哈希(当前成本低于推荐成本时)
|
|||
// hashedPassword: 密码哈希值
|
|||
// preferredCost: 推荐的加密成本
|
|||
// 返回是否需要重新哈希
|
|||
func NeedsRehash(hashedPassword string, preferredCost int) (bool, error) { |
|||
currentCost, err := GetCost(hashedPassword) |
|||
if err != nil { |
|||
return false, err |
|||
} |
|||
|
|||
return currentCost < preferredCost, nil |
|||
} |
@ -0,0 +1,166 @@ |
|||
package utils |
|||
|
|||
import ( |
|||
"testing" |
|||
) |
|||
|
|||
func TestHashPassword(t *testing.T) { |
|||
password := "testpassword123" |
|||
|
|||
// 测试基本加密功能
|
|||
hashedPassword, err := HashPassword(password) |
|||
if err != nil { |
|||
t.Fatalf("HashPassword failed: %v", err) |
|||
} |
|||
|
|||
if hashedPassword == "" { |
|||
t.Fatal("HashedPassword should not be empty") |
|||
} |
|||
|
|||
if hashedPassword == password { |
|||
t.Fatal("HashedPassword should not equal original password") |
|||
} |
|||
} |
|||
|
|||
func TestHashPasswordWithCost(t *testing.T) { |
|||
password := "testpassword123" |
|||
cost := 12 |
|||
|
|||
// 测试指定成本的加密
|
|||
hashedPassword, err := HashPasswordWithCost(password, cost) |
|||
if err != nil { |
|||
t.Fatalf("HashPasswordWithCost failed: %v", err) |
|||
} |
|||
|
|||
// 验证成本
|
|||
actualCost, err := GetCost(hashedPassword) |
|||
if err != nil { |
|||
t.Fatalf("GetCost failed: %v", err) |
|||
} |
|||
|
|||
if actualCost != cost { |
|||
t.Fatalf("Expected cost %d, got %d", cost, actualCost) |
|||
} |
|||
} |
|||
|
|||
func TestCheckPassword(t *testing.T) { |
|||
password := "testpassword123" |
|||
wrongPassword := "wrongpassword" |
|||
|
|||
// 加密密码
|
|||
hashedPassword, err := HashPassword(password) |
|||
if err != nil { |
|||
t.Fatalf("HashPassword failed: %v", err) |
|||
} |
|||
|
|||
// 测试正确密码验证
|
|||
if !CheckPassword(hashedPassword, password) { |
|||
t.Fatal("CheckPassword should return true for correct password") |
|||
} |
|||
|
|||
// 测试错误密码验证
|
|||
if CheckPassword(hashedPassword, wrongPassword) { |
|||
t.Fatal("CheckPassword should return false for wrong password") |
|||
} |
|||
} |
|||
|
|||
func TestCheckPasswordWithError(t *testing.T) { |
|||
password := "testpassword123" |
|||
wrongPassword := "wrongpassword" |
|||
|
|||
// 加密密码
|
|||
hashedPassword, err := HashPassword(password) |
|||
if err != nil { |
|||
t.Fatalf("HashPassword failed: %v", err) |
|||
} |
|||
|
|||
// 测试正确密码验证
|
|||
err = CheckPasswordWithError(hashedPassword, password) |
|||
if err != nil { |
|||
t.Fatalf("CheckPasswordWithError should return nil for correct password, got: %v", err) |
|||
} |
|||
|
|||
// 测试错误密码验证
|
|||
err = CheckPasswordWithError(hashedPassword, wrongPassword) |
|||
if err == nil { |
|||
t.Fatal("CheckPasswordWithError should return error for wrong password") |
|||
} |
|||
} |
|||
|
|||
func TestPasswordValidation(t *testing.T) { |
|||
// 测试空密码
|
|||
_, err := HashPassword("") |
|||
if err != ErrPasswordEmpty { |
|||
t.Fatalf("Expected ErrPasswordEmpty, got: %v", err) |
|||
} |
|||
|
|||
// 测试过长密码 (> 72 bytes)
|
|||
longPassword := make([]byte, 73) |
|||
for i := range longPassword { |
|||
longPassword[i] = 'a' |
|||
} |
|||
|
|||
_, err = HashPassword(string(longPassword)) |
|||
if err != ErrPasswordTooLong { |
|||
t.Fatalf("Expected ErrPasswordTooLong, got: %v", err) |
|||
} |
|||
|
|||
// 测试无效成本
|
|||
_, err = HashPasswordWithCost("test", 3) // 低于 MinCost
|
|||
if err != ErrInvalidCost { |
|||
t.Fatalf("Expected ErrInvalidCost for low cost, got: %v", err) |
|||
} |
|||
|
|||
_, err = HashPasswordWithCost("test", 32) // 高于 MaxCost
|
|||
if err != ErrInvalidCost { |
|||
t.Fatalf("Expected ErrInvalidCost for high cost, got: %v", err) |
|||
} |
|||
} |
|||
|
|||
func TestNeedsRehash(t *testing.T) { |
|||
password := "testpassword123" |
|||
|
|||
// 使用较低成本加密
|
|||
lowCostPassword, err := HashPasswordWithCost(password, 8) |
|||
if err != nil { |
|||
t.Fatalf("HashPasswordWithCost failed: %v", err) |
|||
} |
|||
|
|||
// 检查是否需要重新哈希
|
|||
needsRehash, err := NeedsRehash(lowCostPassword, 12) |
|||
if err != nil { |
|||
t.Fatalf("NeedsRehash failed: %v", err) |
|||
} |
|||
|
|||
if !needsRehash { |
|||
t.Fatal("Should need rehash when current cost is lower than preferred cost") |
|||
} |
|||
|
|||
// 使用相同成本检查
|
|||
needsRehash, err = NeedsRehash(lowCostPassword, 8) |
|||
if err != nil { |
|||
t.Fatalf("NeedsRehash failed: %v", err) |
|||
} |
|||
|
|||
if needsRehash { |
|||
t.Fatal("Should not need rehash when current cost equals preferred cost") |
|||
} |
|||
} |
|||
|
|||
func TestEmptyInputs(t *testing.T) { |
|||
// 测试空哈希密码
|
|||
if CheckPassword("", "password") { |
|||
t.Fatal("CheckPassword should return false for empty hash") |
|||
} |
|||
|
|||
// 测试空输入密码
|
|||
if CheckPassword("hash", "") { |
|||
t.Fatal("CheckPassword should return false for empty password") |
|||
} |
|||
|
|||
// 测试 GetCost 的空输入
|
|||
_, err := GetCost("") |
|||
if err == nil { |
|||
t.Fatal("GetCost should return error for empty hash") |
|||
} |
|||
} |
@ -0,0 +1,223 @@ |
|||
// 生成token,保存到redis,返回token,redis的key是token,value是user_id
|
|||
// 过期时间由etc下 yaml文件配置 Auth.AccessExpire确定
|
|||
// 秘钥由etc下 yaml文件配置 Auth.AccessSecret确定
|
|||
|
|||
package utils |
|||
|
|||
import ( |
|||
"context" |
|||
"errors" |
|||
"fmt" |
|||
"strconv" |
|||
"time" |
|||
|
|||
"github.com/golang-jwt/jwt/v5" |
|||
"github.com/google/uuid" |
|||
"github.com/redis/go-redis/v9" |
|||
) |
|||
|
|||
// JWT 自定义声明
|
|||
type JWTClaims struct { |
|||
UserID int64 `json:"user_id"` |
|||
Username string `json:"username"` |
|||
ExString string `json:"ex_string"` |
|||
jwt.RegisteredClaims |
|||
} |
|||
|
|||
// JWT 工具结构体
|
|||
type JWTUtil struct { |
|||
AccessSecret string |
|||
AccessExpire int64 |
|||
RedisClient *redis.Client |
|||
TkStore bool |
|||
} |
|||
|
|||
// 创建新的 JWT 工具实例
|
|||
func NewJWTUtil(accessSecret string, accessExpire int64, redisClient *redis.Client, tkStore bool) *JWTUtil { |
|||
return &JWTUtil{ |
|||
AccessSecret: accessSecret, |
|||
AccessExpire: accessExpire, |
|||
RedisClient: redisClient, |
|||
TkStore: tkStore, |
|||
} |
|||
} |
|||
|
|||
// 生成 JWT token
|
|||
func (j *JWTUtil) GenerateToken(ctx context.Context, userID int64, username string) (string, error) { |
|||
// 创建声明
|
|||
claims := &JWTClaims{ |
|||
UserID: userID, |
|||
Username: username, |
|||
RegisteredClaims: jwt.RegisteredClaims{ |
|||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Second * time.Duration(j.AccessExpire))), |
|||
IssuedAt: jwt.NewNumericDate(time.Now()), |
|||
NotBefore: jwt.NewNumericDate(time.Now()), |
|||
Issuer: "usercenter", |
|||
Subject: strconv.FormatInt(userID, 10), |
|||
}, |
|||
ExString: uuid.New().String(), |
|||
} |
|||
|
|||
// 使用 HS256 算法生成 token
|
|||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) |
|||
tokenString, err := token.SignedString([]byte(j.AccessSecret)) |
|||
if err != nil { |
|||
return "", fmt.Errorf("生成 token 失败: %w", err) |
|||
} |
|||
|
|||
// 将 token 存储到 Redis,key 是 token,value 是 user_id
|
|||
if j.TkStore { |
|||
err = j.RedisClient.Set(ctx, tokenString, userID, time.Duration(j.AccessExpire)*time.Second).Err() |
|||
if err != nil { |
|||
return "", fmt.Errorf("存储 token 到 Redis 失败: %w", err) |
|||
} |
|||
} |
|||
return tokenString, nil |
|||
} |
|||
|
|||
// 验证 JWT token
|
|||
func (j *JWTUtil) ValidateToken(ctx context.Context, tokenString string) (*JWTClaims, error) { |
|||
// 解析 token
|
|||
token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { |
|||
// 验证签名方法
|
|||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { |
|||
return nil, fmt.Errorf("意外的签名方法: %v", token.Header["alg"]) |
|||
} |
|||
return []byte(j.AccessSecret), nil |
|||
}) |
|||
|
|||
if err != nil { |
|||
return nil, fmt.Errorf("解析 token 失败: %w", err) |
|||
} |
|||
|
|||
// 验证 token 是否有效
|
|||
if !token.Valid { |
|||
return nil, errors.New("无效的 token") |
|||
} |
|||
|
|||
// 获取声明
|
|||
claims, ok := token.Claims.(*JWTClaims) |
|||
if !ok { |
|||
return nil, errors.New("无法解析 token 声明") |
|||
} |
|||
|
|||
// 验证 token 是否在 Redis 中存在
|
|||
userID, err := j.RedisClient.Get(ctx, tokenString).Result() |
|||
if err != nil { |
|||
if err == redis.Nil { |
|||
return nil, errors.New("token 已过期或不存在") |
|||
} |
|||
return nil, fmt.Errorf("从 Redis 获取 token 失败: %w", err) |
|||
} |
|||
|
|||
// 验证 Redis 中的 user_id 是否与 token 中的一致
|
|||
redisUserID, err := strconv.ParseInt(userID, 10, 64) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("解析 Redis 中的 user_id 失败: %w", err) |
|||
} |
|||
|
|||
if redisUserID != claims.UserID { |
|||
return nil, errors.New("token 中的用户ID与 Redis 中的不匹配") |
|||
} |
|||
|
|||
return claims, nil |
|||
} |
|||
|
|||
// 刷新 token
|
|||
func (j *JWTUtil) RefreshToken(ctx context.Context, tokenString string) (string, error) { |
|||
// 先验证当前 token
|
|||
claims, err := j.ValidateToken(ctx, tokenString) |
|||
if err != nil { |
|||
return "", fmt.Errorf("验证旧 token 失败: %w", err) |
|||
} |
|||
|
|||
// 删除旧的 token
|
|||
err = j.RedisClient.Del(ctx, tokenString).Err() |
|||
if err != nil { |
|||
return "", fmt.Errorf("删除旧 token 失败: %w", err) |
|||
} |
|||
|
|||
// 生成新的 token
|
|||
return j.GenerateToken(ctx, claims.UserID, claims.Username) |
|||
} |
|||
|
|||
// 删除 token(登出)
|
|||
func (j *JWTUtil) DeleteToken(ctx context.Context, tokenString string) error { |
|||
// 从 Redis 中删除 token
|
|||
err := j.RedisClient.Del(ctx, tokenString).Err() |
|||
if err != nil { |
|||
return fmt.Errorf("从 Redis 删除 token 失败: %w", err) |
|||
} |
|||
return nil |
|||
} |
|||
|
|||
// 检查 token 是否存在
|
|||
func (j *JWTUtil) TokenExists(ctx context.Context, tokenString string) (bool, error) { |
|||
exists, err := j.RedisClient.Exists(ctx, tokenString).Result() |
|||
if err != nil { |
|||
return false, fmt.Errorf("检查 token 是否存在失败: %w", err) |
|||
} |
|||
return exists == 1, nil |
|||
} |
|||
|
|||
// 解析 token(不验证签名,用于获取基本信息)
|
|||
func (j *JWTUtil) ParseTokenUnverified(tokenString string) (*JWTClaims, error) { |
|||
token, _, err := new(jwt.Parser).ParseUnverified(tokenString, &JWTClaims{}) |
|||
if err != nil { |
|||
return nil, fmt.Errorf("解析 token 失败: %w", err) |
|||
} |
|||
|
|||
claims, ok := token.Claims.(*JWTClaims) |
|||
if !ok { |
|||
return nil, errors.New("无法解析 token 声明") |
|||
} |
|||
|
|||
return claims, nil |
|||
} |
|||
|
|||
// 获取用户所有有效的 token(通过用户ID前缀搜索)
|
|||
func (j *JWTUtil) GetUserTokens(ctx context.Context, userID int64) ([]string, error) { |
|||
// 使用 SCAN 命令搜索包含用户ID的 token
|
|||
var tokens []string |
|||
iter := j.RedisClient.Scan(ctx, 0, "*", 0).Iterator() |
|||
|
|||
for iter.Next(ctx) { |
|||
key := iter.Val() |
|||
// 获取该 key 对应的 value
|
|||
value, err := j.RedisClient.Get(ctx, key).Result() |
|||
if err != nil { |
|||
continue |
|||
} |
|||
|
|||
// 检查 value 是否是目标用户ID
|
|||
if value == strconv.FormatInt(userID, 10) { |
|||
tokens = append(tokens, key) |
|||
} |
|||
} |
|||
|
|||
if err := iter.Err(); err != nil { |
|||
return nil, fmt.Errorf("搜索用户 token 失败: %w", err) |
|||
} |
|||
|
|||
return tokens, nil |
|||
} |
|||
|
|||
// 删除用户所有 token(强制登出)
|
|||
func (j *JWTUtil) DeleteAllUserTokens(ctx context.Context, userID int64) error { |
|||
tokens, err := j.GetUserTokens(ctx, userID) |
|||
if err != nil { |
|||
return fmt.Errorf("获取用户 token 失败: %w", err) |
|||
} |
|||
|
|||
if len(tokens) == 0 { |
|||
return nil |
|||
} |
|||
|
|||
// 批量删除 token
|
|||
err = j.RedisClient.Del(ctx, tokens...).Err() |
|||
if err != nil { |
|||
return fmt.Errorf("批量删除用户 token 失败: %w", err) |
|||
} |
|||
|
|||
return nil |
|||
} |
@ -0,0 +1,327 @@ |
|||
package utils |
|||
|
|||
import ( |
|||
"context" |
|||
"testing" |
|||
"time" |
|||
|
|||
"github.com/redis/go-redis/v9" |
|||
"github.com/stretchr/testify/assert" |
|||
) |
|||
|
|||
// 测试用的 Redis 客户端
|
|||
func setupTestRedis() *redis.Client { |
|||
return redis.NewClient(&redis.Options{ |
|||
Addr: "localhost:6379", |
|||
Password: "", |
|||
DB: 1, // 使用测试数据库
|
|||
}) |
|||
} |
|||
|
|||
// 测试 JWT 工具的基本功能
|
|||
func TestJWTUtil_BasicOperations(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
// 清理测试数据
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("test-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
userID := int64(123) |
|||
username := "testuser" |
|||
|
|||
// 测试生成 token
|
|||
token, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
assert.NoError(t, err) |
|||
assert.NotEmpty(t, token) |
|||
|
|||
// 测试验证 token
|
|||
claims, err := jwtUtil.ValidateToken(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, userID, claims.UserID) |
|||
assert.Equal(t, username, claims.Username) |
|||
|
|||
// 测试 token 是否存在
|
|||
exists, err := jwtUtil.TokenExists(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.True(t, exists) |
|||
|
|||
// 测试解析 token(不验证签名)
|
|||
unverifiedClaims, err := jwtUtil.ParseTokenUnverified(token) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, userID, unverifiedClaims.UserID) |
|||
assert.Equal(t, username, unverifiedClaims.Username) |
|||
|
|||
// 测试删除 token
|
|||
err = jwtUtil.DeleteToken(ctx, token) |
|||
assert.NoError(t, err) |
|||
|
|||
// 验证 token 已被删除
|
|||
exists, err = jwtUtil.TokenExists(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.False(t, exists) |
|||
} |
|||
|
|||
// 测试 token 刷新功能
|
|||
func TestJWTUtil_RefreshToken(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("test-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
userID := int64(456) |
|||
username := "refreshuser" |
|||
|
|||
// 生成原始 token
|
|||
oldToken, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
assert.NoError(t, err) |
|||
|
|||
// 刷新 token
|
|||
newToken, err := jwtUtil.RefreshToken(ctx, oldToken) |
|||
assert.NoError(t, err) |
|||
assert.NotEmpty(t, newToken) |
|||
assert.NotEqual(t, oldToken, newToken) |
|||
|
|||
// 验证新 token
|
|||
claims, err := jwtUtil.ValidateToken(ctx, newToken) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, userID, claims.UserID) |
|||
assert.Equal(t, username, claims.Username) |
|||
|
|||
// 验证旧 token 已失效
|
|||
_, err = jwtUtil.ValidateToken(ctx, oldToken) |
|||
assert.Error(t, err) |
|||
} |
|||
|
|||
// 测试获取和删除用户所有 token
|
|||
func TestJWTUtil_UserTokens(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("test-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
userID := int64(789) |
|||
username := "multiuser" |
|||
|
|||
// 生成多个 token
|
|||
var tokens []string |
|||
for i := 0; i < 3; i++ { |
|||
token, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
assert.NoError(t, err) |
|||
tokens = append(tokens, token) |
|||
} |
|||
|
|||
// 获取用户所有 token
|
|||
userTokens, err := jwtUtil.GetUserTokens(ctx, userID) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, 3, len(userTokens)) |
|||
|
|||
// 删除用户所有 token
|
|||
err = jwtUtil.DeleteAllUserTokens(ctx, userID) |
|||
assert.NoError(t, err) |
|||
|
|||
// 验证所有 token 已被删除
|
|||
for _, token := range tokens { |
|||
exists, err := jwtUtil.TokenExists(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.False(t, exists) |
|||
} |
|||
} |
|||
|
|||
// 测试无效 token
|
|||
func TestJWTUtil_InvalidToken(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("test-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
// 测试无效 token
|
|||
_, err := jwtUtil.ValidateToken(ctx, "invalid-token") |
|||
assert.Error(t, err) |
|||
|
|||
// 测试空 token
|
|||
_, err = jwtUtil.ValidateToken(ctx, "") |
|||
assert.Error(t, err) |
|||
} |
|||
|
|||
// 测试过期 token
|
|||
func TestJWTUtil_ExpiredToken(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
// 创建一个很短过期时间的 JWT 工具
|
|||
jwtUtil := NewJWTUtil("test-secret", 1, redisClient) // 1秒过期
|
|||
ctx := context.Background() |
|||
|
|||
userID := int64(999) |
|||
username := "expireduser" |
|||
|
|||
// 生成 token
|
|||
token, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
assert.NoError(t, err) |
|||
|
|||
// 立即验证应该成功
|
|||
claims, err := jwtUtil.ValidateToken(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, userID, claims.UserID) |
|||
|
|||
// 等待 token 过期
|
|||
time.Sleep(2 * time.Second) |
|||
|
|||
// 验证过期 token 应该失败
|
|||
_, err = jwtUtil.ValidateToken(ctx, token) |
|||
assert.Error(t, err) |
|||
} |
|||
|
|||
// 测试不同签名密钥
|
|||
func TestJWTUtil_DifferentSecret(t *testing.T) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil1 := NewJWTUtil("secret1", 3600, redisClient) |
|||
jwtUtil2 := NewJWTUtil("secret2", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
userID := int64(111) |
|||
username := "secretuser" |
|||
|
|||
// 用第一个工具生成 token
|
|||
token, err := jwtUtil1.GenerateToken(ctx, userID, username) |
|||
assert.NoError(t, err) |
|||
|
|||
// 用第一个工具验证应该成功
|
|||
claims, err := jwtUtil1.ValidateToken(ctx, token) |
|||
assert.NoError(t, err) |
|||
assert.Equal(t, userID, claims.UserID) |
|||
|
|||
// 用第二个工具验证应该失败
|
|||
_, err = jwtUtil2.ValidateToken(ctx, token) |
|||
assert.Error(t, err) |
|||
} |
|||
|
|||
// 基准测试:生成 token
|
|||
func BenchmarkJWTUtil_GenerateToken(b *testing.B) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("bench-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
b.ResetTimer() |
|||
for i := 0; i < b.N; i++ { |
|||
_, err := jwtUtil.GenerateToken(ctx, int64(i), "benchuser") |
|||
if err != nil { |
|||
b.Fatal(err) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 基准测试:验证 token
|
|||
func BenchmarkJWTUtil_ValidateToken(b *testing.B) { |
|||
redisClient := setupTestRedis() |
|||
defer redisClient.Close() |
|||
|
|||
redisClient.FlushDB(context.Background()) |
|||
|
|||
jwtUtil := NewJWTUtil("bench-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
// 预先生成一些 token
|
|||
tokens := make([]string, 1000) |
|||
for i := 0; i < 1000; i++ { |
|||
token, err := jwtUtil.GenerateToken(ctx, int64(i), "benchuser") |
|||
if err != nil { |
|||
b.Fatal(err) |
|||
} |
|||
tokens[i] = token |
|||
} |
|||
|
|||
b.ResetTimer() |
|||
for i := 0; i < b.N; i++ { |
|||
tokenIndex := i % len(tokens) |
|||
_, err := jwtUtil.ValidateToken(ctx, tokens[tokenIndex]) |
|||
if err != nil { |
|||
b.Fatal(err) |
|||
} |
|||
} |
|||
} |
|||
|
|||
// 示例:如何在 HTTP 处理器中使用
|
|||
func ExampleJWTUtil_HTTPHandler() { |
|||
redisClient := redis.NewClient(&redis.Options{ |
|||
Addr: "localhost:6379", |
|||
DB: 0, |
|||
}) |
|||
defer redisClient.Close() |
|||
|
|||
jwtUtil := NewJWTUtil("your-secret", 3600, redisClient) |
|||
ctx := context.Background() |
|||
|
|||
// 模拟登录处理
|
|||
handleLogin := func(userID int64, username string) (string, error) { |
|||
token, err := jwtUtil.GenerateToken(ctx, userID, username) |
|||
if err != nil { |
|||
return "", err |
|||
} |
|||
return token, nil |
|||
} |
|||
|
|||
// 模拟需要认证的处理
|
|||
handleProtected := func(tokenString string) (*JWTClaims, error) { |
|||
claims, err := jwtUtil.ValidateToken(ctx, tokenString) |
|||
if err != nil { |
|||
return nil, err |
|||
} |
|||
return claims, nil |
|||
} |
|||
|
|||
// 模拟登出处理
|
|||
handleLogout := func(tokenString string) error { |
|||
return jwtUtil.DeleteToken(ctx, tokenString) |
|||
} |
|||
|
|||
// 示例使用
|
|||
userID := int64(123) |
|||
username := "exampleuser" |
|||
|
|||
// 登录
|
|||
token, err := handleLogin(userID, username) |
|||
if err != nil { |
|||
panic(err) |
|||
} |
|||
|
|||
// 访问保护的资源
|
|||
claims, err := handleProtected(token) |
|||
if err != nil { |
|||
panic(err) |
|||
} |
|||
|
|||
// 登出
|
|||
err = handleLogout(token) |
|||
if err != nil { |
|||
panic(err) |
|||
} |
|||
|
|||
// 输出结果
|
|||
t := &testing.T{} |
|||
assert.NotEmpty(t, token) |
|||
assert.Equal(t, userID, claims.UserID) |
|||
assert.Equal(t, username, claims.Username) |
|||
} |
@ -0,0 +1,14 @@ |
|||
server{ |
|||
listen 8888; |
|||
access_log /var/log/nginx/jsj2025.com_access.log; |
|||
error_log /var/log/nginx/jsj2025.com_error.log; |
|||
# 用户中心 |
|||
location ~ /usercenter/ { |
|||
proxy_set_header Host $http_host; |
|||
proxy_set_header X-Real-IP $remote_addr; |
|||
proxy_set_header REMOTE-HOST $remote_addr; |
|||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; |
|||
proxy_pass http://backend:8080; |
|||
} |
|||
|
|||
} |
@ -0,0 +1,159 @@ |
|||
2025/07/11 05:07:41 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:07:43 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:07:46 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:07:49 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:07:51 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:07:55 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:08:00 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:08:08 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:08:23 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:08:50 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:09:43 [emerg] 1#1: host not found in upstream "usercenter" in /etc/nginx/conf.d/gateway.conf:11 |
|||
2025/07/11 05:10:06 [notice] 1#1: using the "epoll" event method |
|||
2025/07/11 05:10:06 [notice] 1#1: nginx/1.21.5 |
|||
2025/07/11 05:10:06 [notice] 1#1: built by gcc 10.2.1 20210110 (Debian 10.2.1-6) |
|||
2025/07/11 05:10:06 [notice] 1#1: OS: Linux 5.15.167.4-microsoft-standard-WSL2 |
|||
2025/07/11 05:10:06 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker processes |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 22 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 23 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 24 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 25 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 26 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 27 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 28 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 29 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 30 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 31 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 32 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 33 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 34 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 35 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 36 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 37 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 38 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 39 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 40 |
|||
2025/07/11 05:10:06 [notice] 1#1: start worker process 41 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 3 (SIGQUIT) received, shutting down |
|||
2025/07/11 05:10:22 [notice] 23#23: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 34#34: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 22#22: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 24#24: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 25#25: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 28#28: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 26#26: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 27#27: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 29#29: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 31#31: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 30#30: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 33#33: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 32#32: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 35#35: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 36#36: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 23#23: exiting |
|||
2025/07/11 05:10:22 [notice] 38#38: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 37#37: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 39#39: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 40#40: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 41#41: gracefully shutting down |
|||
2025/07/11 05:10:22 [notice] 34#34: exiting |
|||
2025/07/11 05:10:22 [notice] 22#22: exiting |
|||
2025/07/11 05:10:22 [notice] 24#24: exiting |
|||
2025/07/11 05:10:22 [notice] 25#25: exiting |
|||
2025/07/11 05:10:22 [notice] 28#28: exiting |
|||
2025/07/11 05:10:22 [notice] 26#26: exiting |
|||
2025/07/11 05:10:22 [notice] 27#27: exiting |
|||
2025/07/11 05:10:22 [notice] 29#29: exiting |
|||
2025/07/11 05:10:22 [notice] 31#31: exiting |
|||
2025/07/11 05:10:22 [notice] 30#30: exiting |
|||
2025/07/11 05:10:22 [notice] 33#33: exiting |
|||
2025/07/11 05:10:22 [notice] 32#32: exiting |
|||
2025/07/11 05:10:22 [notice] 35#35: exiting |
|||
2025/07/11 05:10:22 [notice] 36#36: exiting |
|||
2025/07/11 05:10:22 [notice] 23#23: exit |
|||
2025/07/11 05:10:22 [notice] 38#38: exiting |
|||
2025/07/11 05:10:22 [notice] 37#37: exiting |
|||
2025/07/11 05:10:22 [notice] 39#39: exiting |
|||
2025/07/11 05:10:22 [notice] 40#40: exiting |
|||
2025/07/11 05:10:22 [notice] 41#41: exiting |
|||
2025/07/11 05:10:22 [notice] 34#34: exit |
|||
2025/07/11 05:10:22 [notice] 22#22: exit |
|||
2025/07/11 05:10:22 [notice] 24#24: exit |
|||
2025/07/11 05:10:22 [notice] 25#25: exit |
|||
2025/07/11 05:10:22 [notice] 28#28: exit |
|||
2025/07/11 05:10:22 [notice] 26#26: exit |
|||
2025/07/11 05:10:22 [notice] 27#27: exit |
|||
2025/07/11 05:10:22 [notice] 29#29: exit |
|||
2025/07/11 05:10:22 [notice] 31#31: exit |
|||
2025/07/11 05:10:22 [notice] 30#30: exit |
|||
2025/07/11 05:10:22 [notice] 33#33: exit |
|||
2025/07/11 05:10:22 [notice] 32#32: exit |
|||
2025/07/11 05:10:22 [notice] 35#35: exit |
|||
2025/07/11 05:10:22 [notice] 36#36: exit |
|||
2025/07/11 05:10:22 [notice] 38#38: exit |
|||
2025/07/11 05:10:22 [notice] 37#37: exit |
|||
2025/07/11 05:10:22 [notice] 39#39: exit |
|||
2025/07/11 05:10:22 [notice] 40#40: exit |
|||
2025/07/11 05:10:22 [notice] 41#41: exit |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 41 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 22 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 25 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 41 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 38 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 23 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 27 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 29 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 30 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 32 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 35 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 38 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 40 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 37 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 26 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 37 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 24 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 24 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 34 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 28 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 33 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 34 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 39 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 31 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 39 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 29 (SIGIO) received |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 31 |
|||
2025/07/11 05:10:22 [notice] 1#1: signal 17 (SIGCHLD) received from 36 |
|||
2025/07/11 05:10:22 [notice] 1#1: worker process 36 exited with code 0 |
|||
2025/07/11 05:10:22 [notice] 1#1: exit |
|||
2025/07/11 05:10:23 [notice] 1#1: using the "epoll" event method |
|||
2025/07/11 05:10:23 [notice] 1#1: nginx/1.21.5 |
|||
2025/07/11 05:10:23 [notice] 1#1: built by gcc 10.2.1 20210110 (Debian 10.2.1-6) |
|||
2025/07/11 05:10:23 [notice] 1#1: OS: Linux 5.15.167.4-microsoft-standard-WSL2 |
|||
2025/07/11 05:10:23 [notice] 1#1: getrlimit(RLIMIT_NOFILE): 1048576:1048576 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker processes |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 22 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 23 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 24 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 25 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 26 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 27 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 28 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 29 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 30 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 31 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 32 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 33 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 34 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 35 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 36 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 37 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 38 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 39 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 40 |
|||
2025/07/11 05:10:23 [notice] 1#1: start worker process 41 |
Loading…
Reference in new issue